profileShare

rasmusjy / splitapp-backend-microservices

Read-only snapshot

No repository description.

main default branch 501 files Expires Sep 13, 2026, 9:06 AM

Commit

Initial commit: SplitApp microservices split over RabbitMQ

commit 585922f

501 changed files with +115136 and −0

Jump to a changed file
  1. .dockerignore +20 −0
  2. .gitignore +87 −0
  3. Dockerfile +42 −0
  4. Dockerfile.usersservice +30 −0
  5. LICENSE +21 −0
  6. README.md +227 −0
  7. SplitApp.Modular/Directory.Build.props +9 −0
  8. SplitApp.Modular/README.md +96 −0
  9. SplitApp.Modular/SplitApp.sln +375 −0
  10. SplitApp.Modular/docs/ARCHITECTURE.md +191 −0
  11. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor +3 −0
  12. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor.css +6 −0
  13. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/CurrenciesController.cs +39 −0
  14. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/ExpensesController.cs +277 −0
  15. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SettlementsController.cs +290 −0
  16. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SplitPresetsController.cs +208 −0
  17. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/CurrencyDto.cs +9 −0
  18. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/ExpenseDto.cs +49 −0
  19. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SettlementDto.cs +37 −0
  20. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SplitPresetDto.cs +35 −0
  21. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/ExampleJsInterop.cs +31 −0
  22. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/SplitApp.Modules.Expenses.Api.csproj +27 −0
  23. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/_Imports.razor +1 −0
  24. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/background.png +0 −0
  25. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/exampleJsInterop.js +6 −0
  26. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/Contracts/IExpensesUnitOfWork.cs +15 −0
  27. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/CurrencyConverter.cs +22 −0
  28. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/ExpensesModuleMarker.cs +4 −0
  29. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/SplitApp.Modules.Expenses.Application.csproj +19 −0
  30. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Currency.cs +16 −0
  31. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Expense.cs +39 −0
  32. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/ExpenseSplit.cs +19 −0
  33. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPayment.cs +30 −0
  34. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPlan.cs +27 −0
  35. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPreset.cs +27 −0
  36. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPresetMember.cs +19 −0
  37. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/EPaymentStatus.cs +8 −0
  38. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESettlementStatus.cs +8 −0
  39. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESplitMethod.cs +9 −0
  40. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/SplitApp.Modules.Expenses.Domain.csproj +14 −0
  41. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/ExpensesModuleExtensions.cs +64 −0
  42. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/CalculateSettlementHandler.cs +113 −0
  43. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetBudgetCategorySpentHandler.cs +30 −0
  44. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetCurrenciesByIdsHandler.cs +28 −0
  45. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetTripExpenseTotalsHandler.cs +34 −0
  46. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/RemoveSettlementPlanHandler.cs +37 −0
  47. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/TripDeletedHandler.cs +55 −0
  48. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesDbContext.cs +78 −0
  49. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesUnitOfWork.cs +33 −0
  50. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.Designer.cs +330 −0
  51. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.cs +235 −0
  52. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/ExpensesDbContextModelSnapshot.cs +327 −0
  53. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Repositories/ExpensesBaseRepository.cs +29 −0
  54. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
  55. SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj +30 −0
  56. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor +3 −0
  57. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor.css +6 −0
  58. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/BudgetCategoriesController.cs +146 −0
  59. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/InvitationsController.cs +200 −0
  60. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/PollsController.cs +255 −0
  61. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/TripsController.cs +329 −0
  62. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/WishlistController.cs +242 −0
  63. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/BudgetCategoryDto.cs +21 −0
  64. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/InvitationDto.cs +17 −0
  65. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/PollDto.cs +36 −0
  66. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/TripDto.cs +53 −0
  67. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/WishlistItemDto.cs +32 −0
  68. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/ExampleJsInterop.cs +31 −0
  69. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/SplitApp.Modules.Trips.Api.csproj +27 −0
  70. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/_Imports.razor +1 −0
  71. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/background.png +0 −0
  72. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/exampleJsInterop.js +6 −0
  73. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Contracts/ITripsUnitOfWork.cs +18 −0
  74. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Handlers/GetTripByIdHandler.cs +23 −0
  75. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/SplitApp.Modules.Trips.Application.csproj +19 −0
  76. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/TripsModuleMarker.cs +4 −0
  77. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/BudgetCategory.cs +25 −0
  78. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/Trip.cs +56 −0
  79. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripInvitation.cs +24 −0
  80. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripParticipant.cs +27 −0
  81. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPoll.cs +26 −0
  82. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollOption.cs +17 −0
  83. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollVote.cs +14 −0
  84. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistItem.cs +40 −0
  85. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistVote.cs +16 −0
  86. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EInvitationStatus.cs +10 −0
  87. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EParticipantRole.cs +7 −0
  88. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/ETripStatus.cs +9 −0
  89. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistCategory.cs +9 −0
  90. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistPriority.cs +8 −0
  91. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/SplitApp.Modules.Trips.Domain.csproj +15 −0
  92. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetBudgetCategoryNamesByIdsHandler.cs +30 −0
  93. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetTripParticipantsHandler.cs +27 −0
  94. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/IsTripParticipantHandler.cs +25 −0
  95. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/SettlementPlanCompletedHandler.cs +32 −0
  96. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.Designer.cs +495 −0
  97. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.cs +350 −0
  98. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/TripsDbContextModelSnapshot.cs +492 −0
  99. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Repositories/TripsBaseRepository.cs +29 −0
  100. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsDbContext.cs +96 −0
  101. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsUnitOfWork.cs +38 −0
  102. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
  103. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj +30 −0
  104. SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/TripsModuleExtensions.cs +40 −0
  105. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor +3 −0
  106. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor.css +6 −0
  107. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Controllers/AccountController.cs +145 −0
  108. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Controllers/AdminUsersController.cs +139 −0
  109. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/Admin/AdminUserDtos.cs +21 −0
  110. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/JWTResponse.cs +9 −0
  111. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LoginInfo.cs +7 −0
  112. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LogoutInfo.cs +6 −0
  113. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RegisterInfo.cs +9 −0
  114. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RestApiErrorResponse.cs +9 −0
  115. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/TokenRefreshInfo.cs +7 −0
  116. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/ExampleJsInterop.cs +31 −0
  117. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/SplitApp.Modules.Users.Api.csproj +28 −0
  118. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/_Imports.razor +1 −0
  119. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/background.png +0 −0
  120. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/exampleJsInterop.js +6 −0
  121. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IRefreshTokenRepository.cs +12 −0
  122. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUserRepository.cs +12 −0
  123. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUsersUnitOfWork.cs +9 −0
  124. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IIdentityService.cs +9 −0
  125. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityService.cs +270 −0
  126. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityServiceModels.cs +63 −0
  127. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/SplitApp.Modules.Users.Application.csproj +23 −0
  128. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/UsersModuleMarker.cs +4 −0
  129. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRefreshToken.cs +20 −0
  130. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRole.cs +8 −0
  131. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppUser.cs +16 −0
  132. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/SplitApp.Modules.Users.Domain.csproj +17 −0
  133. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.Designer.cs +359 −0
  134. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.cs +310 −0
  135. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs +356 −0
  136. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/BaseRepository.cs +29 −0
  137. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs +42 −0
  138. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/UserRepository.cs +40 −0
  139. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersDbContext.cs +66 −0
  140. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersUnitOfWork.cs +23 −0
  141. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
  142. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj +34 −0
  143. SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/UsersModuleExtensions.cs +141 −0
  144. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Controllers/HealthController.cs +19 −0
  145. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Hosting/ConfigureSwaggerOptions.cs +58 −0
  146. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Hosting/SeededHealthState.cs +13 −0
  147. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Messaging/GetUserByIdRequestHandler.cs +19 −0
  148. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Messaging/GetUsersByIdsRequestHandler.cs +21 −0
  149. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Messaging/UserDtoMapper.cs +12 −0
  150. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Program.cs +94 −0
  151. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Properties/launchSettings.json +13 −0
  152. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/SplitApp.UsersService.csproj +27 −0
  153. SplitApp.Modular/src/Services/Users/SplitApp.UsersService/appsettings.json +29 −0
  154. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/CalculateSettlementCommand.cs +11 −0
  155. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/RemoveSettlementPlanCommand.cs +8 −0
  156. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/CurrencyDto.cs +7 −0
  157. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/ExpenseSettledEvent.cs +5 −0
  158. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/SettlementPlanCompletedEvent.cs +10 −0
  159. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetBudgetCategorySpentQuery.cs +6 −0
  160. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetCurrenciesByIdsQuery.cs +6 −0
  161. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetTripExpenseTotalsQuery.cs +5 −0
  162. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/TripExpenseTotalsDto.cs +7 −0
  163. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/SplitApp.Shared.Contracts.csproj +17 −0
  164. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/BudgetCategoryNameDto.cs +6 −0
  165. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Events/TripDeletedEvent.cs +5 −0
  166. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetBudgetCategoryNamesByIdsQuery.cs +6 −0
  167. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripByIdQuery.cs +5 −0
  168. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripParticipantsQuery.cs +5 −0
  169. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/IsTripParticipantQuery.cs +5 −0
  170. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripParticipantDto.cs +11 −0
  171. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripSummaryDto.cs +8 −0
  172. SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/UserDto.cs +7 −0
  173. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Auth/IdentityHelpers.cs +59 −0
  174. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/BaseEntity.cs +8 −0
  175. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/IBaseEntity.cs +6 −0
  176. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Localization/LangStr.cs +73 −0
  177. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IBaseRepository.cs +13 −0
  178. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IUnitOfWork.cs +6 −0
  179. SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/SplitApp.Shared.Kernel.csproj +14 −0
  180. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IEventHandler.cs +6 −0
  181. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IIntegrationEvent.cs +6 −0
  182. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IIntegrationRequest.cs +6 −0
  183. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IMessageBus.cs +13 −0
  184. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IRequestHandler.cs +6 −0
  185. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/GetUserByIdRequest.cs +6 −0
  186. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/GetUsersByIdsRequest.cs +6 −0
  187. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/IUserLookup.cs +16 −0
  188. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/UserDeletedEvent.cs +7 −0
  189. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/UserLookup.cs +64 −0
  190. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/HandlerRegistry.cs +15 −0
  191. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/MessageDispatcherFactory.cs +39 −0
  192. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/MessageHandlerRegistration.cs +27 −0
  193. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/RabbitMqBus.cs +228 −0
  194. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/RabbitMqConnectionProvider.cs +94 −0
  195. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/RabbitMqConsumerHostedService.cs +181 −0
  196. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/MessageBusUnavailableException.cs +12 −0
  197. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/MessagingOptions.cs +20 −0
  198. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/RabbitMqTopology.cs +14 −0
  199. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/ServiceCollectionExtensions.cs +100 −0
  200. SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/SplitApp.Shared.Messaging.csproj +22 −0
  201. SplitApp.Modular/src/SplitApp.WebApp/Application/Contracts/IAppUnitOfWork.cs +82 −0
  202. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/AppUserBllDto.cs +11 −0
  203. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BalanceBllDto.cs +10 −0
  204. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BudgetCategoryBllDto.cs +22 −0
  205. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/CurrencyBllDto.cs +15 −0
  206. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseBllDto.cs +37 −0
  207. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseSplitBllDto.cs +19 −0
  208. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPaymentBllDto.cs +30 −0
  209. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPlanBllDto.cs +29 −0
  210. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SplitPresetBllDto.cs +41 −0
  211. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripBllDto.cs +38 −0
  212. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripInvitationBllDto.cs +29 −0
  213. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripParticipantBllDto.cs +29 −0
  214. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollBllDto.cs +26 −0
  215. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollOptionBllDto.cs +18 −0
  216. SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripWishlistItemBllDto.cs +37 −0
  217. SplitApp.Modular/src/SplitApp.WebApp/Application/Helpers/CurrencyConverter.cs +30 −0
  218. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/AppUserBllDtoFactory.cs +26 −0
  219. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/BudgetCategoryBllDtoFactory.cs +42 −0
  220. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/CurrencyBllDtoFactory.cs +31 −0
  221. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/ExpenseBllDtoFactory.cs +71 −0
  222. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/InvitationBllDtoFactory.cs +36 −0
  223. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/PollBllDtoFactory.cs +63 −0
  224. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SettlementBllDtoFactory.cs +73 −0
  225. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SplitPresetBllDtoFactory.cs +61 −0
  226. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/TripBllDtoFactory.cs +86 −0
  227. SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/WishlistBllDtoFactory.cs +50 −0
  228. SplitApp.Modular/src/SplitApp.WebApp/Application/Messaging/UserDeletedEventHandler.cs +47 −0
  229. SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs +583 −0
  230. SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs +177 −0
  231. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminDashboardData.cs +66 −0
  232. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminStatsService.cs +202 −0
  233. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/BudgetCategoryAdminService.cs +80 −0
  234. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/CurrencyAdminService.cs +72 −0
  235. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ExpenseAdminService.cs +97 −0
  236. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IAdminStatsService.cs +6 −0
  237. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IBudgetCategoryAdminService.cs +14 −0
  238. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ICurrencyAdminService.cs +13 −0
  239. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IExpenseAdminService.cs +18 −0
  240. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IInvitationAdminService.cs +15 −0
  241. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IPollAdminService.cs +16 −0
  242. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPaymentAdminService.cs +15 −0
  243. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPlanAdminService.cs +16 −0
  244. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISplitPresetAdminService.cs +14 −0
  245. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripAdminService.cs +14 −0
  246. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripParticipantAdminService.cs +16 −0
  247. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IWishlistAdminService.cs +16 −0
  248. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/InvitationAdminService.cs +80 −0
  249. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/PollAdminService.cs +80 −0
  250. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPaymentAdminService.cs +81 −0
  251. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPlanAdminService.cs +79 −0
  252. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SplitPresetAdminService.cs +64 −0
  253. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripAdminService.cs +70 −0
  254. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripParticipantAdminService.cs +88 −0
  255. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/WishlistAdminService.cs +86 −0
  256. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/BudgetCategoryService.cs +86 −0
  257. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ExpenseService.cs +347 −0
  258. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IBudgetCategoryService.cs +14 −0
  259. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IExpenseService.cs +43 −0
  260. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IInvitationService.cs +29 −0
  261. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IPollService.cs +27 −0
  262. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISettlementService.cs +45 −0
  263. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISplitPresetService.cs +19 −0
  264. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ITripService.cs +38 −0
  265. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IWishlistService.cs +16 −0
  266. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/InvitationService.cs +192 −0
  267. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/PollService.cs +206 −0
  268. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SettlementService.cs +337 −0
  269. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SplitPresetService.cs +123 −0
  270. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/TripService.cs +239 −0
  271. SplitApp.Modular/src/SplitApp.WebApp/Application/Services/WishlistService.cs +148 −0
  272. SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/Dtos/AccountDtos.cs +43 −0
  273. SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/Dtos/AdminDtos.cs +28 −0
  274. SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/IUsersServiceClient.cs +37 −0
  275. SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/JwtForwardingHandler.cs +47 −0
  276. SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/UsersServiceClient.cs +141 −0
  277. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +146 −0
  278. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/CurrenciesController.cs +133 −0
  279. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/DashboardController.cs +88 −0
  280. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/ExpensesController.cs +148 −0
  281. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/InvitationsController.cs +157 −0
  282. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/PollsController.cs +128 −0
  283. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +152 −0
  284. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPlansController.cs +186 −0
  285. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SplitPresetsController.cs +114 −0
  286. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +187 −0
  287. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripsController.cs +139 −0
  288. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/UsersController.cs +139 −0
  289. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/WishlistController.cs +128 −0
  290. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Models/AdminViewModels.cs +287 −0
  291. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Create.cshtml +55 −0
  292. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Delete.cshtml +25 −0
  293. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Details.cshtml +28 −0
  294. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Edit.cshtml +56 −0
  295. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Index.cshtml +76 −0
  296. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Create.cshtml +45 −0
  297. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Delete.cshtml +25 −0
  298. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Details.cshtml +22 −0
  299. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Edit.cshtml +46 −0
  300. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Index.cshtml +65 −0
  301. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Dashboard/Index.cshtml +373 −0
  302. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Create.cshtml +71 −0
  303. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Delete.cshtml +31 −0
  304. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Details.cshtml +37 −0
  305. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Edit.cshtml +72 −0
  306. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Index.cshtml +80 −0
  307. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Create.cshtml +58 −0
  308. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Delete.cshtml +39 −0
  309. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Details.cshtml +40 −0
  310. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Edit.cshtml +59 −0
  311. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Index.cshtml +65 −0
  312. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Create.cshtml +48 −0
  313. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Delete.cshtml +13 −0
  314. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Details.cshtml +37 −0
  315. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Edit.cshtml +45 −0
  316. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Index.cshtml +73 −0
  317. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Create.cshtml +55 −0
  318. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Delete.cshtml +39 −0
  319. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Details.cshtml +43 −0
  320. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Edit.cshtml +56 −0
  321. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Index.cshtml +67 −0
  322. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Create.cshtml +52 −0
  323. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Delete.cshtml +28 −0
  324. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Details.cshtml +28 −0
  325. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Edit.cshtml +53 −0
  326. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Index.cshtml +73 −0
  327. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Shared/_Layout.cshtml +124 −0
  328. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Create.cshtml +52 −0
  329. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Delete.cshtml +28 −0
  330. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Details.cshtml +50 −0
  331. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Index.cshtml +64 −0
  332. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Create.cshtml +53 −0
  333. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Delete.cshtml +28 −0
  334. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Details.cshtml +34 −0
  335. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Edit.cshtml +54 −0
  336. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Index.cshtml +78 −0
  337. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Create.cshtml +62 −0
  338. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Delete.cshtml +28 −0
  339. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Details.cshtml +37 −0
  340. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Edit.cshtml +64 −0
  341. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Index.cshtml +69 −0
  342. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Delete.cshtml +44 −0
  343. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Details.cshtml +44 −0
  344. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Edit.cshtml +44 −0
  345. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/EditRoles.cshtml +29 −0
  346. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Index.cshtml +46 −0
  347. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Create.cshtml +64 −0
  348. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Delete.cshtml +13 −0
  349. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Details.cshtml +35 −0
  350. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Edit.cshtml +57 −0
  351. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Index.cshtml +71 −0
  352. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewImports.cshtml +15 −0
  353. SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewStart.cshtml +3 −0
  354. SplitApp.Modular/src/SplitApp.WebApp/Controllers/AccountController.cs +124 −0
  355. SplitApp.Modular/src/SplitApp.WebApp/Controllers/BudgetController.cs +209 −0
  356. SplitApp.Modular/src/SplitApp.WebApp/Controllers/ExpensesController.cs +266 −0
  357. SplitApp.Modular/src/SplitApp.WebApp/Controllers/HomeController.cs +35 −0
  358. SplitApp.Modular/src/SplitApp.WebApp/Controllers/MembersController.cs +177 −0
  359. SplitApp.Modular/src/SplitApp.WebApp/Controllers/PollsClientController.cs +144 −0
  360. SplitApp.Modular/src/SplitApp.WebApp/Controllers/SettlementController.cs +190 −0
  361. SplitApp.Modular/src/SplitApp.WebApp/Controllers/TripsController.cs +260 −0
  362. SplitApp.Modular/src/SplitApp.WebApp/Controllers/WishlistClientController.cs +277 −0
  363. SplitApp.Modular/src/SplitApp.WebApp/Hosting/AppDataInit.cs +138 −0
  364. SplitApp.Modular/src/SplitApp.WebApp/Hosting/ConfigureSwaggerOptions.cs +61 −0
  365. SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/CurrencyConverter.cs +30 −0
  366. SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/EnumHelper.cs +18 −0
  367. SplitApp.Modular/src/SplitApp.WebApp/Hosting/InvariantDecimalModelBinderProvider.cs +55 −0
  368. SplitApp.Modular/src/SplitApp.WebApp/Hosting/PassthroughStringLocalizer.cs +28 −0
  369. SplitApp.Modular/src/SplitApp.WebApp/Models/Account/AccountViewModels.cs +47 −0
  370. SplitApp.Modular/src/SplitApp.WebApp/Models/ErrorViewModel.cs +8 −0
  371. SplitApp.Modular/src/SplitApp.WebApp/Program.cs +240 −0
  372. SplitApp.Modular/src/SplitApp.WebApp/Properties/launchSettings.json +23 −0
  373. SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.cs +8 −0
  374. SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.et.resx +70 −0
  375. SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.resx +70 −0
  376. SplitApp.Modular/src/SplitApp.WebApp/Resources/Shared.cs +7 −0
  377. SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.et.resx +513 −0
  378. SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.resx +514 −0
  379. SplitApp.Modular/src/SplitApp.WebApp/SplitApp.WebApp.csproj +40 −0
  380. SplitApp.Modular/src/SplitApp.WebApp/Views/Account/Login.cshtml +30 −0
  381. SplitApp.Modular/src/SplitApp.WebApp/Views/Account/Manage.cshtml +18 −0
  382. SplitApp.Modular/src/SplitApp.WebApp/Views/Account/Register.cshtml +47 −0
  383. SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/CreateCategory.cshtml +71 −0
  384. SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/DeleteCategory.cshtml +51 −0
  385. SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/EditCategory.cshtml +72 −0
  386. SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/Index.cshtml +155 −0
  387. SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Create.cshtml +365 −0
  388. SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Delete.cshtml +51 −0
  389. SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Edit.cshtml +84 −0
  390. SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Index.cshtml +111 −0
  391. SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Index.cshtml +116 −0
  392. SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Privacy.cshtml +6 −0
  393. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/AcceptInvitation.cshtml +60 −0
  394. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Index.cshtml +125 −0
  395. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InvitationInvalid.cshtml +24 −0
  396. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Invite.cshtml +34 −0
  397. SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InviteGenerated.cshtml +40 −0
  398. SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Create.cshtml +83 −0
  399. SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Details.cshtml +126 −0
  400. SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Index.cshtml +99 −0
  401. SplitApp.Modular/src/SplitApp.WebApp/Views/Settlement/Index.cshtml +232 −0
  402. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/Error.cshtml +29 −0
  403. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LanguageSelection.cshtml +24 −0
  404. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml +112 −0
  405. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml.css +48 −0
  406. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LoginPartial.cshtml +45 −0
  407. SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_ValidationScriptsPartial.cshtml +2 −0
  408. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Create.cshtml +82 −0
  409. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Delete.cshtml +56 −0
  410. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Details.cshtml +291 −0
  411. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Edit.cshtml +87 −0
  412. SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Index.cshtml +104 −0
  413. SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Create.cshtml +85 −0
  414. SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Delete.cshtml +45 −0
  415. SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Edit.cshtml +77 −0
  416. SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Index.cshtml +147 −0
  417. SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewImports.cshtml +14 −0
  418. SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewStart.cshtml +3 −0
  419. SplitApp.Modular/src/SplitApp.WebApp/appsettings.json +39 −0
  420. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/admin.css +439 −0
  421. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/site.css +31 −0
  422. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/splitapp-design.css +1605 −0
  423. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/favicon.ico +0 −0
  424. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/site.js +4 −0
  425. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/splitapp.js +323 −0
  426. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/LICENSE +22 −0
  427. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css +4085 −0
  428. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map +1 −0
  429. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css +6 −0
  430. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map +1 −0
  431. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css +4084 −0
  432. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map +1 −0
  433. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css +6 −0
  434. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map +1 −0
  435. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css +597 −0
  436. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map +1 −0
  437. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css +6 −0
  438. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map +1 −0
  439. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css +594 −0
  440. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map +1 −0
  441. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css +6 −0
  442. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map +1 −0
  443. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css +5402 −0
  444. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map +1 −0
  445. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css +6 −0
  446. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map +1 −0
  447. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css +5393 −0
  448. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map +1 −0
  449. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css +6 −0
  450. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map +1 −0
  451. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css +12057 −0
  452. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map +1 −0
  453. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css +6 −0
  454. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map +1 −0
  455. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css +12030 −0
  456. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map +1 −0
  457. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css +6 −0
  458. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map +1 −0
  459. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js +6314 −0
  460. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map +1 −0
  461. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js +7 −0
  462. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map +1 −0
  463. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js +4447 −0
  464. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map +1 −0
  465. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js +7 −0
  466. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map +1 −0
  467. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js +4494 −0
  468. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map +1 −0
  469. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js +7 −0
  470. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map +1 −0
  471. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt +23 −0
  472. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.js +435 −0
  473. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js +8 −0
  474. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/LICENSE.md +22 −0
  475. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/dist/additional-methods.js +1505 −0
  476. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/dist/additional-methods.min.js +4 −0
  477. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/dist/jquery.validate.js +1703 −0
  478. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/dist/jquery.validate.min.js +4 −0
  479. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/LICENSE.txt +21 −0
  480. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.js +0 −0
  481. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.min.js +2 −0
  482. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.min.map +1 −0
  483. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.slim.js +8617 −0
  484. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.slim.min.js +2 −0
  485. SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.slim.min.map +1 −0
  486. SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/CurrencyConverterTests.cs +61 −0
  487. SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/SplitApp.Modules.Expenses.Tests.csproj +25 −0
  488. SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/LangStrTests.cs +86 −0
  489. SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/SplitApp.Modules.Trips.Tests.csproj +25 −0
  490. SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/IdentityHelpersTests.cs +105 −0
  491. SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/SplitApp.Modules.Users.Tests.csproj +25 −0
  492. SplitApp.Modular/tests/SplitApp.Shared.Messaging.Tests/IntegrationContractTests.cs +103 −0
  493. SplitApp.Modular/tests/SplitApp.Shared.Messaging.Tests/SplitApp.Shared.Messaging.Tests.csproj +23 −0
  494. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/CrossModuleNavigationTests.cs +90 −0
  495. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/DbContextSchemaIsolationTests.cs +70 −0
  496. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs +87 −0
  497. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs +59 −0
  498. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostFeatureTests.cs +87 −0
  499. SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/SplitApp.WebApp.IntegrationTests.csproj +29 −0
  500. architecture.md +263 −0
  501. docker-compose.yml +93 −0
added .dockerignore +20 −0
@@ -0,0 +1,20 @@
1 +**/.git
2 +**/.gitignore
3 +**/.vs
4 +**/.vscode
5 +**/.idea
6 +**/bin
7 +**/obj
8 +**/node_modules
9 +**/out
10 +**/.env
11 +**/.env.*
12 +**/appsettings.Development.json
13 +**/*.user
14 +**/*.suo
15 +**/docker-compose*.yml
16 +**/Dockerfile*
17 +**/.dockerignore
18 +**/README.md
19 +**/LICENSE
20 +**/*.md
added .gitignore +87 −0
@@ -0,0 +1,87 @@
1 +## Credentials
2 +credentials.md
3 +**/credentials.md
4 +
5 +## .NET
6 +bin/
7 +obj/
8 +*.user
9 +*.suo
10 +*.userosscache
11 +*.sln.docstates
12 +*.userprefs
13 +
14 +## Visual Studio
15 +.vs/
16 +*.rsuser
17 +[Dd]ebug/
18 +[Rr]elease/
19 +x64/
20 +x86/
21 +[Bb]uild/
22 +bld/
23 +
24 +## JetBrains Rider
25 +.idea/
26 +*.sln.iml
27 +
28 +## VS Code
29 +.vscode/
30 +
31 +## NuGet
32 +*.nupkg
33 +**/[Pp]ackages/*
34 +!**/[Pp]ackages/build/
35 +
36 +## Build results
37 +[Dd]ebugPublic/
38 +[Rr]eleases/
39 +[Aa]rtifacts/
40 +*_i.c
41 +*_p.c
42 +*_h.h
43 +*.ilk
44 +*.meta
45 +*.obj
46 +*.iobj
47 +*.pch
48 +*.pdb
49 +*.ipdb
50 +*.pgc
51 +*.pgd
52 +*.rsp
53 +*.sbr
54 +*.tlb
55 +*.tli
56 +*.tlh
57 +*.tmp
58 +*.tmp_proj
59 +*_wpftmp.csproj
60 +*.log
61 +*.vspscc
62 +*.vssscc
63 +.builds
64 +*.pidb
65 +*.svclog
66 +*.scc
67 +
68 +## OS files
69 +Thumbs.db
70 +ehthumbs.db
71 +Desktop.ini
72 +.DS_Store
73 +
74 +## Environment
75 +.env
76 +.env.*
77 +appsettings.Development.json
78 +
79 +## Docker
80 +docker-compose.override.yml
81 +
82 +## Node (if applicable)
83 +node_modules/
84 +npm-debug.log*
85 +
86 +## Publish output
87 +publish/
added Dockerfile +42 −0
@@ -0,0 +1,42 @@
1 +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
2 +WORKDIR /src
3 +
4 +# Copy solution + Directory.Build.props
5 +COPY SplitApp.Modular/SplitApp.sln .
6 +COPY SplitApp.Modular/Directory.Build.props .
7 +
8 +# Copy all csproj files first for restore-layer caching
9 +COPY SplitApp.Modular/src/SplitApp.WebApp/*.csproj src/SplitApp.WebApp/
10 +COPY SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/*.csproj src/Shared/SplitApp.Shared.Kernel/
11 +COPY SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/*.csproj src/Shared/SplitApp.Shared.Contracts/
12 +COPY SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/*.csproj src/Shared/SplitApp.Shared.Messaging/
13 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/*.csproj src/Modules/Users/SplitApp.Modules.Users.Domain/
14 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/*.csproj src/Modules/Users/SplitApp.Modules.Users.Application/
15 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/*.csproj src/Modules/Users/SplitApp.Modules.Users.Infrastructure/
16 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/*.csproj src/Modules/Users/SplitApp.Modules.Users.Api/
17 +COPY SplitApp.Modular/src/Services/Users/SplitApp.UsersService/*.csproj src/Services/Users/SplitApp.UsersService/
18 +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Domain/
19 +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Application/
20 +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/
21 +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Api/
22 +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/
23 +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Application/
24 +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/
25 +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Api/
26 +COPY SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/*.csproj tests/SplitApp.Modules.Users.Tests/
27 +COPY SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/*.csproj tests/SplitApp.Modules.Trips.Tests/
28 +COPY SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/*.csproj tests/SplitApp.Modules.Expenses.Tests/
29 +COPY SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/*.csproj tests/SplitApp.WebApp.IntegrationTests/
30 +
31 +RUN dotnet restore src/SplitApp.WebApp/SplitApp.WebApp.csproj
32 +
33 +# Copy everything else and publish the host
34 +COPY SplitApp.Modular/ .
35 +RUN dotnet publish src/SplitApp.WebApp/SplitApp.WebApp.csproj -c Release -o /app/publish
36 +
37 +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
38 +WORKDIR /app
39 +COPY --from=build /app/publish .
40 +ENV ASPNETCORE_URLS=http://+:8080
41 +EXPOSE 8080
42 +ENTRYPOINT ["dotnet", "SplitApp.WebApp.dll"]
added Dockerfile.usersservice +30 −0
@@ -0,0 +1,30 @@
1 +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
2 +WORKDIR /src
3 +
4 +COPY SplitApp.Modular/SplitApp.sln .
5 +COPY SplitApp.Modular/Directory.Build.props .
6 +
7 +# csproj first for restore caching
8 +COPY SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/*.csproj src/Shared/SplitApp.Shared.Kernel/
9 +COPY SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/*.csproj src/Shared/SplitApp.Shared.Contracts/
10 +COPY SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/*.csproj src/Shared/SplitApp.Shared.Messaging/
11 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/*.csproj src/Modules/Users/SplitApp.Modules.Users.Domain/
12 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/*.csproj src/Modules/Users/SplitApp.Modules.Users.Application/
13 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/*.csproj src/Modules/Users/SplitApp.Modules.Users.Infrastructure/
14 +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/*.csproj src/Modules/Users/SplitApp.Modules.Users.Api/
15 +COPY SplitApp.Modular/src/Services/Users/SplitApp.UsersService/*.csproj src/Services/Users/SplitApp.UsersService/
16 +
17 +RUN dotnet restore src/Services/Users/SplitApp.UsersService/SplitApp.UsersService.csproj
18 +
19 +# Now copy everything and publish
20 +COPY SplitApp.Modular/ .
21 +RUN dotnet publish src/Services/Users/SplitApp.UsersService/SplitApp.UsersService.csproj -c Release -o /app/publish
22 +
23 +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
24 +WORKDIR /app
25 +# curl for the compose healthcheck
26 +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
27 +COPY --from=build /app/publish .
28 +ENV ASPNETCORE_URLS=http://+:8080
29 +EXPOSE 8080
30 +ENTRYPOINT ["dotnet", "SplitApp.UsersService.dll"]
added LICENSE +21 −0
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2026 Rasmus Jürgenson
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
added README.md +227 −0
@@ -0,0 +1,227 @@
1 +# SplitApp — Trip Expense Management (Microservices)
2 +
3 +SplitApp is an ASP.NET Core 10.0 web application for managing group trips and splitting expenses. Users create trips, invite friends, track costs with flexible splitting (equal / equal-subset / exact / percentage), manage budgets, run polls, maintain a wishlist, and settle debts via an optimized algorithm.
4 +
5 +This repo is the **microservices** implementation. It builds on the modular-monolith version (`splitapp-backend-modular-monolith`) by extracting the **Users module into a separate microservice** (`SplitApp.UsersService`) with its own database. The two processes communicate via **RabbitMQ** (inter-service RPC + events) and **HTTP REST** (login / register / admin CRUD). Runs locally via `docker compose` (webapp + users-service + RabbitMQ + two Postgres databases).
6 +
7 +---
8 +
9 +## Run
10 +
11 +```bash
12 +docker compose -p splitapp-phase4 up --build -d
13 +```
14 +
15 +Brings up five containers:
16 +
17 +| Service | Container | Host port | Notes |
18 +|---|---|---|---|
19 +| `webapp` | `phase4-webapp` | http://localhost:97 | The MVC monolith (Trips + Expenses + Admin UX) |
20 +| `users-service` | `phase4-users-service` | http://localhost:98 | The Users microservice — Identity, JWT issuance, admin REST API |
21 +| `rabbitmq` | `phase4-rabbitmq` | http://localhost:15672 (UI), :5672 | Message broker (guest / guest) |
22 +| `db-monolith` | `phase4-db-monolith` | (internal) | PostgreSQL 16, `splitapp` database (Trips + Expenses) |
23 +| `db-users` | `phase4-db-users` | (internal) | PostgreSQL 16, `splitapp_users` database (Identity + RefreshTokens) |
24 +
25 +Module migrations run automatically on each service's startup. The `users-service` seeds the 6 test users; the `webapp` skips example-trip seeding (the Users service doesn't expose seed data without admin auth — see "Known limitations" below).
26 +
27 +Test login (after seed):
28 +- `user@example.com`, `alice@example.com`, `bob@example.com`, `charlie@example.com`, `diana@example.com` / `Kala.12345` — regular users. The password is in the source on purpose: this is a demo and the data is invented.
29 +- The `admin` account is seeded only when `SEED_ADMIN_PASSWORD` is set, and there is no default. Without the variable there is no administrator at all.
30 +
31 +Stop:
32 +
33 +```bash
34 +docker compose -p splitapp-phase4 down # keep data volumes
35 +docker compose -p splitapp-phase4 down -v # also drop the named volumes
36 +```
37 +
38 +---
39 +
40 +## Architecture at a glance
41 +
42 +```
43 +┌──────────────────────┐ HTTP REST (login/register/admin) ┌──────────────────────┐
44 +│ SplitApp.WebApp │ ◄──────────────────────────────────► │ SplitApp.UsersService│
45 +│ (monolith, port 97) │ │ (microservice, 98) │
46 +│ │ RabbitMQ (RPC + events, JSON) │ │
47 +│ - Trips module │ ◄──────────────────────────────────► │ - Users.Domain │
48 +│ - Expenses module │ │ - Users.Application │
49 +│ - MVC client UX │ │ - Users.Infrastructure│
50 +│ - Full Admin UX │ │ - Users.Api (REST) │
51 +│ - JWT (validate) │ │ - Identity + UoW │
52 +│ - No Users.* refs │ │ - JWT issuer │
53 +└──────────┬───────────┘ └───────────┬───────────┘
54 + │ │
55 + ▼ ▼
56 + ┌───────────────┐ ┌───────────────┐
57 + │ db-monolith │ ┌──────────────┐ │ db-users │
58 + │ (Postgres 16) │ │ rabbitmq │ │ (Postgres 16) │
59 + │ trips + exp. │ │ exchanges: │ │ Identity + │
60 + │ │ │ splitapp. │ │ refresh tokens│
61 + │ │ │ events │ │ │
62 + │ │ │ splitapp. │ │ │
63 + │ │ │ requests │ │ │
64 + └───────────────┘ └──────────────┘ └───────────────┘
65 +```
66 +
67 +**Communication contract:**
68 +
69 +| Concern | Channel | Direction |
70 +|---|---|---|
71 +| Login / Register / Refresh / Logout | HTTP `/api/v1/identity/account/*` | WebApp → UsersService |
72 +| Admin user list / edit / delete / roles | HTTP `/api/v1/identity/admin/*` | WebApp → UsersService |
73 +| Inter-module user lookup (id → DisplayName/Email) | RabbitMQ RPC (`GetUserByIdRequest`, `GetUsersByIdsRequest`) | WebApp → UsersService |
74 +| User deletion fan-out | RabbitMQ event (`UserDeletedEvent`) | UsersService → WebApp |
75 +| JWT validation | Local (shared HS256 key in config) | both services validate identically |
76 +
77 +**Reference rules:**
78 +- `SplitApp.WebApp` has **zero project references** to any `SplitApp.Modules.Users.*` project.
79 +- Cross-process inter-module function calls go through **RabbitMQ only** (via the `IUserLookup` abstraction in `SplitApp.Shared.Messaging`).
80 +- `Trips.Api` + `Expenses.Api` controllers + `AppUnitOfWork` use `IUserLookup` (RabbitMQ RPC), not in-process MediatR, for user data.
81 +- Inside the monolith (Trips ↔ Expenses), MediatR is still used in-process for cross-module calls — only **Users** moved out of process.
82 +
83 +See [architecture.md](architecture.md) for the full architecture deep-dive.
84 +
85 +---
86 +
87 +## Solution layout
88 +
89 +```
90 +SplitApp.Modular/
91 +├── SplitApp.sln
92 +├── Directory.Build.props
93 +├── src/
94 +│ ├── SplitApp.WebApp/ ← monolith host (Trips + Expenses + MVC + Admin)
95 +│ │ ├── Program.cs ← JWT bearer + cookie reader, AddMessaging, AddHttpClient<IUsersServiceClient>
96 +│ │ ├── Application/
97 +│ │ │ ├── Services/ (+ Admin/) ← lifted phase-2 BLL — IUsersServiceClient for user lookups
98 +│ │ │ ├── DTO/ ← BllDtos (no AppUser refs anywhere)
99 +│ │ │ ├── Mappers/ ← Domain ↔ BllDto factories (UserDto-based)
100 +│ │ │ ├── Persistence/AppUnitOfWork.cs ← Trips + Expenses DbContexts, IUserLookup for user hydration
101 +│ │ │ ├── Persistence/CrossModuleNavigationLoader.cs ← uses IUserLookup (RabbitMQ RPC)
102 +│ │ │ ├── UsersService/ ← typed HttpClient + JwtForwardingHandler
103 +│ │ │ └── Messaging/UserDeletedEventHandler.cs ← RabbitMQ event subscriber
104 +│ │ ├── Controllers/AccountController.cs ← MVC login/register/logout (replaces Areas/Identity)
105 +│ │ ├── Areas/Admin/ ← full admin UX, UsersController calls HTTP REST
106 +│ │ └── Resources/ ← i18n .resx (EN + ET)
107 +│ ├── Services/Users/SplitApp.UsersService/ ← NEW microservice host
108 +│ │ ├── Program.cs ← AddUsersModule + MassMessaging consumers + Swagger
109 +│ │ ├── Messaging/ ← GetUserByIdRequestHandler, GetUsersByIdsRequestHandler
110 +│ │ ├── Controllers/HealthController.cs ← /health endpoint (gated on seed completion)
111 +│ │ └── Hosting/ ← Swagger config + SeededHealthState
112 +│ ├── Shared/
113 +│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers
114 +│ │ ├── SplitApp.Shared.Contracts/ ← in-process MediatR contracts (Trips ↔ Expenses)
115 +│ │ └── SplitApp.Shared.Messaging/ ← RabbitMQ wrapper, IUserLookup, integration messages
116 +│ └── Modules/
117 +│ ├── Users/ ← consumed by SplitApp.UsersService (NOT by WebApp)
118 +│ ├── Trips/ ← in-process module in WebApp
119 +│ └── Expenses/ ← in-process module in WebApp
120 +└── tests/
121 + ├── SplitApp.Modules.Users.Tests/
122 + ├── SplitApp.Modules.Trips.Tests/
123 + ├── SplitApp.Modules.Expenses.Tests/
124 + ├── SplitApp.Shared.Messaging.Tests/ ← NEW: integration message contract tests
125 + └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + HTTP smoke
126 +```
127 +
128 +The `Modules/Users/*` projects still exist in the modular monolith layout — but they are consumed only by the new `SplitApp.UsersService` host. The `SplitApp.WebApp.csproj` has zero `<ProjectReference>` entries pointing at any `Users.*` project.
129 +
130 +---
131 +
132 +## URL map
133 +
134 +### Webapp (port 97)
135 +| URL | Purpose |
136 +|---|---|
137 +| `/` | Landing page (MVC) |
138 +| `/Account/{Login, Register, Logout, Manage}` | MVC auth (HTTP → Users service → JWT cookie) |
139 +| `/Trips`, `/Trips/{Create,Details/{id},Edit/{id},Delete/{id}}` | Trip CRUD |
140 +| `/Members?tripId={id}` and `/Members/AcceptInvitation/{token}` | Trip participants + invitation flow |
141 +| `/Expenses?tripId={id}` (with Create/Edit/Delete) | Trip expenses |
142 +| `/Budget?tripId={id}` (CreateCategory/EditCategory/DeleteCategory) | Budget categories |
143 +| `/Settlement?tripId={id}` | Balances + settlement plans |
144 +| `/PollsClient?tripId={id}` (Create/Details) | Trip polls |
145 +| `/WishlistClient?tripId={id}` | Trip wishlist |
146 +| `/Admin/Dashboard` | Admin home (`admin` role) |
147 +| `/Admin/Users` | Admin user list — proxies to `users-service` via HTTP |
148 +| `/Admin/{Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist}` | Admin CRUD per entity |
149 +| `/swagger` | Swagger UI — Trips + Expenses REST only |
150 +
151 +### Users service (port 98)
152 +| URL | Purpose |
153 +|---|---|
154 +| `/swagger` | Swagger UI for all Users REST endpoints |
155 +| `/health` | Healthcheck (200 only after migrate + seed completes) |
156 +| `/api/v1/identity/account/{register, login, logout, refreshtokendata}` | Auth (anonymous) |
157 +| `/api/v1/identity/admin/users` (GET/PUT/DELETE/+ `/{id}/roles`, `/admin/roles`) | Admin user CRUD (`[Authorize(Roles = "admin")]`) |
158 +
159 +### RabbitMQ management (port 15672)
160 +- Login `guest` / `guest`
161 +- Exchanges: `splitapp.events` (topic), `splitapp.requests` (direct)
162 +- Queues: `q.webapp.UserDeletedEvent`, `q.users-service.GetUserByIdRequest`, `q.users-service.GetUsersByIdsRequest`
163 +
164 +---
165 +
166 +## Inter-service messaging
167 +
168 +All cross-service communication goes through the `SplitApp.Shared.Messaging` library. Two patterns:
169 +
170 +| Pattern | Used for | Library API |
171 +|---|---|---|
172 +| **Pub/Sub (events)** | Fire-and-forget fan-out (`UserDeletedEvent`) | `IMessageBus.PublishEventAsync<T>` + `IEventHandler<T>` |
173 +| **RPC (requests)** | Synchronous data lookups (`GetUserByIdRequest`, `GetUsersByIdsRequest`) | `IMessageBus.RequestAsync<TReq, TResp>` + `IRequestHandler<TReq, TResp>` |
174 +
175 +For the WebApp, `IUserLookup` is the high-level facade over the bus — controllers and services consume `IUserLookup`, not `IMessageBus` directly.
176 +
177 +In-process MediatR is still used between **Trips ↔ Expenses** modules (they live in the same process). Only the Users module crossed the process boundary.
178 +
179 +---
180 +
181 +## Tests
182 +
183 +```bash
184 +cd SplitApp.Modular
185 +dotnet test
186 +```
187 +
188 +**53 tests** across five projects, all passing:
189 +
190 +| Project | Tests | Covers |
191 +|---|---:|---|
192 +| `SplitApp.Modules.Users.Tests` | 8 | `IdentityHelpers` — JWT generation/validation round-trips |
193 +| `SplitApp.Modules.Trips.Tests` | 12 | `LangStr` — multi-language fallback, edge cases |
194 +| `SplitApp.Modules.Expenses.Tests` | 10 | `CurrencyConverter` — exchange rates, rounding |
195 +| `SplitApp.Shared.Messaging.Tests` | 9 | Wire-format contract round-trips (`GetUserByIdRequest`, `UserDeletedEvent`, `UserDto`, …) + marker interface invariants + queue naming |
196 +| `SplitApp.WebApp.IntegrationTests` | 14 | Architecture invariants (module boundaries, schema isolation) + `WebApplicationFactory<Program>` HTTP smoke |
197 +
198 +---
199 +
200 +## Phase 4 ↔ Phase 3 mapping
201 +
202 +| Phase 3 (modular monolith) | Phase 4 (microservices) |
203 +|---|---|
204 +| 1 process (`SplitApp.WebApp`) | 2 processes (`SplitApp.WebApp` + `SplitApp.UsersService`) |
205 +| 1 Postgres database, 3 schemas (`users`/`trips`/`expenses`) | 2 Postgres databases (`splitapp` + `splitapp_users`) |
206 +| In-process MediatR for all cross-module calls | RabbitMQ (`Users.*`) + in-process MediatR (`Trips`/`Expenses`) |
207 +| `UsersDbContext` registered in `WebApp` host | `UsersDbContext` registered in `SplitApp.UsersService` only |
208 +| `Areas/Identity` scaffolded Razor Pages handle login/register | New `Controllers/AccountController.cs` posts to Users service via HTTP, stores JWT in HttpOnly cookie |
209 +| `Admin/UsersController` uses `UserManager<AppUser>` directly | `Admin/UsersController` uses typed `IUsersServiceClient` HTTP client |
210 +| `[NotMapped] AppUser? CreatedBy` on Trip/Expense entities | `[NotMapped] UserDto? CreatedBy` — entities no longer reference Users domain types |
211 +| ~13 WebApp files inject `UserManager`/`SignInManager`/`RoleManager` | All replaced with `User.FindFirstValue(ClaimTypes.NameIdentifier)` claim reads |
212 +| 44 tests | 53 tests (added Shared.Messaging.Tests) |
213 +| One container deploy (`docker compose -p splitapp-phase3 up`) | Five-container compose with healthcheck-gated startup |
214 +
215 +---
216 +
217 +## Known limitations
218 +
219 +- **No example trip data on first boot.** `AppDataInit.SeedExampleData` calls `IUsersServiceClient.ListUsersAsync()` without a JWT (it runs from startup, not an HTTP request context). The Users service returns 401, the `try`/`catch` swallows it, and example trips don't get seeded. Core user accounts (admin, alice, …) ARE seeded by the Users service itself, so login works — you just create your own trips from scratch.
220 +- **Graceful degradation if RabbitMQ is unavailable.** `IUserLookup` catches `MessageBusTimeout`/`MessageBusUnavailableException` and returns empty results, so pages render with blank user names rather than crashing. Logs show the warning.
221 +
222 +---
223 +
224 +## Files & docs
225 +
226 +- [architecture.md](architecture.md) — architecture deep-dive
227 +- [SplitApp.Modular/README.md](SplitApp.Modular/README.md) — solution-level README
added SplitApp.Modular/Directory.Build.props +9 −0
@@ -0,0 +1,9 @@
1 +<Project>
2 + <PropertyGroup>
3 + <TargetFramework>net10.0</TargetFramework>
4 + <LangVersion>latest</LangVersion>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + <WarningsAsErrors>CS8600,CS8602,CS8603,CS8613,CS8618,CS8625</WarningsAsErrors>
8 + </PropertyGroup>
9 +</Project>
added SplitApp.Modular/README.md +96 −0
@@ -0,0 +1,96 @@
1 +# SplitApp — Phase 3 Modular Monolith
2 +
3 +ASP.NET Core 10 modular monolith for splitting trip expenses among participants. Three modules (Users, Trips, Expenses) communicating via MediatR; one deployable; per-module Postgres schemas. Full phase-2 UI surface preserved (101 Razor views, 21 MVC + Admin controllers, identity Razor pages, 10 REST API controllers).
4 +
5 +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for layout, reference rules, contracts, per-module migration commands, and the `[NotMapped]` cross-module navigation caveat.
6 +
7 +## Run
8 +
9 +### With Docker (recommended)
10 +
11 +From the repo root (one level up from this directory):
12 +
13 +```bash
14 +docker compose up --build
15 +```
16 +
17 +**Deployment:** runs locally via Docker Compose (see the repo root README).
18 +
19 +Locally, phase 3 listens on **http://localhost:90** (host port `90` → container port `8080`). Each module's migrations are applied automatically on startup. The container is named `phase3`. Postgres uses dedicated schemas `users` / `trips` / `expenses` inside the same `splitapp` database (DB is internal-only — not exposed to host).
20 +
21 +The single `Dockerfile` lives at the repo root and copies from `SplitApp.Modular/`. There is no separate Dockerfile inside this directory — the root one is canonical.
22 +
23 +### Without Docker
24 +
25 +```bash
26 +docker compose up -d db
27 +dotnet run --project src/SplitApp.WebApp
28 +```
29 +
30 +By default `dotnet run` uses `https://localhost:7133` / `http://localhost:5297` (see `Properties/launchSettings.json`).
31 +
32 +## URL map
33 +
34 +| URL | Purpose |
35 +|-----|---------|
36 +| `/` | Landing page (MVC) |
37 +| `/Trips`, `/Trips/Create`, `/Trips/Details/{id}`, `/Trips/Edit/{id}`, `/Trips/Delete/{id}` | Trip CRUD |
38 +| `/Members?tripId={id}` and `/Members/AcceptInvitation/{token}` | Trip participants + invitation flow |
39 +| `/Expenses?tripId={id}` (with Create/Edit/Delete) | Trip expenses |
40 +| `/Budget?tripId={id}` (with CreateCategory/EditCategory/DeleteCategory) | Budget categories per trip |
41 +| `/Settlement?tripId={id}` | Balances + settlement plans |
42 +| `/PollsClient?tripId={id}` (Create/Details) | Trip polls |
43 +| `/WishlistClient?tripId={id}` (Create/Edit/Delete) | Trip wishlist |
44 +| `/Identity/Account/Register` | Cookie-based register |
45 +| `/Admin/Dashboard` | Admin home (`admin` role) |
46 +| `/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist}` | Admin CRUD over each entity |
47 +| `/swagger` | Swagger UI listing every module's REST endpoints |
48 +
49 +## REST API
50 +
51 +| Module | Endpoints |
52 +|--------|-----------|
53 +| Users | `/api/v1/identity/account/{register, login, logout, refreshtokendata}` |
54 +| Trips | `/api/v1/trips`, `/api/v1/budgetcategories`, `/api/v1/invitations`, `/api/v1/polls`, `/api/v1/wishlist` |
55 +| Expenses | `/api/v1/expenses`, `/api/v1/currencies`, `/api/v1/settlements`, `/api/v1/splitpresets` |
56 +
57 +## Tests
58 +
59 +```bash
60 +dotnet test
61 +```
62 +
63 +**25 tests** across four projects:
64 +
65 +- **Per-module unit tests** — `CurrencyConverter` (5), `LangStr` (7), `IdentityHelpers` JWT round-trips (4)
66 +- **Architecture invariants** (in `tests/SplitApp.WebApp.IntegrationTests/Architecture/`):
67 + - `ModuleBoundaryTests` — no module's `Application`/`Infrastructure`/`Api` may `<ProjectReference>` another module
68 + - `DbContextSchemaIsolationTests` — every `DbContext` exposes `DbSet<T>` only for entities in its own `Domain` project
69 + - `CrossModuleNavigationTests` — cross-module navigation properties allowed only when `[NotMapped]`; the WebApp facade hydrates them in-memory after loading from the owning module's DbContext, so EF never crosses schemas
70 +- **Smoke** — `WebApplicationFactory<Program>` boots the full host in `Testing` env (skipping migrations) and serves `/`, `/Home/Index`, returns 401 on unauthenticated API hit
71 +
72 +A failing architecture test means a developer just violated the modular-monolith invariant.
73 +
74 +## Module owners
75 +
76 +| Module | Owns | Schema |
77 +|--------|------|--------|
78 +| Users | Identity, JWT issuance, refresh tokens, user profile | `users` |
79 +| Trips | Trips, participants, invitations, polls, wishlists, budget categories | `trips` |
80 +| Expenses | Expenses, splits, settlement plans, settlement payments, split presets, currencies | `expenses` |
81 +
82 +## Caveat — `[NotMapped]` cross-module navigation properties
83 +
84 +To preserve phase 2's view-rendering parity (BLL DTO factories that read `Trip.DefaultCurrency.Code`, `TripParticipant.User.FirstName`, etc.) the entity classes still declare those navigation properties — annotated `[NotMapped]` so EF never crosses schemas. The `CrossModuleNavigationLoader` in `src/SplitApp.WebApp/Application/Persistence/` populates them in C# after entity load by querying the appropriate module's DbContext.
85 +
86 +Keeping the property *types* on the entities required adding `<ProjectReference>` between Domain projects:
87 +
88 +```
89 +Modules/Trips/SplitApp.Modules.Trips.Domain
90 + → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
91 + → Modules/Expenses/SplitApp.Modules.Expenses.Domain (for Currency refs)
92 +Modules/Expenses/SplitApp.Modules.Expenses.Domain
93 + → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs)
94 +```
95 +
96 +This bends the strict "no direct references between modules" rule from `phase3.md` at the **Domain** level. **`Application`, `Infrastructure`, and `Api` projects remain isolated** — they never `<ProjectReference>` another module — and continue to use MediatR for actual function calls. Schema isolation, MediatR-only inter-module communication, and per-module DbContext ownership are all preserved at runtime; only the entity *type system* is shared.
added SplitApp.Modular/SplitApp.sln +375 −0
@@ -0,0 +1,375 @@
1 +
2 +Microsoft Visual Studio Solution File, Format Version 12.00
3 +# Visual Studio Version 17
4 +VisualStudioVersion = 17.0.31903.59
5 +MinimumVisualStudioVersion = 10.0.40219.1
6 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
7 +EndProject
8 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.WebApp", "src\SplitApp.WebApp\SplitApp.WebApp.csproj", "{634BAEEE-802C-4568-98D6-50FCD86372BE}"
9 +EndProject
10 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{C8E42992-5E42-0C2B-DBFE-AA848D06431C}"
11 +EndProject
12 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Shared.Kernel", "src\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj", "{22C9586A-C6D7-4102-A474-69279D977486}"
13 +EndProject
14 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Shared.Contracts", "src\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj", "{15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}"
15 +EndProject
16 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Modules", "Modules", "{EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}"
17 +EndProject
18 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Users", "Users", "{A26F344D-182E-CE53-AD51-2154946AC6F3}"
19 +EndProject
20 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Domain", "src\Modules\Users\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj", "{5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}"
21 +EndProject
22 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Application", "src\Modules\Users\SplitApp.Modules.Users.Application\SplitApp.Modules.Users.Application.csproj", "{F356134B-BF21-4687-9BD8-342C40AD4B9D}"
23 +EndProject
24 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Infrastructure", "src\Modules\Users\SplitApp.Modules.Users.Infrastructure\SplitApp.Modules.Users.Infrastructure.csproj", "{E316CB96-ACE5-42EB-B8EC-95F22DDE2251}"
25 +EndProject
26 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Api", "src\Modules\Users\SplitApp.Modules.Users.Api\SplitApp.Modules.Users.Api.csproj", "{3B01763E-85E1-4F42-9A1C-6B0240810EA3}"
27 +EndProject
28 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
29 +EndProject
30 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Users.Tests", "tests\SplitApp.Modules.Users.Tests\SplitApp.Modules.Users.Tests.csproj", "{62DDD903-B3D7-4140-B200-D489CE7A567D}"
31 +EndProject
32 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Trips", "Trips", "{869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}"
33 +EndProject
34 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Domain", "src\Modules\Trips\SplitApp.Modules.Trips.Domain\SplitApp.Modules.Trips.Domain.csproj", "{B2A31D85-D7B1-4476-819B-7A413A875DA6}"
35 +EndProject
36 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Application", "src\Modules\Trips\SplitApp.Modules.Trips.Application\SplitApp.Modules.Trips.Application.csproj", "{7E0445E0-A676-4892-89C6-85C1738C1A49}"
37 +EndProject
38 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Infrastructure", "src\Modules\Trips\SplitApp.Modules.Trips.Infrastructure\SplitApp.Modules.Trips.Infrastructure.csproj", "{271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}"
39 +EndProject
40 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Api", "src\Modules\Trips\SplitApp.Modules.Trips.Api\SplitApp.Modules.Trips.Api.csproj", "{E0619779-5956-4D74-B683-BFDECBC89710}"
41 +EndProject
42 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Trips.Tests", "tests\SplitApp.Modules.Trips.Tests\SplitApp.Modules.Trips.Tests.csproj", "{B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}"
43 +EndProject
44 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Expenses", "Expenses", "{69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}"
45 +EndProject
46 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Domain", "src\Modules\Expenses\SplitApp.Modules.Expenses.Domain\SplitApp.Modules.Expenses.Domain.csproj", "{49DB325B-DE26-429F-AF4A-A0F6D56B854C}"
47 +EndProject
48 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Application", "src\Modules\Expenses\SplitApp.Modules.Expenses.Application\SplitApp.Modules.Expenses.Application.csproj", "{0EF6B704-D7B3-4839-B811-753BAD35568F}"
49 +EndProject
50 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Infrastructure", "src\Modules\Expenses\SplitApp.Modules.Expenses.Infrastructure\SplitApp.Modules.Expenses.Infrastructure.csproj", "{8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}"
51 +EndProject
52 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Api", "src\Modules\Expenses\SplitApp.Modules.Expenses.Api\SplitApp.Modules.Expenses.Api.csproj", "{540AF573-0915-42A5-B616-F6395ED13AFA}"
53 +EndProject
54 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Modules.Expenses.Tests", "tests\SplitApp.Modules.Expenses.Tests\SplitApp.Modules.Expenses.Tests.csproj", "{037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}"
55 +EndProject
56 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.WebApp.IntegrationTests", "tests\SplitApp.WebApp.IntegrationTests\SplitApp.WebApp.IntegrationTests.csproj", "{4B0B55CE-A512-4C2F-BB69-A89A348B83AB}"
57 +EndProject
58 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{4F52FD11-658E-A102-6CD3-7D7C16FFA15B}"
59 +EndProject
60 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Shared.Messaging", "src\Shared\SplitApp.Shared.Messaging\SplitApp.Shared.Messaging.csproj", "{A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}"
61 +EndProject
62 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Users", "Users", "{A10C4C97-C1A1-C462-9A04-F253A6FEFC1E}"
63 +EndProject
64 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.UsersService", "src\Services\Users\SplitApp.UsersService\SplitApp.UsersService.csproj", "{559D8CFA-C095-4E8C-905F-3B6D4580AC08}"
65 +EndProject
66 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SplitApp.Shared.Messaging.Tests", "tests\SplitApp.Shared.Messaging.Tests\SplitApp.Shared.Messaging.Tests.csproj", "{B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}"
67 +EndProject
68 +Global
69 + GlobalSection(SolutionConfigurationPlatforms) = preSolution
70 + Debug|Any CPU = Debug|Any CPU
71 + Debug|x64 = Debug|x64
72 + Debug|x86 = Debug|x86
73 + Release|Any CPU = Release|Any CPU
74 + Release|x64 = Release|x64
75 + Release|x86 = Release|x86
76 + EndGlobalSection
77 + GlobalSection(ProjectConfigurationPlatforms) = postSolution
78 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
79 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
80 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x64.ActiveCfg = Debug|Any CPU
81 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x64.Build.0 = Debug|Any CPU
82 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x86.ActiveCfg = Debug|Any CPU
83 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x86.Build.0 = Debug|Any CPU
84 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
85 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|Any CPU.Build.0 = Release|Any CPU
86 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x64.ActiveCfg = Release|Any CPU
87 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x64.Build.0 = Release|Any CPU
88 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x86.ActiveCfg = Release|Any CPU
89 + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x86.Build.0 = Release|Any CPU
90 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
91 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|Any CPU.Build.0 = Debug|Any CPU
92 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x64.ActiveCfg = Debug|Any CPU
93 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x64.Build.0 = Debug|Any CPU
94 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x86.ActiveCfg = Debug|Any CPU
95 + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x86.Build.0 = Debug|Any CPU
96 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|Any CPU.ActiveCfg = Release|Any CPU
97 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|Any CPU.Build.0 = Release|Any CPU
98 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x64.ActiveCfg = Release|Any CPU
99 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x64.Build.0 = Release|Any CPU
100 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x86.ActiveCfg = Release|Any CPU
101 + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x86.Build.0 = Release|Any CPU
102 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
103 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
104 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x64.ActiveCfg = Debug|Any CPU
105 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x64.Build.0 = Debug|Any CPU
106 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x86.ActiveCfg = Debug|Any CPU
107 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x86.Build.0 = Debug|Any CPU
108 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
109 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|Any CPU.Build.0 = Release|Any CPU
110 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x64.ActiveCfg = Release|Any CPU
111 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x64.Build.0 = Release|Any CPU
112 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x86.ActiveCfg = Release|Any CPU
113 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x86.Build.0 = Release|Any CPU
114 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
115 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
116 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x64.ActiveCfg = Debug|Any CPU
117 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x64.Build.0 = Debug|Any CPU
118 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x86.ActiveCfg = Debug|Any CPU
119 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x86.Build.0 = Debug|Any CPU
120 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
121 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|Any CPU.Build.0 = Release|Any CPU
122 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x64.ActiveCfg = Release|Any CPU
123 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x64.Build.0 = Release|Any CPU
124 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x86.ActiveCfg = Release|Any CPU
125 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x86.Build.0 = Release|Any CPU
126 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
127 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|Any CPU.Build.0 = Debug|Any CPU
128 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x64.ActiveCfg = Debug|Any CPU
129 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x64.Build.0 = Debug|Any CPU
130 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x86.ActiveCfg = Debug|Any CPU
131 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x86.Build.0 = Debug|Any CPU
132 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|Any CPU.ActiveCfg = Release|Any CPU
133 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|Any CPU.Build.0 = Release|Any CPU
134 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x64.ActiveCfg = Release|Any CPU
135 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x64.Build.0 = Release|Any CPU
136 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x86.ActiveCfg = Release|Any CPU
137 + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x86.Build.0 = Release|Any CPU
138 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
139 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|Any CPU.Build.0 = Debug|Any CPU
140 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x64.ActiveCfg = Debug|Any CPU
141 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x64.Build.0 = Debug|Any CPU
142 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x86.ActiveCfg = Debug|Any CPU
143 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x86.Build.0 = Debug|Any CPU
144 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|Any CPU.ActiveCfg = Release|Any CPU
145 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|Any CPU.Build.0 = Release|Any CPU
146 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x64.ActiveCfg = Release|Any CPU
147 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x64.Build.0 = Release|Any CPU
148 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x86.ActiveCfg = Release|Any CPU
149 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x86.Build.0 = Release|Any CPU
150 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
151 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU
152 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x64.ActiveCfg = Debug|Any CPU
153 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x64.Build.0 = Debug|Any CPU
154 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x86.ActiveCfg = Debug|Any CPU
155 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x86.Build.0 = Debug|Any CPU
156 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU
157 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|Any CPU.Build.0 = Release|Any CPU
158 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x64.ActiveCfg = Release|Any CPU
159 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x64.Build.0 = Release|Any CPU
160 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x86.ActiveCfg = Release|Any CPU
161 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x86.Build.0 = Release|Any CPU
162 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
163 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|Any CPU.Build.0 = Debug|Any CPU
164 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x64.ActiveCfg = Debug|Any CPU
165 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x64.Build.0 = Debug|Any CPU
166 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x86.ActiveCfg = Debug|Any CPU
167 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x86.Build.0 = Debug|Any CPU
168 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|Any CPU.ActiveCfg = Release|Any CPU
169 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|Any CPU.Build.0 = Release|Any CPU
170 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x64.ActiveCfg = Release|Any CPU
171 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x64.Build.0 = Release|Any CPU
172 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x86.ActiveCfg = Release|Any CPU
173 + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x86.Build.0 = Release|Any CPU
174 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
175 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
176 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x64.ActiveCfg = Debug|Any CPU
177 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x64.Build.0 = Debug|Any CPU
178 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x86.ActiveCfg = Debug|Any CPU
179 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x86.Build.0 = Debug|Any CPU
180 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
181 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|Any CPU.Build.0 = Release|Any CPU
182 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x64.ActiveCfg = Release|Any CPU
183 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x64.Build.0 = Release|Any CPU
184 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x86.ActiveCfg = Release|Any CPU
185 + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x86.Build.0 = Release|Any CPU
186 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
187 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|Any CPU.Build.0 = Debug|Any CPU
188 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x64.ActiveCfg = Debug|Any CPU
189 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x64.Build.0 = Debug|Any CPU
190 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x86.ActiveCfg = Debug|Any CPU
191 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x86.Build.0 = Debug|Any CPU
192 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|Any CPU.ActiveCfg = Release|Any CPU
193 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|Any CPU.Build.0 = Release|Any CPU
194 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x64.ActiveCfg = Release|Any CPU
195 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x64.Build.0 = Release|Any CPU
196 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x86.ActiveCfg = Release|Any CPU
197 + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x86.Build.0 = Release|Any CPU
198 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
199 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
200 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x64.ActiveCfg = Debug|Any CPU
201 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x64.Build.0 = Debug|Any CPU
202 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x86.ActiveCfg = Debug|Any CPU
203 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x86.Build.0 = Debug|Any CPU
204 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
205 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|Any CPU.Build.0 = Release|Any CPU
206 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x64.ActiveCfg = Release|Any CPU
207 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x64.Build.0 = Release|Any CPU
208 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x86.ActiveCfg = Release|Any CPU
209 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x86.Build.0 = Release|Any CPU
210 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
211 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|Any CPU.Build.0 = Debug|Any CPU
212 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x64.ActiveCfg = Debug|Any CPU
213 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x64.Build.0 = Debug|Any CPU
214 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x86.ActiveCfg = Debug|Any CPU
215 + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x86.Build.0 = Debug|Any CPU
216 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|Any CPU.ActiveCfg = Release|Any CPU
217 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|Any CPU.Build.0 = Release|Any CPU
218 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x64.ActiveCfg = Release|Any CPU
219 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x64.Build.0 = Release|Any CPU
220 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x86.ActiveCfg = Release|Any CPU
221 + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x86.Build.0 = Release|Any CPU
222 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
223 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|Any CPU.Build.0 = Debug|Any CPU
224 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x64.ActiveCfg = Debug|Any CPU
225 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x64.Build.0 = Debug|Any CPU
226 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x86.ActiveCfg = Debug|Any CPU
227 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x86.Build.0 = Debug|Any CPU
228 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|Any CPU.ActiveCfg = Release|Any CPU
229 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|Any CPU.Build.0 = Release|Any CPU
230 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x64.ActiveCfg = Release|Any CPU
231 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x64.Build.0 = Release|Any CPU
232 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x86.ActiveCfg = Release|Any CPU
233 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x86.Build.0 = Release|Any CPU
234 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
235 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|Any CPU.Build.0 = Debug|Any CPU
236 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x64.ActiveCfg = Debug|Any CPU
237 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x64.Build.0 = Debug|Any CPU
238 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x86.ActiveCfg = Debug|Any CPU
239 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x86.Build.0 = Debug|Any CPU
240 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|Any CPU.ActiveCfg = Release|Any CPU
241 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|Any CPU.Build.0 = Release|Any CPU
242 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x64.ActiveCfg = Release|Any CPU
243 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x64.Build.0 = Release|Any CPU
244 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x86.ActiveCfg = Release|Any CPU
245 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x86.Build.0 = Release|Any CPU
246 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
247 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|Any CPU.Build.0 = Debug|Any CPU
248 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x64.ActiveCfg = Debug|Any CPU
249 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x64.Build.0 = Debug|Any CPU
250 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x86.ActiveCfg = Debug|Any CPU
251 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x86.Build.0 = Debug|Any CPU
252 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|Any CPU.ActiveCfg = Release|Any CPU
253 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|Any CPU.Build.0 = Release|Any CPU
254 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x64.ActiveCfg = Release|Any CPU
255 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x64.Build.0 = Release|Any CPU
256 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x86.ActiveCfg = Release|Any CPU
257 + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x86.Build.0 = Release|Any CPU
258 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
259 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|Any CPU.Build.0 = Debug|Any CPU
260 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x64.ActiveCfg = Debug|Any CPU
261 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x64.Build.0 = Debug|Any CPU
262 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x86.ActiveCfg = Debug|Any CPU
263 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x86.Build.0 = Debug|Any CPU
264 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|Any CPU.ActiveCfg = Release|Any CPU
265 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|Any CPU.Build.0 = Release|Any CPU
266 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x64.ActiveCfg = Release|Any CPU
267 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x64.Build.0 = Release|Any CPU
268 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x86.ActiveCfg = Release|Any CPU
269 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x86.Build.0 = Release|Any CPU
270 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
271 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|Any CPU.Build.0 = Debug|Any CPU
272 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x64.ActiveCfg = Debug|Any CPU
273 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x64.Build.0 = Debug|Any CPU
274 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x86.ActiveCfg = Debug|Any CPU
275 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x86.Build.0 = Debug|Any CPU
276 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|Any CPU.ActiveCfg = Release|Any CPU
277 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|Any CPU.Build.0 = Release|Any CPU
278 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x64.ActiveCfg = Release|Any CPU
279 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x64.Build.0 = Release|Any CPU
280 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x86.ActiveCfg = Release|Any CPU
281 + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x86.Build.0 = Release|Any CPU
282 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
283 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
284 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x64.ActiveCfg = Debug|Any CPU
285 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x64.Build.0 = Debug|Any CPU
286 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x86.ActiveCfg = Debug|Any CPU
287 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x86.Build.0 = Debug|Any CPU
288 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
289 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|Any CPU.Build.0 = Release|Any CPU
290 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x64.ActiveCfg = Release|Any CPU
291 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x64.Build.0 = Release|Any CPU
292 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x86.ActiveCfg = Release|Any CPU
293 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x86.Build.0 = Release|Any CPU
294 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
295 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
296 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x64.ActiveCfg = Debug|Any CPU
297 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x64.Build.0 = Debug|Any CPU
298 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x86.ActiveCfg = Debug|Any CPU
299 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x86.Build.0 = Debug|Any CPU
300 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
301 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|Any CPU.Build.0 = Release|Any CPU
302 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x64.ActiveCfg = Release|Any CPU
303 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x64.Build.0 = Release|Any CPU
304 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x86.ActiveCfg = Release|Any CPU
305 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x86.Build.0 = Release|Any CPU
306 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
307 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Debug|Any CPU.Build.0 = Debug|Any CPU
308 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Debug|x64.ActiveCfg = Debug|Any CPU
309 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Debug|x64.Build.0 = Debug|Any CPU
310 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Debug|x86.ActiveCfg = Debug|Any CPU
311 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Debug|x86.Build.0 = Debug|Any CPU
312 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Release|Any CPU.ActiveCfg = Release|Any CPU
313 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Release|Any CPU.Build.0 = Release|Any CPU
314 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Release|x64.ActiveCfg = Release|Any CPU
315 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Release|x64.Build.0 = Release|Any CPU
316 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Release|x86.ActiveCfg = Release|Any CPU
317 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409}.Release|x86.Build.0 = Release|Any CPU
318 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
319 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Debug|Any CPU.Build.0 = Debug|Any CPU
320 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Debug|x64.ActiveCfg = Debug|Any CPU
321 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Debug|x64.Build.0 = Debug|Any CPU
322 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Debug|x86.ActiveCfg = Debug|Any CPU
323 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Debug|x86.Build.0 = Debug|Any CPU
324 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Release|Any CPU.ActiveCfg = Release|Any CPU
325 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Release|Any CPU.Build.0 = Release|Any CPU
326 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Release|x64.ActiveCfg = Release|Any CPU
327 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Release|x64.Build.0 = Release|Any CPU
328 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Release|x86.ActiveCfg = Release|Any CPU
329 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08}.Release|x86.Build.0 = Release|Any CPU
330 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
331 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Debug|Any CPU.Build.0 = Debug|Any CPU
332 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Debug|x64.ActiveCfg = Debug|Any CPU
333 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Debug|x64.Build.0 = Debug|Any CPU
334 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Debug|x86.ActiveCfg = Debug|Any CPU
335 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Debug|x86.Build.0 = Debug|Any CPU
336 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Release|Any CPU.ActiveCfg = Release|Any CPU
337 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Release|Any CPU.Build.0 = Release|Any CPU
338 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Release|x64.ActiveCfg = Release|Any CPU
339 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Release|x64.Build.0 = Release|Any CPU
340 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Release|x86.ActiveCfg = Release|Any CPU
341 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36}.Release|x86.Build.0 = Release|Any CPU
342 + EndGlobalSection
343 + GlobalSection(SolutionProperties) = preSolution
344 + HideSolutionNode = FALSE
345 + EndGlobalSection
346 + GlobalSection(NestedProjects) = preSolution
347 + {634BAEEE-802C-4568-98D6-50FCD86372BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
348 + {C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
349 + {22C9586A-C6D7-4102-A474-69279D977486} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
350 + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C}
351 + {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
352 + {A26F344D-182E-CE53-AD51-2154946AC6F3} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
353 + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8} = {A26F344D-182E-CE53-AD51-2154946AC6F3}
354 + {F356134B-BF21-4687-9BD8-342C40AD4B9D} = {A26F344D-182E-CE53-AD51-2154946AC6F3}
355 + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251} = {A26F344D-182E-CE53-AD51-2154946AC6F3}
356 + {3B01763E-85E1-4F42-9A1C-6B0240810EA3} = {A26F344D-182E-CE53-AD51-2154946AC6F3}
357 + {62DDD903-B3D7-4140-B200-D489CE7A567D} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
358 + {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
359 + {B2A31D85-D7B1-4476-819B-7A413A875DA6} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}
360 + {7E0445E0-A676-4892-89C6-85C1738C1A49} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}
361 + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}
362 + {E0619779-5956-4D74-B683-BFDECBC89710} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4}
363 + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
364 + {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558}
365 + {49DB325B-DE26-429F-AF4A-A0F6D56B854C} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}
366 + {0EF6B704-D7B3-4839-B811-753BAD35568F} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}
367 + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}
368 + {540AF573-0915-42A5-B616-F6395ED13AFA} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3}
369 + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
370 + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
371 + {A9A0C526-03FE-4108-8ED3-B8CDF0A3E409} = {4F52FD11-658E-A102-6CD3-7D7C16FFA15B}
372 + {559D8CFA-C095-4E8C-905F-3B6D4580AC08} = {A10C4C97-C1A1-C462-9A04-F253A6FEFC1E}
373 + {B846690C-CBAE-43E4-AF20-C4B6AA6F5A36} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
374 + EndGlobalSection
375 +EndGlobal
added SplitApp.Modular/docs/ARCHITECTURE.md +191 −0
@@ -0,0 +1,191 @@
1 +# SplitApp — Modular Monolith (Phase 3)
2 +
3 +This is the phase-3 refactor of SplitApp from a Clean/Onion monolith (phase 2) to a modular monolith. One deployable, three internally isolated modules, MediatR for cross-module communication.
4 +
5 +## Deployment
6 +
7 +**Deployment:** runs locally via Docker Compose (see the repo root README).
8 +
9 +The repo's root `docker-compose.yml` + `Dockerfile` build phase 3 locally:
10 +
11 +| Service | Container | Builds from | Host port | Notes |
12 +|---|---|---|---|---|
13 +| `phase3` | `phase3` | `./Dockerfile` (root) | **`90`** | Phase 3 — modular monolith |
14 +| `db` | `phase3-db` | `postgres:16` | (internal only) | PostgreSQL with schemas `users` / `trips` / `expenses` — not exposed to host |
15 +
16 +Bring everything up:
17 +
18 +```bash
19 +docker compose up --build
20 +```
21 +
22 +The container exposes `http://localhost:90` (host port `90` → container port `8080`). Per-module migrations run automatically on startup.
23 +
24 +## Solution layout
25 +
26 +```
27 +SplitApp.sln
28 +├── src/
29 +│ ├── SplitApp.WebApp/ // composition root + admin Area + host
30 +│ ├── Shared/
31 +│ │ ├── SplitApp.Shared.Kernel/ // BaseEntity, IBaseRepository, IUnitOfWork, LangStr, IdentityHelpers
32 +│ │ └── SplitApp.Shared.Contracts/ // MediatR IRequest / INotification contracts
33 +│ └── Modules/
34 +│ ├── Users/
35 +│ │ ├── SplitApp.Modules.Users.Domain/ // AppUser, AppRole, AppRefreshToken
36 +│ │ ├── SplitApp.Modules.Users.Application/ // IIdentityService + JWT/refresh logic, MediatR handlers
37 +│ │ ├── SplitApp.Modules.Users.Infrastructure/ // UsersDbContext (schema "users"), repos, AddUsersModule
38 +│ │ └── SplitApp.Modules.Users.Api/ // /api/v1/identity/... controllers + DTOs
39 +│ ├── Trips/ // same 4-project layout, schema "trips"
40 +│ └── Expenses/ // same 4-project layout, schema "expenses"
41 +└── tests/
42 + ├── SplitApp.Modules.Users.Tests/
43 + ├── SplitApp.Modules.Trips.Tests/
44 + ├── SplitApp.Modules.Expenses.Tests/
45 + └── SplitApp.WebApp.IntegrationTests/ // architecture + integration tests
46 +```
47 +
48 +## The reference rules
49 +
50 +Compiler-enforced via `<ProjectReference>` graph and verified by `tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs`:
51 +
52 +- A module's `Application` / `Infrastructure` / `Api` project may reference: another project inside the **same** module, plus `Shared.Kernel` and `Shared.Contracts`. **Nothing else.**
53 +- Inter-module function calls go through **MediatR only** — never via a direct `<ProjectReference>` to another module's services or repositories.
54 +- `Shared.*` projects may not reference any module.
55 +- `WebApp` is the only project that references all three modules' `Api` and `Infrastructure` projects.
56 +
57 +**Caveat at the Domain level only:** to keep phase 2's view-rendering parity (mappers that read `Trip.DefaultCurrency.Code`, `TripParticipant.User.FirstName`, etc.), the entity classes still declare those navigation properties — annotated `[NotMapped]` so EF never crosses schemas. Keeping the property *types* on the entities required adding three Domain-to-Domain `<ProjectReference>`s:
58 +
59 +```
60 +Modules/Trips/SplitApp.Modules.Trips.Domain
61 + → Modules/Users/SplitApp.Modules.Users.Domain
62 + → Modules/Expenses/SplitApp.Modules.Expenses.Domain
63 +Modules/Expenses/SplitApp.Modules.Expenses.Domain
64 + → Modules/Users/SplitApp.Modules.Users.Domain
65 +```
66 +
67 +This bends the strict "no direct references between modules" rule from `phase3.md` at the Domain level. `Application`, `Infrastructure`, and `Api` projects remain isolated and use MediatR for actual function calls — only entity *types* are shared. The `CrossModuleNavigationTests` invariant enforces that any cross-module navigation is `[NotMapped]`; a plain mapped nav across schemas would fail the build.
68 +
69 +## Inter-module communication
70 +
71 +All cross-module calls go through MediatR. Contracts live in `SplitApp.Shared.Contracts/<Module>/{Queries|Events}/` and are records implementing `IRequest<T>` (sync queries/commands) or `INotification` (fan-out events). Handlers live in the **owning** module's `Application` or `Infrastructure` layer.
72 +
73 +Currently shipped contracts:
74 +
75 +| Contract | Owner module | Notes |
76 +|----------|--------------|-------|
77 +| `GetUserByIdQuery : IRequest<UserDto?>` | Users | Used by Trips/Expenses for display-name lookup |
78 +| `GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>>` | Users | Batch lookup |
79 +| `UserDeletedEvent : INotification` | Users | Trips + Expenses subscribe to clean up rows |
80 +| `GetTripByIdQuery : IRequest<TripSummaryDto?>` | Trips | Cross-module trip lookup |
81 +| `GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>>` | Trips | |
82 +| `IsTripParticipantQuery : IRequest<bool>` | Trips | Used by `ExpensesController` for IDOR + payer validation |
83 +| `TripDeletedEvent : INotification` | Trips | Expenses subscribes to delete dependent expenses/settlements |
84 +| `GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto>` | Expenses | |
85 +| `GetBudgetCategorySpentQuery : IRequest<IReadOnlyDictionary<Guid, decimal>>` | Expenses | Per-budget-category spent totals — used by Trips' BudgetCategoriesController |
86 +| `ExpenseSettledEvent : INotification` | Expenses | Reserved for future use |
87 +| `SettlementPlanCompletedEvent : INotification` | Expenses | Trips subscribes to advance "Finalizing" trips to "Settled" once every payment is confirmed |
88 +
89 +## Data isolation
90 +
91 +Each module owns its own `DbContext`:
92 +
93 +- `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` → schema `users`
94 +- `TripsDbContext : DbContext` → schema `trips`
95 +- `ExpensesDbContext : DbContext` → schema `expenses`
96 +
97 +All three connect to the **same** physical Postgres database via the same `ConnectionStrings:DefaultConnection`. Schemas — not separate databases — provide isolation. **Cross-module SQL joins are forbidden**; cross-module data is composed at the application layer via MediatR.
98 +
99 +Cross-module entity references are bare `Guid` fields with **no** navigation properties and **no** EF foreign-key constraints (e.g. `Trip.CreatedById : Guid` references `users.AspNetUsers.Id` only conceptually, not via `FOREIGN KEY`). Referential integrity is maintained by:
100 +
101 +- Up-front MediatR validation queries (e.g. `IsTripParticipantQuery` before persisting an expense split).
102 +- Domain-event cleanup on delete (`UserDeletedEvent`, `TripDeletedEvent`).
103 +
104 +## Per-module migrations
105 +
106 +Each module ships its own EF migration history table inside its own schema. Generate new migrations against the module-specific DbContext + project, with `WebApp` as the startup project (so the connection string is read from `appsettings.json`):
107 +
108 +```bash
109 +# Users
110 +dotnet ef migrations add <Name> -c UsersDbContext \
111 + -p src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj \
112 + -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
113 + -o Persistence/Migrations
114 +
115 +# Trips
116 +dotnet ef migrations add <Name> -c TripsDbContext \
117 + -p src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj \
118 + -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
119 + -o Persistence/Migrations
120 +
121 +# Expenses
122 +dotnet ef migrations add <Name> -c ExpensesDbContext \
123 + -p src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj \
124 + -s src/SplitApp.WebApp/SplitApp.WebApp.csproj \
125 + -o Persistence/Migrations
126 +```
127 +
128 +Migrations are applied automatically on startup by each module's `UseXxxModule(IApplicationBuilder)` extension (called from `Program.cs`).
129 +
130 +## Composition root
131 +
132 +`SplitApp.WebApp/Program.cs` is the only place that sees all three modules. It:
133 +
134 +1. Configures cross-cutting host concerns (MVC, API versioning via `Asp.Versioning`, Swagger, authorization).
135 +2. Calls `services.AddUsersModule(...)`, `services.AddTripsModule(...)`, `services.AddExpensesModule(...)` — each module's extension wires its own `DbContext`, repositories, services, and MediatR handlers.
136 +3. Registers controllers from each module's `Api` assembly via `AddApplicationPart(...)`.
137 +4. Calls `app.UseUsersModule()`, `app.UseTripsModule()`, `app.UseExpensesModule()` — each applies its module's pending migrations.
138 +
139 +The composition root does **not** call `AddDbContext<...>` directly, does not register any module-internal repository/service, and does not scan module assemblies for MediatR. Each module owns its own registration.
140 +
141 +## Phase 2 → Phase 3 mapping
142 +
143 +| Phase 2 project | Phase 3 destination |
144 +|-----------------|---------------------|
145 +| `Base.Domain` (`BaseEntity`, `LangStr`) | `SplitApp.Shared.Kernel` |
146 +| `Base.Contracts` (`IBaseEntity`, `IBaseRepository`, `IUnitOfWork`) | `SplitApp.Shared.Kernel` |
147 +| `Base.Helpers` (`IdentityHelpers`) | `SplitApp.Shared.Kernel.Auth` |
148 +| `App.Domain.Identity.*` | `SplitApp.Modules.Users.Domain.Entities` |
149 +| `App.Domain.{Trip,TripParticipant,...}` | `SplitApp.Modules.Trips.Domain.Entities` |
150 +| `App.Domain.{Expense,SettlementPlan,...,Currency}` | `SplitApp.Modules.Expenses.Domain.Entities` |
151 +| `App.DAL.EF.AppDbContext` | Split into 3 `XxxDbContext` per module |
152 +| `App.BLL.Services.Identity.*` | `SplitApp.Modules.Users.Application.Services` |
153 +| `WebApp.ApiControllers.Identity.*` | `SplitApp.Modules.Users.Api.Controllers` |
154 +| `WebApp.ApiControllers.{TripsController,...}` | `SplitApp.Modules.Trips.Api.Controllers` |
155 +| `WebApp.ApiControllers.{ExpensesController,...}` | `SplitApp.Modules.Expenses.Api.Controllers` |
156 +
157 +## Composition root: MVC + Razor + Admin
158 +
159 +The WebApp hosts the full phase 2 UI surface (101 Razor views, 21 MVC + Admin controllers, identity Razor pages). It is the **only** place that sees all three modules. Phase 2 is preserved by lifting the BLL layer into [src/SplitApp.WebApp/Application/](../src/SplitApp.WebApp/Application/) under three buckets:
160 +
161 +- **DTO** — phase 2 BLL DTOs unchanged (POCOs; views model-bind to these)
162 +- **Mappers** — entity ↔ DTO factories, unchanged structurally; cross-module nav fields populate from `[NotMapped]` properties
163 +- **Services + Services/Admin + Services/Identity** — phase 2 BLL services unchanged; they consume `IAppUnitOfWork`
164 +
165 +[`Application/Persistence/AppUnitOfWork.cs`](../src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs) is the composition-root facade: it injects all three module DbContexts (`UsersDbContext`, `TripsDbContext`, `ExpensesDbContext`) and exposes them through phase 2's `IAppUnitOfWork` interface. Each repository routes to its module's DbContext — schemas stay isolated, but the host can compose across them.
166 +
167 +[`Application/Persistence/CrossModuleNavigationLoader.cs`](../src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs) hydrates the `[NotMapped]` cross-module nav properties (`Trip.DefaultCurrency`, `TripParticipant.User`, `Expense.PaidByUser`, etc.) after entities are loaded. EF never crosses schemas; the loader pulls cross-module data via a separate query against the appropriate module's DbContext.
168 +
169 +### MVC client controllers
170 +
171 +`Controllers/{Home, Trips, Expenses, Budget, Members, Settlement, PollsClient, WishlistClient}Controller` — the user-facing site. Cookie-authenticated.
172 +
173 +### Admin area
174 +
175 +`Areas/Admin/Controllers/{Dashboard, Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPayments, SettlementPlans, SplitPresets, TripParticipants, Wishlist}Controller` — admin CRUD per entity, gated on the `admin` role.
176 +
177 +### Identity Razor page
178 +
179 +`Areas/Identity/Pages/Account/Register` — cookie-based registration. JWT flows are handled by `Modules.Users.Api.AccountController`.
180 +
181 +Migrations are skipped when `ASPNETCORE_ENVIRONMENT=Testing` so `WebApplicationFactory<Program>` can boot without a real Postgres instance — see [`tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs`](../tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs).
182 +
183 +## Architecture tests
184 +
185 +[`tests/SplitApp.WebApp.IntegrationTests/Architecture/`](../tests/SplitApp.WebApp.IntegrationTests/Architecture/) runs as part of `dotnet test` and asserts:
186 +
187 +1. **`ModuleBoundaryTests`** — no module project has a `<ProjectReference>` to another module's Application/Infrastructure/Api project; no `Shared.*` project references a module.
188 +2. **`DbContextSchemaIsolationTests`** — each `DbContext` only exposes `DbSet<T>` for entities that live in its own Domain project (plus a tiny framework allowlist).
189 +3. **`CrossModuleNavigationTests`** — Domain entities may declare navigation properties to another module's entity type **only** when annotated `[NotMapped]`. EF never loads these — the WebApp facade hydrates them in-memory after fetching from the appropriate module's DbContext.
190 +
191 +A failing test means a developer just violated the modular-monolith invariant.
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor +3 −0
@@ -0,0 +1,3 @@
1 +<div class="my-component">
2 + This component is defined in the <strong>SplitApp.Modules.Expenses.Api</strong> library.
3 +</div>
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor.css +6 −0
@@ -0,0 +1,6 @@
1 +.my-component {
2 + border: 2px dashed red;
3 + padding: 1em;
4 + margin: 1em 0;
5 + background-image: url('background.png');
6 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/CurrenciesController.cs +39 −0
@@ -0,0 +1,39 @@
1 +using Asp.Versioning;
2 +using Microsoft.AspNetCore.Authentication.JwtBearer;
3 +using Microsoft.AspNetCore.Authorization;
4 +using Microsoft.AspNetCore.Mvc;
5 +using SplitApp.Modules.Expenses.Api.Dto.v1;
6 +using SplitApp.Modules.Expenses.Application.Contracts;
7 +using SplitApp.Modules.Expenses.Domain.Entities;
8 +
9 +namespace SplitApp.Modules.Expenses.Api.Controllers;
10 +
11 +[ApiVersion("1.0")]
12 +[ApiController]
13 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
14 +[Route("api/v{version:apiVersion}/[controller]")]
15 +public class CurrenciesController : ControllerBase
16 +{
17 + private readonly IExpensesUnitOfWork _uow;
18 +
19 + public CurrenciesController(IExpensesUnitOfWork uow)
20 + {
21 + _uow = uow;
22 + }
23 +
24 + [HttpGet]
25 + [AllowAnonymous]
26 + public async Task<ActionResult<List<CurrencyDto>>> List()
27 + {
28 + var all = await _uow.Currencies.GetAllAsync();
29 + return Ok(all.Select(MapToDto).ToList());
30 + }
31 +
32 + private static CurrencyDto MapToDto(Currency c) => new()
33 + {
34 + Id = c.Id,
35 + Code = c.Code,
36 + Name = c.Name.Translate() ?? c.Code,
37 + Symbol = c.Symbol,
38 + };
39 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/ExpensesController.cs +277 −0
@@ -0,0 +1,277 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Expenses.Api.Dto.v1;
8 +using SplitApp.Modules.Expenses.Application;
9 +using SplitApp.Modules.Expenses.Application.Contracts;
10 +using SplitApp.Modules.Expenses.Domain.Entities;
11 +using SplitApp.Modules.Expenses.Domain.Enums;
12 +using SplitApp.Shared.Contracts.Trips.Queries;
13 +using SplitApp.Shared.Messaging.Integration.Users;
14 +
15 +namespace SplitApp.Modules.Expenses.Api.Controllers;
16 +
17 +[ApiVersion("1.0")]
18 +[ApiController]
19 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
20 +[Route("api/v{version:apiVersion}/[controller]")]
21 +public class ExpensesController : ControllerBase
22 +{
23 + private readonly IExpensesUnitOfWork _uow;
24 + private readonly IMediator _mediator;
25 + private readonly IUserLookup _users;
26 +
27 + public ExpensesController(IExpensesUnitOfWork uow, IMediator mediator, IUserLookup users)
28 + {
29 + _uow = uow;
30 + _mediator = mediator;
31 + _users = users;
32 + }
33 +
34 + // Phase-2 frontend compatibility: path-based GET /api/v1/Expenses/trip/{tripId}.
35 + // Query-form GET /api/v1/Expenses?tripId=... also works.
36 + [HttpGet("trip/{tripId:guid}")]
37 + [HttpGet]
38 + public async Task<ActionResult<IEnumerable<ExpenseDto>>> List([FromRoute] Guid? tripId, [FromQuery] Guid? tripIdQuery = null)
39 + {
40 + var userId = CurrentUserId();
41 + if (userId == null) return Unauthorized();
42 +
43 + var resolvedTripId = tripId ?? tripIdQuery ?? Guid.Empty;
44 + if (resolvedTripId == Guid.Empty
45 + && Request.Query.TryGetValue("tripId", out var v) && Guid.TryParse(v, out var parsed))
46 + {
47 + resolvedTripId = parsed;
48 + }
49 + if (resolvedTripId == Guid.Empty) return BadRequest(new { error = "tripId is required." });
50 +
51 + if (!await _mediator.Send(new IsTripParticipantQuery(resolvedTripId, userId.Value))) return Forbid();
52 +
53 + var all = await _uow.Expenses.GetAllAsync();
54 + var forTrip = all.Where(e => e.TripId == resolvedTripId).ToList();
55 + var dtos = await BuildExpenseDtosAsync(forTrip, resolvedTripId, includeSplits: true);
56 + return Ok(dtos);
57 + }
58 +
59 + [HttpGet("{id:guid}")]
60 + public async Task<ActionResult<ExpenseDto>> Get(Guid id)
61 + {
62 + var userId = CurrentUserId();
63 + if (userId == null) return Unauthorized();
64 +
65 + var expense = await _uow.Expenses.GetByIdAsync(id);
66 + if (expense == null) return NotFound();
67 + if (!await _mediator.Send(new IsTripParticipantQuery(expense.TripId, userId.Value))) return Forbid();
68 +
69 + var dtos = await BuildExpenseDtosAsync(new[] { expense }, expense.TripId, includeSplits: true);
70 + return Ok(dtos[0]);
71 + }
72 +
73 + [HttpPost]
74 + public async Task<ActionResult<ExpenseDto>> Create([FromBody] ExpenseCreateDto dto)
75 + {
76 + var userId = CurrentUserId();
77 + if (userId == null) return Unauthorized();
78 +
79 + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, userId.Value))) return Forbid();
80 + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, dto.PaidByUserId)))
81 + return BadRequest(new { error = "Payer is not a participant of this trip." });
82 +
83 + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var split))
84 + return BadRequest(new { error = $"Unknown split method '{dto.SplitMethod}'." });
85 +
86 + foreach (var s in dto.Splits)
87 + {
88 + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, s.UserId)))
89 + return BadRequest(new { error = $"User {s.UserId} is not a participant of this trip." });
90 + }
91 +
92 + var expense = new Expense
93 + {
94 + TripId = dto.TripId,
95 + PaidByUserId = dto.PaidByUserId,
96 + CurrencyId = dto.CurrencyId,
97 + BudgetCategoryId = dto.BudgetCategoryId,
98 + Amount = dto.Amount,
99 + Description = dto.Description,
100 + ExpenseDate = dto.ExpenseDate,
101 + SplitMethod = split,
102 + };
103 + _uow.Expenses.Add(expense);
104 +
105 + foreach (var s in dto.Splits)
106 + {
107 + _uow.ExpenseSplits.Add(new ExpenseSplit
108 + {
109 + ExpenseId = expense.Id,
110 + UserId = s.UserId,
111 + Amount = s.Amount,
112 + Percentage = s.Percentage,
113 + });
114 + }
115 + await _uow.SaveChangesAsync();
116 +
117 + var dtos = await BuildExpenseDtosAsync(new[] { expense }, expense.TripId, includeSplits: true);
118 + return CreatedAtAction(nameof(Get), new { id = expense.Id, version = "1.0" }, dtos[0]);
119 + }
120 +
121 + [HttpPut("{id:guid}")]
122 + public async Task<IActionResult> Update(Guid id, [FromBody] ExpenseCreateDto dto)
123 + {
124 + var userId = CurrentUserId();
125 + if (userId == null) return Unauthorized();
126 +
127 + var existing = await _uow.Expenses.GetByIdAsync(id);
128 + if (existing == null) return NotFound();
129 +
130 + if (!await _mediator.Send(new IsTripParticipantQuery(existing.TripId, userId.Value))) return Forbid();
131 +
132 + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var split))
133 + return BadRequest(new { error = $"Unknown split method '{dto.SplitMethod}'." });
134 +
135 + existing.Amount = dto.Amount;
136 + existing.Description = dto.Description;
137 + existing.ExpenseDate = dto.ExpenseDate;
138 + existing.SplitMethod = split;
139 + existing.BudgetCategoryId = dto.BudgetCategoryId;
140 + existing.CurrencyId = dto.CurrencyId;
141 + existing.PaidByUserId = dto.PaidByUserId;
142 + _uow.Expenses.Update(existing);
143 +
144 + var oldSplits = await _uow.ExpenseSplits.GetAllAsync();
145 + foreach (var s in oldSplits.Where(x => x.ExpenseId == id).ToList())
146 + {
147 + await _uow.ExpenseSplits.RemoveAsync(s.Id);
148 + }
149 + foreach (var s in dto.Splits)
150 + {
151 + if (!await _mediator.Send(new IsTripParticipantQuery(existing.TripId, s.UserId)))
152 + return BadRequest(new { error = $"User {s.UserId} is not a participant of this trip." });
153 + _uow.ExpenseSplits.Add(new ExpenseSplit
154 + {
155 + ExpenseId = id,
156 + UserId = s.UserId,
157 + Amount = s.Amount,
158 + Percentage = s.Percentage,
159 + });
160 + }
161 + await _uow.SaveChangesAsync();
162 + return NoContent();
163 + }
164 +
165 + [HttpDelete("{id:guid}")]
166 + public async Task<IActionResult> Delete(Guid id)
167 + {
168 + var userId = CurrentUserId();
169 + if (userId == null) return Unauthorized();
170 +
171 + var expense = await _uow.Expenses.GetByIdAsync(id);
172 + if (expense == null) return NotFound();
173 + if (!await _mediator.Send(new IsTripParticipantQuery(expense.TripId, userId.Value))) return Forbid();
174 +
175 + var splits = (await _uow.ExpenseSplits.GetAllAsync()).Where(s => s.ExpenseId == id).ToList();
176 + foreach (var s in splits) await _uow.ExpenseSplits.RemoveAsync(s.Id);
177 +
178 + await _uow.Expenses.RemoveAsync(id);
179 + await _uow.SaveChangesAsync();
180 + return NoContent();
181 + }
182 +
183 + private Guid? CurrentUserId()
184 + {
185 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
186 + return Guid.TryParse(raw, out var id) ? id : null;
187 + }
188 +
189 + /// <summary>Hydrates Expense entities into Vue-shaped DTOs in a single batch:
190 + /// fetches user names, currency codes, and budget category names cross-module via MediatR
191 + /// (one query per type, not per row) and computes amountInTripCurrency.</summary>
192 + private async Task<List<ExpenseDto>> BuildExpenseDtosAsync(
193 + IEnumerable<Expense> expenses,
194 + Guid tripId,
195 + bool includeSplits)
196 + {
197 + var list = expenses as IList<Expense> ?? expenses.ToList();
198 + if (list.Count == 0) return new List<ExpenseDto>();
199 +
200 + // Trip + default currency for amountInTripCurrency conversion
201 + var trip = await _mediator.Send(new GetTripByIdQuery(tripId));
202 +
203 + var allSplits = includeSplits
204 + ? (await _uow.ExpenseSplits.GetAllAsync())
205 + .Where(s => list.Any(e => e.Id == s.ExpenseId))
206 + .ToList()
207 + : new List<ExpenseSplit>();
208 +
209 + // Batch lookups
210 + var userIds = list.Select(e => e.PaidByUserId)
211 + .Concat(allSplits.Select(s => s.UserId))
212 + .Distinct().ToList();
213 + var userLookup = await _users.GetByIdsAsync(userIds);
214 +
215 + var currencyIds = list.Select(e => e.CurrencyId).Where(id => id.HasValue).Select(id => id!.Value).ToList();
216 + if (trip != null) currencyIds.Add(trip.DefaultCurrencyId);
217 + currencyIds = currencyIds.Distinct().ToList();
218 + var currencies = currencyIds.Count == 0
219 + ? new List<SplitApp.Shared.Contracts.Expenses.CurrencyDto>()
220 + : (await _mediator.Send(new SplitApp.Shared.Contracts.Expenses.Queries.GetCurrenciesByIdsQuery(currencyIds))).ToList();
221 + var currencyLookup = currencies.ToDictionary(c => c.Id);
222 + var tripDefaultCurrency = trip != null && currencyLookup.TryGetValue(trip.DefaultCurrencyId, out var dc) ? dc : null;
223 +
224 + var categoryIds = list.Select(e => e.BudgetCategoryId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().ToList();
225 + var categories = categoryIds.Count == 0
226 + ? new List<SplitApp.Shared.Contracts.Trips.BudgetCategoryNameDto>()
227 + : (await _mediator.Send(new SplitApp.Shared.Contracts.Trips.Queries.GetBudgetCategoryNamesByIdsQuery(categoryIds))).ToList();
228 + var categoryLookup = categories.ToDictionary(c => c.Id);
229 +
230 + return list.Select(e =>
231 + {
232 + var currency = e.CurrencyId.HasValue && currencyLookup.TryGetValue(e.CurrencyId.Value, out var c) ? c : null;
233 + decimal? converted = null;
234 + if (currency != null && tripDefaultCurrency != null && currency.Code != tripDefaultCurrency.Code)
235 + {
236 + converted = CurrencyConverter.Convert(e.Amount, currency.Code, tripDefaultCurrency.Code);
237 + }
238 + userLookup.TryGetValue(e.PaidByUserId, out var paidByUser);
239 +
240 + var dto = new ExpenseDto
241 + {
242 + Id = e.Id,
243 + TripId = e.TripId,
244 + PaidByUserId = e.PaidByUserId,
245 + PaidByUserName = paidByUser?.DisplayName,
246 + BudgetCategoryId = e.BudgetCategoryId,
247 + BudgetCategoryName = e.BudgetCategoryId.HasValue && categoryLookup.TryGetValue(e.BudgetCategoryId.Value, out var cat)
248 + ? cat.Name : null,
249 + CurrencyId = e.CurrencyId,
250 + CurrencyCode = currency?.Code,
251 + CurrencySymbol = currency?.Symbol,
252 + Amount = e.Amount,
253 + AmountInTripCurrency = converted,
254 + Description = e.Description,
255 + ExpenseDate = e.ExpenseDate,
256 + SplitMethod = e.SplitMethod.ToString(),
257 + };
258 +
259 + if (includeSplits)
260 + {
261 + dto.Splits = allSplits.Where(s => s.ExpenseId == e.Id).Select(s =>
262 + {
263 + userLookup.TryGetValue(s.UserId, out var splitUser);
264 + return new ExpenseSplitDto
265 + {
266 + Id = s.Id,
267 + UserId = s.UserId,
268 + UserName = splitUser?.DisplayName,
269 + Amount = s.Amount,
270 + Percentage = s.Percentage,
271 + };
272 + }).ToList();
273 + }
274 + return dto;
275 + }).ToList();
276 + }
277 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SettlementsController.cs +290 −0
@@ -0,0 +1,290 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Expenses.Api.Dto.v1;
8 +using SplitApp.Modules.Expenses.Application;
9 +using SplitApp.Modules.Expenses.Application.Contracts;
10 +using SplitApp.Modules.Expenses.Domain.Entities;
11 +using SplitApp.Modules.Expenses.Domain.Enums;
12 +using SplitApp.Shared.Contracts.Trips.Queries;
13 +using SplitApp.Shared.Messaging.Integration.Users;
14 +
15 +namespace SplitApp.Modules.Expenses.Api.Controllers;
16 +
17 +[ApiVersion("1.0")]
18 +[ApiController]
19 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
20 +[Route("api/v{version:apiVersion}/[controller]")]
21 +public class SettlementsController : ControllerBase
22 +{
23 + private readonly IExpensesUnitOfWork _uow;
24 + private readonly IMediator _mediator;
25 + private readonly IUserLookup _users;
26 +
27 + public SettlementsController(IExpensesUnitOfWork uow, IMediator mediator, IUserLookup users)
28 + {
29 + _uow = uow;
30 + _mediator = mediator;
31 + _users = users;
32 + }
33 +
34 + [HttpGet("trip/{tripId:guid}")]
35 + public async Task<ActionResult<SettlementPlanDto>> GetLatestPlan(Guid tripId)
36 + {
37 + var userId = CurrentUserId();
38 + if (userId == null) return Unauthorized();
39 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
40 +
41 + var plan = await GetLatestPlanForTripAsync(tripId);
42 + if (plan == null) return NotFound();
43 +
44 + return Ok(await BuildPlanDtoAsync(plan));
45 + }
46 +
47 + [HttpGet("trip/{tripId:guid}/balances")]
48 + public async Task<ActionResult<List<BalanceDto>>> GetBalances(Guid tripId)
49 + {
50 + var userId = CurrentUserId();
51 + if (userId == null) return Unauthorized();
52 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
53 +
54 + return Ok(await CalculateBalancesAsync(tripId));
55 + }
56 +
57 + [HttpGet("trip/{tripId:guid}/summary")]
58 + public async Task<ActionResult<SettlementSummaryDto>> GetSummary(Guid tripId)
59 + {
60 + var userId = CurrentUserId();
61 + if (userId == null) return Unauthorized();
62 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
63 +
64 + var balances = await CalculateBalancesAsync(tripId);
65 + var plan = await GetLatestPlanForTripAsync(tripId);
66 +
67 + return Ok(new SettlementSummaryDto
68 + {
69 + Balances = balances,
70 + LatestPlan = plan == null ? null : await BuildPlanDtoAsync(plan),
71 + });
72 + }
73 +
74 + [HttpPost("payments/{paymentId:guid}/mark-paid")]
75 + public async Task<IActionResult> MarkPaid(Guid paymentId)
76 + {
77 + var userId = CurrentUserId();
78 + if (userId == null) return Unauthorized();
79 +
80 + var payment = await _uow.SettlementPayments.GetByIdAsync(paymentId);
81 + if (payment == null) return NotFound();
82 + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
83 + if (plan == null) return NotFound();
84 +
85 + if (!await _mediator.Send(new IsTripParticipantQuery(plan.TripId, userId.Value))) return Forbid();
86 + if (payment.FromUserId != userId.Value) return Forbid();
87 +
88 + payment.Status = EPaymentStatus.MarkedPaid;
89 + payment.MarkedPaidAt = DateTime.UtcNow;
90 + _uow.SettlementPayments.Update(payment);
91 + await _uow.SaveChangesAsync();
92 + return Ok();
93 + }
94 +
95 + [HttpPost("payments/{paymentId:guid}/confirm")]
96 + public async Task<IActionResult> ConfirmPayment(Guid paymentId)
97 + {
98 + var userId = CurrentUserId();
99 + if (userId == null) return Unauthorized();
100 +
101 + var payment = await _uow.SettlementPayments.GetByIdAsync(paymentId);
102 + if (payment == null) return NotFound();
103 + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
104 + if (plan == null) return NotFound();
105 +
106 + if (!await _mediator.Send(new IsTripParticipantQuery(plan.TripId, userId.Value))) return Forbid();
107 + if (payment.ToUserId != userId.Value) return Forbid();
108 +
109 + payment.Status = EPaymentStatus.Confirmed;
110 + payment.ConfirmedAt = DateTime.UtcNow;
111 + _uow.SettlementPayments.Update(payment);
112 +
113 + var allPayments = (await _uow.SettlementPayments.GetAllAsync())
114 + .Where(p => p.SettlementPlanId == plan.Id)
115 + .ToList();
116 + var allConfirmed = allPayments.All(p => p.Id == paymentId || p.Status == EPaymentStatus.Confirmed);
117 +
118 + plan.Status = allConfirmed ? ESettlementStatus.Completed : ESettlementStatus.InProgress;
119 + if (allConfirmed) plan.CompletedAt = DateTime.UtcNow;
120 + _uow.SettlementPlans.Update(plan);
121 +
122 + await _uow.SaveChangesAsync();
123 +
124 + if (allConfirmed)
125 + {
126 + await _mediator.Publish(new SplitApp.Shared.Contracts.Expenses.Events.SettlementPlanCompletedEvent(plan.TripId, plan.Id));
127 + }
128 +
129 + return Ok();
130 + }
131 +
132 + [HttpPost("trip/{tripId:guid}/calculate")]
133 + public async Task<ActionResult<SettlementPlanDto>> CalculatePlan(Guid tripId)
134 + {
135 + var userId = CurrentUserId();
136 + if (userId == null) return Unauthorized();
137 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
138 +
139 + var balances = await CalculateBalancesAsync(tripId);
140 + var creditors = balances.Where(b => b.Balance > 0.01m)
141 + .Select(b => new { b.UserId, Amount = b.Balance })
142 + .OrderByDescending(c => c.Amount).ToList();
143 + var debtors = balances.Where(b => b.Balance < -0.01m)
144 + .Select(b => new { b.UserId, Amount = -b.Balance })
145 + .OrderByDescending(d => d.Amount).ToList();
146 +
147 + if (creditors.Count == 0 || debtors.Count == 0)
148 + return NotFound("No outstanding balances to settle.");
149 +
150 + var plan = new SettlementPlan
151 + {
152 + TripId = tripId,
153 + CreatedByUserId = userId.Value,
154 + TotalAmount = creditors.Sum(c => c.Amount),
155 + Status = ESettlementStatus.Pending,
156 + };
157 + _uow.SettlementPlans.Add(plan);
158 +
159 + var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount);
160 + var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount);
161 + var sortedCreditors = creditBalances.Keys.ToList();
162 + var sortedDebtors = debtBalances.Keys.ToList();
163 + var ci = 0;
164 + var di = 0;
165 +
166 + while (ci < sortedCreditors.Count && di < sortedDebtors.Count)
167 + {
168 + var creditorId = sortedCreditors[ci];
169 + var debtorId = sortedDebtors[di];
170 + var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]);
171 +
172 + if (amount > 0.01m)
173 + {
174 + _uow.SettlementPayments.Add(new SettlementPayment
175 + {
176 + SettlementPlanId = plan.Id,
177 + FromUserId = debtorId,
178 + ToUserId = creditorId,
179 + Amount = Math.Round(amount, 2),
180 + Status = EPaymentStatus.Pending,
181 + });
182 + }
183 +
184 + creditBalances[creditorId] -= amount;
185 + debtBalances[debtorId] -= amount;
186 + if (creditBalances[creditorId] < 0.01m) ci++;
187 + if (debtBalances[debtorId] < 0.01m) di++;
188 + }
189 +
190 + await _uow.SaveChangesAsync();
191 +
192 + return Ok(await BuildPlanDtoAsync(plan));
193 + }
194 +
195 + private Guid? CurrentUserId()
196 + {
197 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
198 + return Guid.TryParse(raw, out var id) ? id : null;
199 + }
200 +
201 + private async Task<SettlementPlan?> GetLatestPlanForTripAsync(Guid tripId)
202 + {
203 + var all = await _uow.SettlementPlans.GetAllAsync();
204 + return all.Where(p => p.TripId == tripId)
205 + .OrderByDescending(p => p.CreatedAt)
206 + .FirstOrDefault();
207 + }
208 +
209 + private async Task<List<BalanceDto>> CalculateBalancesAsync(Guid tripId)
210 + {
211 + var trip = await _mediator.Send(new GetTripByIdQuery(tripId));
212 + var participants = await _mediator.Send(new GetTripParticipantsQuery(tripId));
213 +
214 + if (trip == null || participants.Count == 0) return new List<BalanceDto>();
215 +
216 + var defaultCurrency = await _uow.Currencies.GetByIdAsync(trip.DefaultCurrencyId);
217 + var defaultCode = defaultCurrency?.Code ?? "EUR";
218 +
219 + var userIds = participants.Select(p => p.UserId).Distinct().ToList();
220 + var users = await _users.GetByIdsAsync(userIds);
221 + var nameLookup = users.ToDictionary(kv => kv.Key, kv => kv.Value.DisplayName);
222 +
223 + var balances = participants.ToDictionary(
224 + p => p.UserId,
225 + p => new BalanceDto
226 + {
227 + UserId = p.UserId,
228 + UserName = nameLookup.TryGetValue(p.UserId, out var name) ? name : null,
229 + Balance = 0,
230 + });
231 +
232 + var allExpenses = (await _uow.Expenses.GetAllAsync()).Where(e => e.TripId == tripId).ToList();
233 + var allSplits = (await _uow.ExpenseSplits.GetAllAsync()).ToList();
234 + var allCurrencies = (await _uow.Currencies.GetAllAsync()).ToDictionary(c => c.Id, c => c.Code);
235 +
236 + foreach (var expense in allExpenses)
237 + {
238 + var fromCode = expense.CurrencyId.HasValue
239 + && allCurrencies.TryGetValue(expense.CurrencyId.Value, out var code)
240 + ? code : defaultCode;
241 + var paidConverted = CurrencyConverter.Convert(expense.Amount, fromCode, defaultCode);
242 + if (balances.TryGetValue(expense.PaidByUserId, out var payerEntry))
243 + {
244 + payerEntry.Balance += paidConverted;
245 + }
246 +
247 + var splitsForExpense = allSplits.Where(s => s.ExpenseId == expense.Id);
248 + foreach (var split in splitsForExpense)
249 + {
250 + if (!balances.TryGetValue(split.UserId, out var splitEntry)) continue;
251 + var splitConverted = CurrencyConverter.Convert(split.Amount, fromCode, defaultCode);
252 + splitEntry.Balance -= splitConverted;
253 + }
254 + }
255 +
256 + return balances.Values.OrderByDescending(b => b.Balance).ToList();
257 + }
258 +
259 + private async Task<SettlementPlanDto> BuildPlanDtoAsync(SettlementPlan plan)
260 + {
261 + var allPayments = (await _uow.SettlementPayments.GetAllAsync())
262 + .Where(p => p.SettlementPlanId == plan.Id)
263 + .ToList();
264 +
265 + var userIds = allPayments.SelectMany(p => new[] { p.FromUserId, p.ToUserId }).Distinct().ToList();
266 + var users = await _users.GetByIdsAsync(userIds);
267 + var nameLookup = users.ToDictionary(kv => kv.Key, kv => kv.Value.DisplayName);
268 +
269 + return new SettlementPlanDto
270 + {
271 + Id = plan.Id,
272 + TripId = plan.TripId,
273 + TotalAmount = plan.TotalAmount,
274 + Status = plan.Status.ToString(),
275 + CompletedAt = plan.CompletedAt,
276 + Payments = allPayments.Select(p => new SettlementPaymentDto
277 + {
278 + Id = p.Id,
279 + FromUserId = p.FromUserId,
280 + FromUserName = nameLookup.TryGetValue(p.FromUserId, out var fn) ? fn : null,
281 + ToUserId = p.ToUserId,
282 + ToUserName = nameLookup.TryGetValue(p.ToUserId, out var tn) ? tn : null,
283 + Amount = p.Amount,
284 + Status = p.Status.ToString(),
285 + MarkedPaidAt = p.MarkedPaidAt,
286 + ConfirmedAt = p.ConfirmedAt,
287 + }).ToList(),
288 + };
289 + }
290 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SplitPresetsController.cs +208 −0
@@ -0,0 +1,208 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Expenses.Api.Dto.v1;
8 +using SplitApp.Modules.Expenses.Application.Contracts;
9 +using SplitApp.Modules.Expenses.Domain.Entities;
10 +using SplitApp.Modules.Expenses.Domain.Enums;
11 +using SplitApp.Shared.Contracts.Trips.Queries;
12 +using SplitApp.Shared.Messaging.Integration.Users;
13 +
14 +namespace SplitApp.Modules.Expenses.Api.Controllers;
15 +
16 +[ApiVersion("1.0")]
17 +[ApiController]
18 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
19 +[Route("api/v{version:apiVersion}/[controller]")]
20 +public class SplitPresetsController : ControllerBase
21 +{
22 + private readonly IExpensesUnitOfWork _uow;
23 + private readonly IMediator _mediator;
24 + private readonly IUserLookup _users;
25 +
26 + public SplitPresetsController(IExpensesUnitOfWork uow, IMediator mediator, IUserLookup users)
27 + {
28 + _uow = uow;
29 + _mediator = mediator;
30 + _users = users;
31 + }
32 +
33 + [HttpGet("trip/{tripId:guid}")]
34 + public async Task<ActionResult<List<SplitPresetDto>>> GetForTrip(Guid tripId)
35 + {
36 + var userId = CurrentUserId();
37 + if (userId == null) return Unauthorized();
38 + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid();
39 +
40 + var allPresets = await _uow.SplitPresets.GetAllAsync();
41 + var presets = allPresets.Where(p => p.TripId == tripId).ToList();
42 + return Ok(await BuildDtosAsync(presets));
43 + }
44 +
45 + [HttpGet("{id:guid}")]
46 + public async Task<ActionResult<SplitPresetDto>> Get(Guid id)
47 + {
48 + var userId = CurrentUserId();
49 + if (userId == null) return Unauthorized();
50 +
51 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
52 + if (preset == null) return NotFound();
53 + if (!await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value))) return NotFound();
54 +
55 + var dtos = await BuildDtosAsync(new[] { preset });
56 + return Ok(dtos.Single());
57 + }
58 +
59 + [HttpPost]
60 + public async Task<ActionResult<SplitPresetDto>> Create([FromBody] SplitPresetCreateDto dto)
61 + {
62 + var userId = CurrentUserId();
63 + if (userId == null) return Unauthorized();
64 + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, userId.Value))) return Forbid();
65 +
66 + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var method))
67 + return BadRequest($"Unknown split method '{dto.SplitMethod}'.");
68 +
69 + var preset = new SplitPreset
70 + {
71 + TripId = dto.TripId,
72 + Name = dto.Name,
73 + SplitMethod = method,
74 + CreatedById = userId.Value,
75 + };
76 + _uow.SplitPresets.Add(preset);
77 +
78 + if (dto.Members != null)
79 + {
80 + foreach (var m in dto.Members)
81 + {
82 + _uow.SplitPresetMembers.Add(new SplitPresetMember
83 + {
84 + SplitPresetId = preset.Id,
85 + UserId = m.UserId,
86 + ShareWeight = m.ShareWeight,
87 + Percentage = m.Percentage,
88 + });
89 + }
90 + }
91 +
92 + await _uow.SaveChangesAsync();
93 +
94 + var dtos = await BuildDtosAsync(new[] { preset });
95 + return CreatedAtAction(nameof(Get), new { id = preset.Id }, dtos.Single());
96 + }
97 +
98 + [HttpPut("{id:guid}")]
99 + public async Task<IActionResult> Update(Guid id, [FromBody] SplitPresetCreateDto dto)
100 + {
101 + var userId = CurrentUserId();
102 + if (userId == null) return Unauthorized();
103 +
104 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
105 + if (preset == null) return NotFound();
106 +
107 + var canEdit = preset.CreatedById == userId.Value
108 + || await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value));
109 + if (!canEdit) return Forbid();
110 +
111 + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var method))
112 + return BadRequest($"Unknown split method '{dto.SplitMethod}'.");
113 +
114 + preset.Name = dto.Name;
115 + preset.SplitMethod = method;
116 + _uow.SplitPresets.Update(preset);
117 +
118 + var allMembers = await _uow.SplitPresetMembers.GetAllAsync();
119 + foreach (var existing in allMembers.Where(m => m.SplitPresetId == id))
120 + {
121 + await _uow.SplitPresetMembers.RemoveAsync(existing.Id);
122 + }
123 +
124 + if (dto.Members != null)
125 + {
126 + foreach (var m in dto.Members)
127 + {
128 + _uow.SplitPresetMembers.Add(new SplitPresetMember
129 + {
130 + SplitPresetId = preset.Id,
131 + UserId = m.UserId,
132 + ShareWeight = m.ShareWeight,
133 + Percentage = m.Percentage,
134 + });
135 + }
136 + }
137 +
138 + await _uow.SaveChangesAsync();
139 + return NoContent();
140 + }
141 +
142 + [HttpDelete("{id:guid}")]
143 + public async Task<IActionResult> Delete(Guid id)
144 + {
145 + var userId = CurrentUserId();
146 + if (userId == null) return Unauthorized();
147 +
148 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
149 + if (preset == null) return NotFound();
150 +
151 + if (preset.CreatedById != userId.Value
152 + && !await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value)))
153 + {
154 + return Forbid();
155 + }
156 +
157 + var allMembers = await _uow.SplitPresetMembers.GetAllAsync();
158 + foreach (var m in allMembers.Where(m => m.SplitPresetId == id))
159 + {
160 + await _uow.SplitPresetMembers.RemoveAsync(m.Id);
161 + }
162 + await _uow.SplitPresets.RemoveAsync(id);
163 + await _uow.SaveChangesAsync();
164 + return NoContent();
165 + }
166 +
167 + private Guid? CurrentUserId()
168 + {
169 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
170 + return Guid.TryParse(raw, out var id) ? id : null;
171 + }
172 +
173 + private async Task<List<SplitPresetDto>> BuildDtosAsync(IEnumerable<SplitPreset> presets)
174 + {
175 + var presetList = presets.ToList();
176 + if (presetList.Count == 0) return new List<SplitPresetDto>();
177 +
178 + var presetIds = presetList.Select(p => p.Id).ToHashSet();
179 + var allMembers = (await _uow.SplitPresetMembers.GetAllAsync())
180 + .Where(m => presetIds.Contains(m.SplitPresetId))
181 + .ToList();
182 +
183 + var userIds = presetList.Select(p => p.CreatedById)
184 + .Concat(allMembers.Select(m => m.UserId))
185 + .Distinct()
186 + .ToList();
187 +
188 + var users = await _users.GetByIdsAsync(userIds);
189 + var nameLookup = users.ToDictionary(kv => kv.Key, kv => kv.Value.DisplayName);
190 +
191 + return presetList.Select(p => new SplitPresetDto
192 + {
193 + Id = p.Id,
194 + TripId = p.TripId,
195 + Name = p.Name,
196 + SplitMethod = p.SplitMethod.ToString(),
197 + CreatedByUserName = nameLookup.TryGetValue(p.CreatedById, out var creator) ? creator : null,
198 + Members = allMembers.Where(m => m.SplitPresetId == p.Id).Select(m => new SplitPresetMemberDto
199 + {
200 + Id = m.Id,
201 + UserId = m.UserId,
202 + UserName = nameLookup.TryGetValue(m.UserId, out var name) ? name : null,
203 + ShareWeight = m.ShareWeight,
204 + Percentage = m.Percentage,
205 + }).ToList(),
206 + }).ToList();
207 + }
208 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/CurrencyDto.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Expenses.Api.Dto.v1;
2 +
3 +public class CurrencyDto
4 +{
5 + public Guid Id { get; set; }
6 + public string Code { get; set; } = default!;
7 + public string Name { get; set; } = default!;
8 + public string Symbol { get; set; } = default!;
9 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/ExpenseDto.cs +49 −0
@@ -0,0 +1,49 @@
1 +namespace SplitApp.Modules.Expenses.Api.Dto.v1;
2 +
3 +public class ExpenseDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public Guid PaidByUserId { get; set; }
8 + public string? PaidByUserName { get; set; }
9 + public Guid? BudgetCategoryId { get; set; }
10 + public string? BudgetCategoryName { get; set; }
11 + public Guid? CurrencyId { get; set; }
12 + public string? CurrencyCode { get; set; }
13 + public string? CurrencySymbol { get; set; }
14 + public decimal Amount { get; set; }
15 + public decimal? AmountInTripCurrency { get; set; }
16 + public string? Description { get; set; }
17 + public DateTime ExpenseDate { get; set; }
18 + public string SplitMethod { get; set; } = default!;
19 + public List<ExpenseSplitDto>? Splits { get; set; }
20 +}
21 +
22 +public class ExpenseSplitDto
23 +{
24 + public Guid Id { get; set; }
25 + public Guid UserId { get; set; }
26 + public string? UserName { get; set; }
27 + public decimal Amount { get; set; }
28 + public decimal? Percentage { get; set; }
29 +}
30 +
31 +public class ExpenseCreateDto
32 +{
33 + public Guid TripId { get; set; }
34 + public Guid PaidByUserId { get; set; }
35 + public Guid? CurrencyId { get; set; }
36 + public Guid? BudgetCategoryId { get; set; }
37 + public decimal Amount { get; set; }
38 + public string? Description { get; set; }
39 + public DateTime ExpenseDate { get; set; }
40 + public string SplitMethod { get; set; } = "EqualAll";
41 + public IList<ExpenseSplitCreateDto> Splits { get; set; } = new List<ExpenseSplitCreateDto>();
42 +}
43 +
44 +public class ExpenseSplitCreateDto
45 +{
46 + public Guid UserId { get; set; }
47 + public decimal Amount { get; set; }
48 + public decimal? Percentage { get; set; }
49 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SettlementDto.cs +37 −0
@@ -0,0 +1,37 @@
1 +namespace SplitApp.Modules.Expenses.Api.Dto.v1;
2 +
3 +public class SettlementPlanDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public decimal TotalAmount { get; set; }
8 + public string Status { get; set; } = default!;
9 + public DateTime? CompletedAt { get; set; }
10 + public List<SettlementPaymentDto>? Payments { get; set; }
11 +}
12 +
13 +public class SettlementPaymentDto
14 +{
15 + public Guid Id { get; set; }
16 + public Guid FromUserId { get; set; }
17 + public string? FromUserName { get; set; }
18 + public Guid ToUserId { get; set; }
19 + public string? ToUserName { get; set; }
20 + public decimal Amount { get; set; }
21 + public string Status { get; set; } = default!;
22 + public DateTime? MarkedPaidAt { get; set; }
23 + public DateTime? ConfirmedAt { get; set; }
24 +}
25 +
26 +public class BalanceDto
27 +{
28 + public Guid UserId { get; set; }
29 + public string? UserName { get; set; }
30 + public decimal Balance { get; set; }
31 +}
32 +
33 +public class SettlementSummaryDto
34 +{
35 + public List<BalanceDto> Balances { get; set; } = new();
36 + public SettlementPlanDto? LatestPlan { get; set; }
37 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SplitPresetDto.cs +35 −0
@@ -0,0 +1,35 @@
1 +namespace SplitApp.Modules.Expenses.Api.Dto.v1;
2 +
3 +public class SplitPresetDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public string Name { get; set; } = default!;
8 + public string SplitMethod { get; set; } = default!;
9 + public string? CreatedByUserName { get; set; }
10 + public List<SplitPresetMemberDto>? Members { get; set; }
11 +}
12 +
13 +public class SplitPresetMemberDto
14 +{
15 + public Guid Id { get; set; }
16 + public Guid UserId { get; set; }
17 + public string? UserName { get; set; }
18 + public decimal? ShareWeight { get; set; }
19 + public decimal? Percentage { get; set; }
20 +}
21 +
22 +public class SplitPresetCreateDto
23 +{
24 + public Guid TripId { get; set; }
25 + public string Name { get; set; } = default!;
26 + public string SplitMethod { get; set; } = default!;
27 + public List<SplitPresetMemberCreateDto>? Members { get; set; }
28 +}
29 +
30 +public class SplitPresetMemberCreateDto
31 +{
32 + public Guid UserId { get; set; }
33 + public decimal? ShareWeight { get; set; }
34 + public decimal? Percentage { get; set; }
35 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/ExampleJsInterop.cs +31 −0
@@ -0,0 +1,31 @@
1 +using Microsoft.JSInterop;
2 +
3 +namespace SplitApp.Modules.Expenses.Api;
4 +
5 +// This class provides an example of how JavaScript functionality can be wrapped
6 +// in a .NET class for easy consumption. The associated JavaScript module is
7 +// loaded on demand when first needed.
8 +//
9 +// This class can be registered as scoped DI service and then injected into Blazor
10 +// components for use.
11 +
12 +public class ExampleJsInterop(IJSRuntime jsRuntime) : IAsyncDisposable
13 +{
14 + private readonly Lazy<Task<IJSObjectReference>> moduleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
15 + "import", "./_content/SplitApp.Modules.Expenses.Api/exampleJsInterop.js").AsTask());
16 +
17 + public async ValueTask<string> Prompt(string message)
18 + {
19 + var module = await moduleTask.Value;
20 + return await module.InvokeAsync<string>("showPrompt", message);
21 + }
22 +
23 + public async ValueTask DisposeAsync()
24 + {
25 + if (moduleTask.IsValueCreated)
26 + {
27 + var module = await moduleTask.Value;
28 + await module.DisposeAsync();
29 + }
30 + }
31 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/SplitApp.Modules.Expenses.Api.csproj +27 −0
@@ -0,0 +1,27 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Razor">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + </PropertyGroup>
8 +
9 +
10 + <ItemGroup>
11 + <SupportedPlatform Include="browser" />
12 + </ItemGroup>
13 +
14 + <ItemGroup>
15 + <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
16 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
18 + </ItemGroup>
19 +
20 + <ItemGroup>
21 + <ProjectReference Include="..\SplitApp.Modules.Expenses.Application\SplitApp.Modules.Expenses.Application.csproj" />
22 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
23 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
24 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Messaging\SplitApp.Shared.Messaging.csproj" />
25 + </ItemGroup>
26 +
27 +</Project>
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/_Imports.razor +1 −0
@@ -0,0 +1 @@
1 +@using Microsoft.AspNetCore.Components.Web
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/background.png +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/exampleJsInterop.js +6 −0
@@ -0,0 +1,6 @@
1 +// This is a JavaScript module that is loaded on demand. It can export any number of
2 +// functions, and may import other JavaScript modules if required.
3 +
4 +export function showPrompt(message) {
5 + return prompt(message, 'Type anything here');
6 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/Contracts/IExpensesUnitOfWork.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Shared.Kernel.Persistence;
3 +
4 +namespace SplitApp.Modules.Expenses.Application.Contracts;
5 +
6 +public interface IExpensesUnitOfWork : IUnitOfWork
7 +{
8 + IBaseRepository<Expense> Expenses { get; }
9 + IBaseRepository<ExpenseSplit> ExpenseSplits { get; }
10 + IBaseRepository<SettlementPlan> SettlementPlans { get; }
11 + IBaseRepository<SettlementPayment> SettlementPayments { get; }
12 + IBaseRepository<SplitPreset> SplitPresets { get; }
13 + IBaseRepository<SplitPresetMember> SplitPresetMembers { get; }
14 + IBaseRepository<Currency> Currencies { get; }
15 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/CurrencyConverter.cs +22 −0
@@ -0,0 +1,22 @@
1 +namespace SplitApp.Modules.Expenses.Application;
2 +
3 +public static class CurrencyConverter
4 +{
5 + private static readonly Dictionary<string, decimal> RatesToEur = new()
6 + {
7 + { "EUR", 1.0m },
8 + { "USD", 0.92m },
9 + { "GBP", 1.16m },
10 + { "SEK", 0.087m },
11 + { "NOK", 0.086m },
12 + };
13 +
14 + public static decimal Convert(decimal amount, string fromCurrencyCode, string toCurrencyCode)
15 + {
16 + if (fromCurrencyCode == toCurrencyCode) return amount;
17 + if (!RatesToEur.TryGetValue(fromCurrencyCode, out var fromRate)) return amount;
18 + if (!RatesToEur.TryGetValue(toCurrencyCode, out var toRate)) return amount;
19 + var amountInEur = amount * fromRate;
20 + return Math.Round(amountInEur / toRate, 2);
21 + }
22 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/ExpensesModuleMarker.cs +4 −0
@@ -0,0 +1,4 @@
1 +namespace SplitApp.Modules.Expenses.Application;
2 +
3 +/// <summary>Assembly marker for MediatR handler scanning. Must remain in the Application assembly.</summary>
4 +public sealed class ExpensesModuleMarker;
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/SplitApp.Modules.Expenses.Application.csproj +19 −0
@@ -0,0 +1,19 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Expenses.Domain\SplitApp.Modules.Expenses.Domain.csproj" />
5 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="MediatR" Version="12.4.1" />
11 + </ItemGroup>
12 +
13 + <PropertyGroup>
14 + <TargetFramework>net10.0</TargetFramework>
15 + <ImplicitUsings>enable</ImplicitUsings>
16 + <Nullable>enable</Nullable>
17 + </PropertyGroup>
18 +
19 +</Project>
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Currency.cs +16 −0
@@ -0,0 +1,16 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Localization;
4 +
5 +namespace SplitApp.Modules.Expenses.Domain.Entities;
6 +
7 +public class Currency : BaseEntity
8 +{
9 + [MaxLength(3)]
10 + public string Code { get; set; } = default!;
11 +
12 + public LangStr Name { get; set; } = new();
13 +
14 + [MaxLength(10)]
15 + public string Symbol { get; set; } = default!;
16 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Expense.cs +39 −0
@@ -0,0 +1,39 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Expenses.Domain.Enums;
4 +using SplitApp.Shared.Contracts.Users;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Expenses.Domain.Entities;
8 +
9 +public class Expense : BaseEntity
10 +{
11 + /// <summary>FK to trips.Trip.</summary>
12 + public Guid TripId { get; set; }
13 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
14 + [NotMapped] public object? Trip { get; set; }
15 +
16 + /// <summary>FK to users.AspNetUsers.</summary>
17 + public Guid PaidByUserId { get; set; }
18 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
19 + [NotMapped] public UserDto? PaidByUser { get; set; }
20 +
21 + /// <summary>FK to trips.BudgetCategory.</summary>
22 + public Guid? BudgetCategoryId { get; set; }
23 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
24 + [NotMapped] public object? BudgetCategory { get; set; }
25 +
26 + public Guid? CurrencyId { get; set; }
27 + public Currency? Currency { get; set; }
28 +
29 + public decimal Amount { get; set; }
30 +
31 + [MaxLength(500)]
32 + public string? Description { get; set; }
33 +
34 + public DateTime ExpenseDate { get; set; }
35 +
36 + public ESplitMethod SplitMethod { get; set; }
37 +
38 + public ICollection<ExpenseSplit>? Splits { get; set; }
39 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/ExpenseSplit.cs +19 −0
@@ -0,0 +1,19 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Shared.Contracts.Users;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Expenses.Domain.Entities;
6 +
7 +public class ExpenseSplit : BaseEntity
8 +{
9 + public Guid ExpenseId { get; set; }
10 + public Expense? Expense { get; set; }
11 +
12 + /// <summary>FK to users.AspNetUsers.</summary>
13 + public Guid UserId { get; set; }
14 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
15 + [NotMapped] public UserDto? User { get; set; }
16 +
17 + public decimal Amount { get; set; }
18 + public decimal? Percentage { get; set; }
19 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPayment.cs +30 −0
@@ -0,0 +1,30 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Modules.Expenses.Domain.Enums;
3 +using SplitApp.Shared.Contracts.Users;
4 +using SplitApp.Shared.Kernel.Domain;
5 +
6 +namespace SplitApp.Modules.Expenses.Domain.Entities;
7 +
8 +public class SettlementPayment : BaseEntity
9 +{
10 + public Guid SettlementPlanId { get; set; }
11 + public SettlementPlan? SettlementPlan { get; set; }
12 +
13 + /// <summary>FK to users.AspNetUsers.</summary>
14 + public Guid FromUserId { get; set; }
15 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
16 + [NotMapped] public UserDto? FromUser { get; set; }
17 +
18 + /// <summary>FK to users.AspNetUsers.</summary>
19 + public Guid ToUserId { get; set; }
20 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
21 + [NotMapped] public UserDto? ToUser { get; set; }
22 +
23 + public decimal Amount { get; set; }
24 +
25 + public EPaymentStatus Status { get; set; } = EPaymentStatus.Pending;
26 +
27 + public DateTime? MarkedPaidAt { get; set; }
28 +
29 + public DateTime? ConfirmedAt { get; set; }
30 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPlan.cs +27 −0
@@ -0,0 +1,27 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Modules.Expenses.Domain.Enums;
3 +using SplitApp.Shared.Contracts.Users;
4 +using SplitApp.Shared.Kernel.Domain;
5 +
6 +namespace SplitApp.Modules.Expenses.Domain.Entities;
7 +
8 +public class SettlementPlan : BaseEntity
9 +{
10 + /// <summary>FK to trips.Trip.</summary>
11 + public Guid TripId { get; set; }
12 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
13 + [NotMapped] public object? Trip { get; set; }
14 +
15 + /// <summary>FK to users.AspNetUsers.</summary>
16 + public Guid CreatedByUserId { get; set; }
17 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
18 + [NotMapped] public UserDto? CreatedByUser { get; set; }
19 +
20 + public decimal TotalAmount { get; set; }
21 +
22 + public ESettlementStatus Status { get; set; } = ESettlementStatus.Pending;
23 +
24 + public DateTime? CompletedAt { get; set; }
25 +
26 + public ICollection<SettlementPayment>? Payments { get; set; }
27 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPreset.cs +27 −0
@@ -0,0 +1,27 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Expenses.Domain.Enums;
4 +using SplitApp.Shared.Contracts.Users;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Expenses.Domain.Entities;
8 +
9 +public class SplitPreset : BaseEntity
10 +{
11 + /// <summary>FK to trips.Trip.</summary>
12 + public Guid TripId { get; set; }
13 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
14 + [NotMapped] public object? Trip { get; set; }
15 +
16 + [MaxLength(200)]
17 + public string Name { get; set; } = default!;
18 +
19 + public ESplitMethod SplitMethod { get; set; }
20 +
21 + /// <summary>FK to users.AspNetUsers.</summary>
22 + public Guid CreatedById { get; set; }
23 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
24 + [NotMapped] public UserDto? CreatedBy { get; set; }
25 +
26 + public ICollection<SplitPresetMember>? Members { get; set; }
27 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPresetMember.cs +19 −0
@@ -0,0 +1,19 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Shared.Contracts.Users;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Expenses.Domain.Entities;
6 +
7 +public class SplitPresetMember : BaseEntity
8 +{
9 + public Guid SplitPresetId { get; set; }
10 + public SplitPreset? SplitPreset { get; set; }
11 +
12 + /// <summary>FK to users.AspNetUsers.</summary>
13 + public Guid UserId { get; set; }
14 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
15 + [NotMapped] public UserDto? User { get; set; }
16 +
17 + public decimal? ShareWeight { get; set; }
18 + public decimal? Percentage { get; set; }
19 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/EPaymentStatus.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Modules.Expenses.Domain.Enums;
2 +
3 +public enum EPaymentStatus
4 +{
5 + Pending,
6 + MarkedPaid,
7 + Confirmed
8 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESettlementStatus.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Modules.Expenses.Domain.Enums;
2 +
3 +public enum ESettlementStatus
4 +{
5 + Pending,
6 + InProgress,
7 + Completed
8 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESplitMethod.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Expenses.Domain.Enums;
2 +
3 +public enum ESplitMethod
4 +{
5 + EqualAll,
6 + EqualSubset,
7 + ExactAmounts,
8 + Percentages
9 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/SplitApp.Modules.Expenses.Domain.csproj +14 −0
@@ -0,0 +1,14 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
5 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
6 + </ItemGroup>
7 +
8 + <PropertyGroup>
9 + <TargetFramework>net10.0</TargetFramework>
10 + <ImplicitUsings>enable</ImplicitUsings>
11 + <Nullable>enable</Nullable>
12 + </PropertyGroup>
13 +
14 +</Project>
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/ExpensesModuleExtensions.cs +64 −0
@@ -0,0 +1,64 @@
1 +using Microsoft.AspNetCore.Builder;
2 +using Microsoft.EntityFrameworkCore;
3 +using Microsoft.Extensions.Configuration;
4 +using Microsoft.Extensions.DependencyInjection;
5 +using SplitApp.Modules.Expenses.Application;
6 +using SplitApp.Modules.Expenses.Application.Contracts;
7 +using SplitApp.Modules.Expenses.Domain.Entities;
8 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
9 +using SplitApp.Shared.Kernel.Localization;
10 +
11 +namespace SplitApp.Modules.Expenses.Infrastructure;
12 +
13 +public static class ExpensesModuleExtensions
14 +{
15 + public static IServiceCollection AddExpensesModule(
16 + this IServiceCollection services,
17 + IConfiguration configuration)
18 + {
19 + services.AddDbContext<ExpensesDbContext>(opt =>
20 + {
21 + opt.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
22 + });
23 +
24 + services.AddScoped<IExpensesUnitOfWork, ExpensesUnitOfWork>();
25 +
26 + services.AddMediatR(cfg =>
27 + {
28 + cfg.RegisterServicesFromAssemblyContaining<ExpensesModuleMarker>();
29 + cfg.RegisterServicesFromAssembly(typeof(ExpensesModuleExtensions).Assembly);
30 + });
31 +
32 + return services;
33 + }
34 +
35 + public static IApplicationBuilder UseExpensesModule(this IApplicationBuilder app)
36 + {
37 + using var scope = app.ApplicationServices.CreateScope();
38 + var db = scope.ServiceProvider.GetRequiredService<ExpensesDbContext>();
39 + db.Database.Migrate();
40 +
41 + if (!db.Currencies.Any())
42 + {
43 + var seed = new (string Code, string NameEn, string NameEt, string Symbol)[]
44 + {
45 + ("EUR", "Euro", "Euro", "€"),
46 + ("USD", "US Dollar", "USA dollar", "$"),
47 + ("GBP", "British Pound", "Briti nael", "£"),
48 + ("SEK", "Swedish Krona", "Rootsi kroon", "kr"),
49 + ("NOK", "Norwegian Krone","Norra kroon", "kr"),
50 + };
51 +
52 + foreach (var c in seed)
53 + {
54 + var name = new LangStr(c.NameEn, "en");
55 + name["et"] = c.NameEt;
56 + db.Currencies.Add(new Currency { Code = c.Code, Name = name, Symbol = c.Symbol });
57 + }
58 +
59 + db.SaveChanges();
60 + }
61 +
62 + return app;
63 + }
64 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/CalculateSettlementHandler.cs +113 −0
@@ -0,0 +1,113 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Application;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
7 +using SplitApp.Shared.Contracts.Expenses.Commands;
8 +using SplitApp.Shared.Contracts.Trips.Queries;
9 +
10 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
11 +
12 +public class CalculateSettlementHandler : IRequestHandler<CalculateSettlementCommand, Guid?>
13 +{
14 + private readonly ExpensesDbContext _db;
15 + private readonly IMediator _mediator;
16 +
17 + public CalculateSettlementHandler(ExpensesDbContext db, IMediator mediator)
18 + {
19 + _db = db;
20 + _mediator = mediator;
21 + }
22 +
23 + public async Task<Guid?> Handle(CalculateSettlementCommand request, CancellationToken cancellationToken)
24 + {
25 + var trip = await _mediator.Send(new GetTripByIdQuery(request.TripId), cancellationToken);
26 + if (trip == null) return null;
27 +
28 + var participants = await _mediator.Send(new GetTripParticipantsQuery(request.TripId), cancellationToken);
29 + if (participants.Count == 0) return null;
30 +
31 + var defaultCurrency = await _db.Currencies.FirstOrDefaultAsync(c => c.Id == trip.DefaultCurrencyId, cancellationToken);
32 + var defaultCode = defaultCurrency?.Code ?? "EUR";
33 +
34 + var balances = participants.ToDictionary(p => p.UserId, _ => 0m);
35 +
36 + var allCurrencies = await _db.Currencies.ToDictionaryAsync(c => c.Id, c => c.Code, cancellationToken);
37 + var tripExpenses = await _db.Expenses.Where(e => e.TripId == request.TripId).ToListAsync(cancellationToken);
38 + var expenseIds = tripExpenses.Select(e => e.Id).ToList();
39 + var splits = await _db.ExpenseSplits.Where(s => expenseIds.Contains(s.ExpenseId)).ToListAsync(cancellationToken);
40 +
41 + foreach (var expense in tripExpenses)
42 + {
43 + var fromCode = expense.CurrencyId.HasValue && allCurrencies.TryGetValue(expense.CurrencyId.Value, out var code)
44 + ? code : defaultCode;
45 + var paidConverted = CurrencyConverter.Convert(expense.Amount, fromCode, defaultCode);
46 + if (balances.ContainsKey(expense.PaidByUserId))
47 + {
48 + balances[expense.PaidByUserId] += paidConverted;
49 + }
50 +
51 + foreach (var split in splits.Where(s => s.ExpenseId == expense.Id))
52 + {
53 + if (!balances.ContainsKey(split.UserId)) continue;
54 + var splitConverted = CurrencyConverter.Convert(split.Amount, fromCode, defaultCode);
55 + balances[split.UserId] -= splitConverted;
56 + }
57 + }
58 +
59 + var creditors = balances
60 + .Where(b => b.Value > 0.01m)
61 + .Select(b => new { UserId = b.Key, Amount = b.Value })
62 + .OrderByDescending(c => c.Amount).ToList();
63 + var debtors = balances
64 + .Where(b => b.Value < -0.01m)
65 + .Select(b => new { UserId = b.Key, Amount = -b.Value })
66 + .OrderByDescending(d => d.Amount).ToList();
67 +
68 + if (creditors.Count == 0 || debtors.Count == 0) return null;
69 +
70 + var plan = new SettlementPlan
71 + {
72 + TripId = request.TripId,
73 + CreatedByUserId = request.CreatedByUserId,
74 + TotalAmount = creditors.Sum(c => c.Amount),
75 + Status = ESettlementStatus.Pending,
76 + };
77 + _db.SettlementPlans.Add(plan);
78 +
79 + var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount);
80 + var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount);
81 + var sortedCreditors = creditBalances.Keys.ToList();
82 + var sortedDebtors = debtBalances.Keys.ToList();
83 + var ci = 0;
84 + var di = 0;
85 +
86 + while (ci < sortedCreditors.Count && di < sortedDebtors.Count)
87 + {
88 + var creditorId = sortedCreditors[ci];
89 + var debtorId = sortedDebtors[di];
90 + var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]);
91 +
92 + if (amount > 0.01m)
93 + {
94 + _db.SettlementPayments.Add(new SettlementPayment
95 + {
96 + SettlementPlanId = plan.Id,
97 + FromUserId = debtorId,
98 + ToUserId = creditorId,
99 + Amount = Math.Round(amount, 2),
100 + Status = EPaymentStatus.Pending,
101 + });
102 + }
103 +
104 + creditBalances[creditorId] -= amount;
105 + debtBalances[debtorId] -= amount;
106 + if (creditBalances[creditorId] < 0.01m) ci++;
107 + if (debtBalances[debtorId] < 0.01m) di++;
108 + }
109 +
110 + await _db.SaveChangesAsync(cancellationToken);
111 + return plan.Id;
112 + }
113 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetBudgetCategorySpentHandler.cs +30 −0
@@ -0,0 +1,30 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Expenses.Queries;
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
7 +
8 +public class GetBudgetCategorySpentHandler
9 + : IRequestHandler<GetBudgetCategorySpentQuery, IReadOnlyDictionary<Guid, decimal>>
10 +{
11 + private readonly ExpensesDbContext _db;
12 +
13 + public GetBudgetCategorySpentHandler(ExpensesDbContext db)
14 + {
15 + _db = db;
16 + }
17 +
18 + public async Task<IReadOnlyDictionary<Guid, decimal>> Handle(
19 + GetBudgetCategorySpentQuery request,
20 + CancellationToken cancellationToken)
21 + {
22 + var rows = await _db.Expenses
23 + .Where(e => e.TripId == request.TripId && e.BudgetCategoryId != null)
24 + .GroupBy(e => e.BudgetCategoryId!.Value)
25 + .Select(g => new { CategoryId = g.Key, Total = g.Sum(e => e.Amount) })
26 + .ToListAsync(cancellationToken);
27 +
28 + return rows.ToDictionary(r => r.CategoryId, r => r.Total);
29 + }
30 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetCurrenciesByIdsHandler.cs +28 −0
@@ -0,0 +1,28 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Expenses;
5 +using SplitApp.Shared.Contracts.Expenses.Queries;
6 +
7 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
8 +
9 +public class GetCurrenciesByIdsHandler
10 + : IRequestHandler<GetCurrenciesByIdsQuery, IReadOnlyList<CurrencyDto>>
11 +{
12 + private readonly ExpensesDbContext _db;
13 +
14 + public GetCurrenciesByIdsHandler(ExpensesDbContext db) => _db = db;
15 +
16 + public async Task<IReadOnlyList<CurrencyDto>> Handle(
17 + GetCurrenciesByIdsQuery request,
18 + CancellationToken cancellationToken)
19 + {
20 + if (request.CurrencyIds.Count == 0) return Array.Empty<CurrencyDto>();
21 + var ids = request.CurrencyIds.Distinct().ToList();
22 + var rows = await _db.Currencies
23 + .Where(c => ids.Contains(c.Id))
24 + .Select(c => new CurrencyDto(c.Id, c.Code, c.Symbol))
25 + .ToListAsync(cancellationToken);
26 + return rows;
27 + }
28 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetTripExpenseTotalsHandler.cs +34 −0
@@ -0,0 +1,34 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Expenses;
5 +using SplitApp.Shared.Contracts.Expenses.Queries;
6 +
7 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
8 +
9 +public class GetTripExpenseTotalsHandler : IRequestHandler<GetTripExpenseTotalsQuery, TripExpenseTotalsDto>
10 +{
11 + private readonly ExpensesDbContext _db;
12 +
13 + public GetTripExpenseTotalsHandler(ExpensesDbContext db)
14 + {
15 + _db = db;
16 + }
17 +
18 + public async Task<TripExpenseTotalsDto> Handle(GetTripExpenseTotalsQuery request, CancellationToken cancellationToken)
19 + {
20 + var rows = await _db.Expenses
21 + .Where(e => e.TripId == request.TripId)
22 + .Join(_db.Currencies,
23 + e => e.CurrencyId,
24 + c => c.Id,
25 + (e, c) => new { c.Code, e.Amount })
26 + .ToListAsync(cancellationToken);
27 +
28 + var totals = rows
29 + .GroupBy(r => r.Code)
30 + .ToDictionary(g => g.Key, g => g.Sum(r => r.Amount));
31 +
32 + return new TripExpenseTotalsDto(request.TripId, totals, rows.Count);
33 + }
34 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/RemoveSettlementPlanHandler.cs +37 −0
@@ -0,0 +1,37 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
5 +using SplitApp.Shared.Contracts.Expenses.Commands;
6 +
7 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
8 +
9 +public class RemoveSettlementPlanHandler : IRequestHandler<RemoveSettlementPlanCommand, bool>
10 +{
11 + private readonly ExpensesDbContext _db;
12 +
13 + public RemoveSettlementPlanHandler(ExpensesDbContext db) => _db = db;
14 +
15 + public async Task<bool> Handle(RemoveSettlementPlanCommand request, CancellationToken cancellationToken)
16 + {
17 + var plan = await _db.SettlementPlans
18 + .Where(p => p.TripId == request.TripId)
19 + .OrderByDescending(p => p.CreatedAt)
20 + .FirstOrDefaultAsync(cancellationToken);
21 + if (plan == null) return true;
22 +
23 + var payments = await _db.SettlementPayments
24 + .Where(p => p.SettlementPlanId == plan.Id)
25 + .ToListAsync(cancellationToken);
26 +
27 + if (payments.Any(p => p.Status == EPaymentStatus.Confirmed))
28 + {
29 + return false;
30 + }
31 +
32 + _db.SettlementPayments.RemoveRange(payments);
33 + _db.SettlementPlans.Remove(plan);
34 + await _db.SaveChangesAsync(cancellationToken);
35 + return true;
36 + }
37 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/TripDeletedHandler.cs +55 −0
@@ -0,0 +1,55 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Trips.Events;
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers;
7 +
8 +public class TripDeletedHandler : INotificationHandler<TripDeletedEvent>
9 +{
10 + private readonly ExpensesDbContext _db;
11 +
12 + public TripDeletedHandler(ExpensesDbContext db)
13 + {
14 + _db = db;
15 + }
16 +
17 + public async Task Handle(TripDeletedEvent notification, CancellationToken cancellationToken)
18 + {
19 + var expenseIds = await _db.Expenses
20 + .Where(e => e.TripId == notification.TripId)
21 + .Select(e => e.Id)
22 + .ToListAsync(cancellationToken);
23 +
24 + if (expenseIds.Count > 0)
25 + {
26 + await _db.ExpenseSplits
27 + .Where(s => expenseIds.Contains(s.ExpenseId))
28 + .ExecuteDeleteAsync(cancellationToken);
29 + }
30 +
31 + await _db.Expenses
32 + .Where(e => e.TripId == notification.TripId)
33 + .ExecuteDeleteAsync(cancellationToken);
34 +
35 + var planIds = await _db.SettlementPlans
36 + .Where(p => p.TripId == notification.TripId)
37 + .Select(p => p.Id)
38 + .ToListAsync(cancellationToken);
39 +
40 + if (planIds.Count > 0)
41 + {
42 + await _db.SettlementPayments
43 + .Where(p => planIds.Contains(p.SettlementPlanId))
44 + .ExecuteDeleteAsync(cancellationToken);
45 + }
46 +
47 + await _db.SettlementPlans
48 + .Where(p => p.TripId == notification.TripId)
49 + .ExecuteDeleteAsync(cancellationToken);
50 +
51 + await _db.SplitPresets
52 + .Where(p => p.TripId == notification.TripId)
53 + .ExecuteDeleteAsync(cancellationToken);
54 + }
55 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesDbContext.cs +78 −0
@@ -0,0 +1,78 @@
1 +using System.Text.Json;
2 +using Microsoft.EntityFrameworkCore;
3 +using Microsoft.EntityFrameworkCore.ChangeTracking;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence;
9 +
10 +public class ExpensesDbContext : DbContext
11 +{
12 + public DbSet<Expense> Expenses { get; set; } = default!;
13 + public DbSet<ExpenseSplit> ExpenseSplits { get; set; } = default!;
14 + public DbSet<SettlementPlan> SettlementPlans { get; set; } = default!;
15 + public DbSet<SettlementPayment> SettlementPayments { get; set; } = default!;
16 + public DbSet<SplitPreset> SplitPresets { get; set; } = default!;
17 + public DbSet<SplitPresetMember> SplitPresetMembers { get; set; } = default!;
18 + public DbSet<Currency> Currencies { get; set; } = default!;
19 +
20 + public ExpensesDbContext(DbContextOptions<ExpensesDbContext> options) : base(options)
21 + {
22 + }
23 +
24 + public override int SaveChanges()
25 + {
26 + UpdateTimestamps();
27 + return base.SaveChanges();
28 + }
29 +
30 + public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
31 + {
32 + UpdateTimestamps();
33 + return base.SaveChangesAsync(cancellationToken);
34 + }
35 +
36 + private void UpdateTimestamps()
37 + {
38 + var entries = ChangeTracker.Entries<BaseEntity>();
39 + foreach (var entry in entries)
40 + {
41 + if (entry.State == EntityState.Modified)
42 + {
43 + entry.Entity.UpdatedAt = DateTime.UtcNow;
44 + }
45 + }
46 + }
47 +
48 + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
49 + {
50 + base.ConfigureConventions(configurationBuilder);
51 + configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeConverter>();
52 + }
53 +
54 + protected override void OnModelCreating(ModelBuilder builder)
55 + {
56 + base.OnModelCreating(builder);
57 +
58 + builder.HasDefaultSchema("expenses");
59 +
60 + foreach (var relationship in builder.Model
61 + .GetEntityTypes()
62 + .SelectMany(e => e.GetForeignKeys()))
63 + {
64 + relationship.DeleteBehavior = DeleteBehavior.Restrict;
65 + }
66 +
67 + builder.Entity<Currency>()
68 + .Property(c => c.Name)
69 + .HasConversion(
70 + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
71 + v => JsonSerializer.Deserialize<LangStr>(v, (JsonSerializerOptions?)null) ?? new LangStr())
72 + .HasMaxLength(1024)
73 + .Metadata.SetValueComparer(new ValueComparer<LangStr>(
74 + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null),
75 + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
76 + v => JsonSerializer.Deserialize<LangStr>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!));
77 + }
78 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesUnitOfWork.cs +33 −0
@@ -0,0 +1,33 @@
1 +using SplitApp.Modules.Expenses.Application.Contracts;
2 +using SplitApp.Modules.Expenses.Domain.Entities;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence.Repositories;
4 +using SplitApp.Shared.Kernel.Persistence;
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence;
7 +
8 +public class ExpensesUnitOfWork : IExpensesUnitOfWork
9 +{
10 + private readonly ExpensesDbContext _db;
11 +
12 + public ExpensesUnitOfWork(ExpensesDbContext db)
13 + {
14 + _db = db;
15 + Expenses = new ExpensesBaseRepository<Expense>(db);
16 + ExpenseSplits = new ExpensesBaseRepository<ExpenseSplit>(db);
17 + SettlementPlans = new ExpensesBaseRepository<SettlementPlan>(db);
18 + SettlementPayments = new ExpensesBaseRepository<SettlementPayment>(db);
19 + SplitPresets = new ExpensesBaseRepository<SplitPreset>(db);
20 + SplitPresetMembers = new ExpensesBaseRepository<SplitPresetMember>(db);
21 + Currencies = new ExpensesBaseRepository<Currency>(db);
22 + }
23 +
24 + public IBaseRepository<Expense> Expenses { get; }
25 + public IBaseRepository<ExpenseSplit> ExpenseSplits { get; }
26 + public IBaseRepository<SettlementPlan> SettlementPlans { get; }
27 + public IBaseRepository<SettlementPayment> SettlementPayments { get; }
28 + public IBaseRepository<SplitPreset> SplitPresets { get; }
29 + public IBaseRepository<SplitPresetMember> SplitPresetMembers { get; }
30 + public IBaseRepository<Currency> Currencies { get; }
31 +
32 + public Task<int> SaveChangesAsync() => _db.SaveChangesAsync();
33 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.Designer.cs +330 −0
@@ -0,0 +1,330 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Migrations;
6 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
7 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
8 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
9 +
10 +#nullable disable
11 +
12 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence.Migrations
13 +{
14 + [DbContext(typeof(ExpensesDbContext))]
15 + [Migration("20260430135020_Init")]
16 + partial class Init
17 + {
18 + /// <inheritdoc />
19 + protected override void BuildTargetModel(ModelBuilder modelBuilder)
20 + {
21 +#pragma warning disable 612, 618
22 + modelBuilder
23 + .HasDefaultSchema("expenses")
24 + .HasAnnotation("ProductVersion", "10.0.5")
25 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
26 +
27 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
28 +
29 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Currency", b =>
30 + {
31 + b.Property<Guid>("Id")
32 + .ValueGeneratedOnAdd()
33 + .HasColumnType("uuid");
34 +
35 + b.Property<string>("Code")
36 + .IsRequired()
37 + .HasMaxLength(3)
38 + .HasColumnType("character varying(3)");
39 +
40 + b.Property<DateTime>("CreatedAt")
41 + .HasColumnType("timestamp with time zone");
42 +
43 + b.Property<string>("Name")
44 + .IsRequired()
45 + .HasMaxLength(1024)
46 + .HasColumnType("character varying(1024)");
47 +
48 + b.Property<string>("Symbol")
49 + .IsRequired()
50 + .HasMaxLength(10)
51 + .HasColumnType("character varying(10)");
52 +
53 + b.Property<DateTime>("UpdatedAt")
54 + .HasColumnType("timestamp with time zone");
55 +
56 + b.HasKey("Id");
57 +
58 + b.ToTable("Currencies", "expenses");
59 + });
60 +
61 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
62 + {
63 + b.Property<Guid>("Id")
64 + .ValueGeneratedOnAdd()
65 + .HasColumnType("uuid");
66 +
67 + b.Property<decimal>("Amount")
68 + .HasColumnType("numeric");
69 +
70 + b.Property<Guid?>("BudgetCategoryId")
71 + .HasColumnType("uuid");
72 +
73 + b.Property<DateTime>("CreatedAt")
74 + .HasColumnType("timestamp with time zone");
75 +
76 + b.Property<Guid?>("CurrencyId")
77 + .HasColumnType("uuid");
78 +
79 + b.Property<string>("Description")
80 + .HasMaxLength(500)
81 + .HasColumnType("character varying(500)");
82 +
83 + b.Property<DateTime>("ExpenseDate")
84 + .HasColumnType("timestamp with time zone");
85 +
86 + b.Property<Guid>("PaidByUserId")
87 + .HasColumnType("uuid");
88 +
89 + b.Property<int>("SplitMethod")
90 + .HasColumnType("integer");
91 +
92 + b.Property<Guid>("TripId")
93 + .HasColumnType("uuid");
94 +
95 + b.Property<DateTime>("UpdatedAt")
96 + .HasColumnType("timestamp with time zone");
97 +
98 + b.HasKey("Id");
99 +
100 + b.HasIndex("CurrencyId");
101 +
102 + b.ToTable("Expenses", "expenses");
103 + });
104 +
105 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.ExpenseSplit", b =>
106 + {
107 + b.Property<Guid>("Id")
108 + .ValueGeneratedOnAdd()
109 + .HasColumnType("uuid");
110 +
111 + b.Property<decimal>("Amount")
112 + .HasColumnType("numeric");
113 +
114 + b.Property<DateTime>("CreatedAt")
115 + .HasColumnType("timestamp with time zone");
116 +
117 + b.Property<Guid>("ExpenseId")
118 + .HasColumnType("uuid");
119 +
120 + b.Property<decimal?>("Percentage")
121 + .HasColumnType("numeric");
122 +
123 + b.Property<DateTime>("UpdatedAt")
124 + .HasColumnType("timestamp with time zone");
125 +
126 + b.Property<Guid>("UserId")
127 + .HasColumnType("uuid");
128 +
129 + b.HasKey("Id");
130 +
131 + b.HasIndex("ExpenseId");
132 +
133 + b.ToTable("ExpenseSplits", "expenses");
134 + });
135 +
136 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPayment", b =>
137 + {
138 + b.Property<Guid>("Id")
139 + .ValueGeneratedOnAdd()
140 + .HasColumnType("uuid");
141 +
142 + b.Property<decimal>("Amount")
143 + .HasColumnType("numeric");
144 +
145 + b.Property<DateTime?>("ConfirmedAt")
146 + .HasColumnType("timestamp with time zone");
147 +
148 + b.Property<DateTime>("CreatedAt")
149 + .HasColumnType("timestamp with time zone");
150 +
151 + b.Property<Guid>("FromUserId")
152 + .HasColumnType("uuid");
153 +
154 + b.Property<DateTime?>("MarkedPaidAt")
155 + .HasColumnType("timestamp with time zone");
156 +
157 + b.Property<Guid>("SettlementPlanId")
158 + .HasColumnType("uuid");
159 +
160 + b.Property<int>("Status")
161 + .HasColumnType("integer");
162 +
163 + b.Property<Guid>("ToUserId")
164 + .HasColumnType("uuid");
165 +
166 + b.Property<DateTime>("UpdatedAt")
167 + .HasColumnType("timestamp with time zone");
168 +
169 + b.HasKey("Id");
170 +
171 + b.HasIndex("SettlementPlanId");
172 +
173 + b.ToTable("SettlementPayments", "expenses");
174 + });
175 +
176 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", b =>
177 + {
178 + b.Property<Guid>("Id")
179 + .ValueGeneratedOnAdd()
180 + .HasColumnType("uuid");
181 +
182 + b.Property<DateTime?>("CompletedAt")
183 + .HasColumnType("timestamp with time zone");
184 +
185 + b.Property<DateTime>("CreatedAt")
186 + .HasColumnType("timestamp with time zone");
187 +
188 + b.Property<Guid>("CreatedByUserId")
189 + .HasColumnType("uuid");
190 +
191 + b.Property<int>("Status")
192 + .HasColumnType("integer");
193 +
194 + b.Property<decimal>("TotalAmount")
195 + .HasColumnType("numeric");
196 +
197 + b.Property<Guid>("TripId")
198 + .HasColumnType("uuid");
199 +
200 + b.Property<DateTime>("UpdatedAt")
201 + .HasColumnType("timestamp with time zone");
202 +
203 + b.HasKey("Id");
204 +
205 + b.ToTable("SettlementPlans", "expenses");
206 + });
207 +
208 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", b =>
209 + {
210 + b.Property<Guid>("Id")
211 + .ValueGeneratedOnAdd()
212 + .HasColumnType("uuid");
213 +
214 + b.Property<DateTime>("CreatedAt")
215 + .HasColumnType("timestamp with time zone");
216 +
217 + b.Property<Guid>("CreatedById")
218 + .HasColumnType("uuid");
219 +
220 + b.Property<string>("Name")
221 + .IsRequired()
222 + .HasMaxLength(200)
223 + .HasColumnType("character varying(200)");
224 +
225 + b.Property<int>("SplitMethod")
226 + .HasColumnType("integer");
227 +
228 + b.Property<Guid>("TripId")
229 + .HasColumnType("uuid");
230 +
231 + b.Property<DateTime>("UpdatedAt")
232 + .HasColumnType("timestamp with time zone");
233 +
234 + b.HasKey("Id");
235 +
236 + b.ToTable("SplitPresets", "expenses");
237 + });
238 +
239 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPresetMember", b =>
240 + {
241 + b.Property<Guid>("Id")
242 + .ValueGeneratedOnAdd()
243 + .HasColumnType("uuid");
244 +
245 + b.Property<DateTime>("CreatedAt")
246 + .HasColumnType("timestamp with time zone");
247 +
248 + b.Property<decimal?>("Percentage")
249 + .HasColumnType("numeric");
250 +
251 + b.Property<decimal?>("ShareWeight")
252 + .HasColumnType("numeric");
253 +
254 + b.Property<Guid>("SplitPresetId")
255 + .HasColumnType("uuid");
256 +
257 + b.Property<DateTime>("UpdatedAt")
258 + .HasColumnType("timestamp with time zone");
259 +
260 + b.Property<Guid>("UserId")
261 + .HasColumnType("uuid");
262 +
263 + b.HasKey("Id");
264 +
265 + b.HasIndex("SplitPresetId");
266 +
267 + b.ToTable("SplitPresetMembers", "expenses");
268 + });
269 +
270 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
271 + {
272 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.Currency", "Currency")
273 + .WithMany()
274 + .HasForeignKey("CurrencyId")
275 + .OnDelete(DeleteBehavior.Restrict);
276 +
277 + b.Navigation("Currency");
278 + });
279 +
280 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.ExpenseSplit", b =>
281 + {
282 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.Expense", "Expense")
283 + .WithMany("Splits")
284 + .HasForeignKey("ExpenseId")
285 + .OnDelete(DeleteBehavior.Restrict)
286 + .IsRequired();
287 +
288 + b.Navigation("Expense");
289 + });
290 +
291 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPayment", b =>
292 + {
293 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", "SettlementPlan")
294 + .WithMany("Payments")
295 + .HasForeignKey("SettlementPlanId")
296 + .OnDelete(DeleteBehavior.Restrict)
297 + .IsRequired();
298 +
299 + b.Navigation("SettlementPlan");
300 + });
301 +
302 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPresetMember", b =>
303 + {
304 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", "SplitPreset")
305 + .WithMany("Members")
306 + .HasForeignKey("SplitPresetId")
307 + .OnDelete(DeleteBehavior.Restrict)
308 + .IsRequired();
309 +
310 + b.Navigation("SplitPreset");
311 + });
312 +
313 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
314 + {
315 + b.Navigation("Splits");
316 + });
317 +
318 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", b =>
319 + {
320 + b.Navigation("Payments");
321 + });
322 +
323 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", b =>
324 + {
325 + b.Navigation("Members");
326 + });
327 +#pragma warning restore 612, 618
328 + }
329 + }
330 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.cs +235 −0
@@ -0,0 +1,235 @@
1 +using System;
2 +using Microsoft.EntityFrameworkCore.Migrations;
3 +
4 +#nullable disable
5 +
6 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence.Migrations
7 +{
8 + /// <inheritdoc />
9 + public partial class Init : Migration
10 + {
11 + /// <inheritdoc />
12 + protected override void Up(MigrationBuilder migrationBuilder)
13 + {
14 + migrationBuilder.EnsureSchema(
15 + name: "expenses");
16 +
17 + migrationBuilder.CreateTable(
18 + name: "Currencies",
19 + schema: "expenses",
20 + columns: table => new
21 + {
22 + Id = table.Column<Guid>(type: "uuid", nullable: false),
23 + Code = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false),
24 + Name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
25 + Symbol = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
26 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
27 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
28 + },
29 + constraints: table =>
30 + {
31 + table.PrimaryKey("PK_Currencies", x => x.Id);
32 + });
33 +
34 + migrationBuilder.CreateTable(
35 + name: "SettlementPlans",
36 + schema: "expenses",
37 + columns: table => new
38 + {
39 + Id = table.Column<Guid>(type: "uuid", nullable: false),
40 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
41 + CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
42 + TotalAmount = table.Column<decimal>(type: "numeric", nullable: false),
43 + Status = table.Column<int>(type: "integer", nullable: false),
44 + CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
45 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
46 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
47 + },
48 + constraints: table =>
49 + {
50 + table.PrimaryKey("PK_SettlementPlans", x => x.Id);
51 + });
52 +
53 + migrationBuilder.CreateTable(
54 + name: "SplitPresets",
55 + schema: "expenses",
56 + columns: table => new
57 + {
58 + Id = table.Column<Guid>(type: "uuid", nullable: false),
59 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
60 + Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
61 + SplitMethod = table.Column<int>(type: "integer", nullable: false),
62 + CreatedById = table.Column<Guid>(type: "uuid", nullable: false),
63 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
64 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
65 + },
66 + constraints: table =>
67 + {
68 + table.PrimaryKey("PK_SplitPresets", x => x.Id);
69 + });
70 +
71 + migrationBuilder.CreateTable(
72 + name: "Expenses",
73 + schema: "expenses",
74 + columns: table => new
75 + {
76 + Id = table.Column<Guid>(type: "uuid", nullable: false),
77 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
78 + PaidByUserId = table.Column<Guid>(type: "uuid", nullable: false),
79 + BudgetCategoryId = table.Column<Guid>(type: "uuid", nullable: true),
80 + CurrencyId = table.Column<Guid>(type: "uuid", nullable: true),
81 + Amount = table.Column<decimal>(type: "numeric", nullable: false),
82 + Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
83 + ExpenseDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
84 + SplitMethod = table.Column<int>(type: "integer", nullable: false),
85 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
86 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
87 + },
88 + constraints: table =>
89 + {
90 + table.PrimaryKey("PK_Expenses", x => x.Id);
91 + table.ForeignKey(
92 + name: "FK_Expenses_Currencies_CurrencyId",
93 + column: x => x.CurrencyId,
94 + principalSchema: "expenses",
95 + principalTable: "Currencies",
96 + principalColumn: "Id",
97 + onDelete: ReferentialAction.Restrict);
98 + });
99 +
100 + migrationBuilder.CreateTable(
101 + name: "SettlementPayments",
102 + schema: "expenses",
103 + columns: table => new
104 + {
105 + Id = table.Column<Guid>(type: "uuid", nullable: false),
106 + SettlementPlanId = table.Column<Guid>(type: "uuid", nullable: false),
107 + FromUserId = table.Column<Guid>(type: "uuid", nullable: false),
108 + ToUserId = table.Column<Guid>(type: "uuid", nullable: false),
109 + Amount = table.Column<decimal>(type: "numeric", nullable: false),
110 + Status = table.Column<int>(type: "integer", nullable: false),
111 + MarkedPaidAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
112 + ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
113 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
114 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
115 + },
116 + constraints: table =>
117 + {
118 + table.PrimaryKey("PK_SettlementPayments", x => x.Id);
119 + table.ForeignKey(
120 + name: "FK_SettlementPayments_SettlementPlans_SettlementPlanId",
121 + column: x => x.SettlementPlanId,
122 + principalSchema: "expenses",
123 + principalTable: "SettlementPlans",
124 + principalColumn: "Id",
125 + onDelete: ReferentialAction.Restrict);
126 + });
127 +
128 + migrationBuilder.CreateTable(
129 + name: "SplitPresetMembers",
130 + schema: "expenses",
131 + columns: table => new
132 + {
133 + Id = table.Column<Guid>(type: "uuid", nullable: false),
134 + SplitPresetId = table.Column<Guid>(type: "uuid", nullable: false),
135 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
136 + ShareWeight = table.Column<decimal>(type: "numeric", nullable: true),
137 + Percentage = table.Column<decimal>(type: "numeric", nullable: true),
138 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
139 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
140 + },
141 + constraints: table =>
142 + {
143 + table.PrimaryKey("PK_SplitPresetMembers", x => x.Id);
144 + table.ForeignKey(
145 + name: "FK_SplitPresetMembers_SplitPresets_SplitPresetId",
146 + column: x => x.SplitPresetId,
147 + principalSchema: "expenses",
148 + principalTable: "SplitPresets",
149 + principalColumn: "Id",
150 + onDelete: ReferentialAction.Restrict);
151 + });
152 +
153 + migrationBuilder.CreateTable(
154 + name: "ExpenseSplits",
155 + schema: "expenses",
156 + columns: table => new
157 + {
158 + Id = table.Column<Guid>(type: "uuid", nullable: false),
159 + ExpenseId = table.Column<Guid>(type: "uuid", nullable: false),
160 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
161 + Amount = table.Column<decimal>(type: "numeric", nullable: false),
162 + Percentage = table.Column<decimal>(type: "numeric", nullable: true),
163 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
164 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
165 + },
166 + constraints: table =>
167 + {
168 + table.PrimaryKey("PK_ExpenseSplits", x => x.Id);
169 + table.ForeignKey(
170 + name: "FK_ExpenseSplits_Expenses_ExpenseId",
171 + column: x => x.ExpenseId,
172 + principalSchema: "expenses",
173 + principalTable: "Expenses",
174 + principalColumn: "Id",
175 + onDelete: ReferentialAction.Restrict);
176 + });
177 +
178 + migrationBuilder.CreateIndex(
179 + name: "IX_Expenses_CurrencyId",
180 + schema: "expenses",
181 + table: "Expenses",
182 + column: "CurrencyId");
183 +
184 + migrationBuilder.CreateIndex(
185 + name: "IX_ExpenseSplits_ExpenseId",
186 + schema: "expenses",
187 + table: "ExpenseSplits",
188 + column: "ExpenseId");
189 +
190 + migrationBuilder.CreateIndex(
191 + name: "IX_SettlementPayments_SettlementPlanId",
192 + schema: "expenses",
193 + table: "SettlementPayments",
194 + column: "SettlementPlanId");
195 +
196 + migrationBuilder.CreateIndex(
197 + name: "IX_SplitPresetMembers_SplitPresetId",
198 + schema: "expenses",
199 + table: "SplitPresetMembers",
200 + column: "SplitPresetId");
201 + }
202 +
203 + /// <inheritdoc />
204 + protected override void Down(MigrationBuilder migrationBuilder)
205 + {
206 + migrationBuilder.DropTable(
207 + name: "ExpenseSplits",
208 + schema: "expenses");
209 +
210 + migrationBuilder.DropTable(
211 + name: "SettlementPayments",
212 + schema: "expenses");
213 +
214 + migrationBuilder.DropTable(
215 + name: "SplitPresetMembers",
216 + schema: "expenses");
217 +
218 + migrationBuilder.DropTable(
219 + name: "Expenses",
220 + schema: "expenses");
221 +
222 + migrationBuilder.DropTable(
223 + name: "SettlementPlans",
224 + schema: "expenses");
225 +
226 + migrationBuilder.DropTable(
227 + name: "SplitPresets",
228 + schema: "expenses");
229 +
230 + migrationBuilder.DropTable(
231 + name: "Currencies",
232 + schema: "expenses");
233 + }
234 + }
235 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/ExpensesDbContextModelSnapshot.cs +327 −0
@@ -0,0 +1,327 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
6 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
7 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
8 +
9 +#nullable disable
10 +
11 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence.Migrations
12 +{
13 + [DbContext(typeof(ExpensesDbContext))]
14 + partial class ExpensesDbContextModelSnapshot : ModelSnapshot
15 + {
16 + protected override void BuildModel(ModelBuilder modelBuilder)
17 + {
18 +#pragma warning disable 612, 618
19 + modelBuilder
20 + .HasDefaultSchema("expenses")
21 + .HasAnnotation("ProductVersion", "10.0.5")
22 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
23 +
24 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
25 +
26 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Currency", b =>
27 + {
28 + b.Property<Guid>("Id")
29 + .ValueGeneratedOnAdd()
30 + .HasColumnType("uuid");
31 +
32 + b.Property<string>("Code")
33 + .IsRequired()
34 + .HasMaxLength(3)
35 + .HasColumnType("character varying(3)");
36 +
37 + b.Property<DateTime>("CreatedAt")
38 + .HasColumnType("timestamp with time zone");
39 +
40 + b.Property<string>("Name")
41 + .IsRequired()
42 + .HasMaxLength(1024)
43 + .HasColumnType("character varying(1024)");
44 +
45 + b.Property<string>("Symbol")
46 + .IsRequired()
47 + .HasMaxLength(10)
48 + .HasColumnType("character varying(10)");
49 +
50 + b.Property<DateTime>("UpdatedAt")
51 + .HasColumnType("timestamp with time zone");
52 +
53 + b.HasKey("Id");
54 +
55 + b.ToTable("Currencies", "expenses");
56 + });
57 +
58 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
59 + {
60 + b.Property<Guid>("Id")
61 + .ValueGeneratedOnAdd()
62 + .HasColumnType("uuid");
63 +
64 + b.Property<decimal>("Amount")
65 + .HasColumnType("numeric");
66 +
67 + b.Property<Guid?>("BudgetCategoryId")
68 + .HasColumnType("uuid");
69 +
70 + b.Property<DateTime>("CreatedAt")
71 + .HasColumnType("timestamp with time zone");
72 +
73 + b.Property<Guid?>("CurrencyId")
74 + .HasColumnType("uuid");
75 +
76 + b.Property<string>("Description")
77 + .HasMaxLength(500)
78 + .HasColumnType("character varying(500)");
79 +
80 + b.Property<DateTime>("ExpenseDate")
81 + .HasColumnType("timestamp with time zone");
82 +
83 + b.Property<Guid>("PaidByUserId")
84 + .HasColumnType("uuid");
85 +
86 + b.Property<int>("SplitMethod")
87 + .HasColumnType("integer");
88 +
89 + b.Property<Guid>("TripId")
90 + .HasColumnType("uuid");
91 +
92 + b.Property<DateTime>("UpdatedAt")
93 + .HasColumnType("timestamp with time zone");
94 +
95 + b.HasKey("Id");
96 +
97 + b.HasIndex("CurrencyId");
98 +
99 + b.ToTable("Expenses", "expenses");
100 + });
101 +
102 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.ExpenseSplit", b =>
103 + {
104 + b.Property<Guid>("Id")
105 + .ValueGeneratedOnAdd()
106 + .HasColumnType("uuid");
107 +
108 + b.Property<decimal>("Amount")
109 + .HasColumnType("numeric");
110 +
111 + b.Property<DateTime>("CreatedAt")
112 + .HasColumnType("timestamp with time zone");
113 +
114 + b.Property<Guid>("ExpenseId")
115 + .HasColumnType("uuid");
116 +
117 + b.Property<decimal?>("Percentage")
118 + .HasColumnType("numeric");
119 +
120 + b.Property<DateTime>("UpdatedAt")
121 + .HasColumnType("timestamp with time zone");
122 +
123 + b.Property<Guid>("UserId")
124 + .HasColumnType("uuid");
125 +
126 + b.HasKey("Id");
127 +
128 + b.HasIndex("ExpenseId");
129 +
130 + b.ToTable("ExpenseSplits", "expenses");
131 + });
132 +
133 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPayment", b =>
134 + {
135 + b.Property<Guid>("Id")
136 + .ValueGeneratedOnAdd()
137 + .HasColumnType("uuid");
138 +
139 + b.Property<decimal>("Amount")
140 + .HasColumnType("numeric");
141 +
142 + b.Property<DateTime?>("ConfirmedAt")
143 + .HasColumnType("timestamp with time zone");
144 +
145 + b.Property<DateTime>("CreatedAt")
146 + .HasColumnType("timestamp with time zone");
147 +
148 + b.Property<Guid>("FromUserId")
149 + .HasColumnType("uuid");
150 +
151 + b.Property<DateTime?>("MarkedPaidAt")
152 + .HasColumnType("timestamp with time zone");
153 +
154 + b.Property<Guid>("SettlementPlanId")
155 + .HasColumnType("uuid");
156 +
157 + b.Property<int>("Status")
158 + .HasColumnType("integer");
159 +
160 + b.Property<Guid>("ToUserId")
161 + .HasColumnType("uuid");
162 +
163 + b.Property<DateTime>("UpdatedAt")
164 + .HasColumnType("timestamp with time zone");
165 +
166 + b.HasKey("Id");
167 +
168 + b.HasIndex("SettlementPlanId");
169 +
170 + b.ToTable("SettlementPayments", "expenses");
171 + });
172 +
173 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", b =>
174 + {
175 + b.Property<Guid>("Id")
176 + .ValueGeneratedOnAdd()
177 + .HasColumnType("uuid");
178 +
179 + b.Property<DateTime?>("CompletedAt")
180 + .HasColumnType("timestamp with time zone");
181 +
182 + b.Property<DateTime>("CreatedAt")
183 + .HasColumnType("timestamp with time zone");
184 +
185 + b.Property<Guid>("CreatedByUserId")
186 + .HasColumnType("uuid");
187 +
188 + b.Property<int>("Status")
189 + .HasColumnType("integer");
190 +
191 + b.Property<decimal>("TotalAmount")
192 + .HasColumnType("numeric");
193 +
194 + b.Property<Guid>("TripId")
195 + .HasColumnType("uuid");
196 +
197 + b.Property<DateTime>("UpdatedAt")
198 + .HasColumnType("timestamp with time zone");
199 +
200 + b.HasKey("Id");
201 +
202 + b.ToTable("SettlementPlans", "expenses");
203 + });
204 +
205 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", b =>
206 + {
207 + b.Property<Guid>("Id")
208 + .ValueGeneratedOnAdd()
209 + .HasColumnType("uuid");
210 +
211 + b.Property<DateTime>("CreatedAt")
212 + .HasColumnType("timestamp with time zone");
213 +
214 + b.Property<Guid>("CreatedById")
215 + .HasColumnType("uuid");
216 +
217 + b.Property<string>("Name")
218 + .IsRequired()
219 + .HasMaxLength(200)
220 + .HasColumnType("character varying(200)");
221 +
222 + b.Property<int>("SplitMethod")
223 + .HasColumnType("integer");
224 +
225 + b.Property<Guid>("TripId")
226 + .HasColumnType("uuid");
227 +
228 + b.Property<DateTime>("UpdatedAt")
229 + .HasColumnType("timestamp with time zone");
230 +
231 + b.HasKey("Id");
232 +
233 + b.ToTable("SplitPresets", "expenses");
234 + });
235 +
236 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPresetMember", b =>
237 + {
238 + b.Property<Guid>("Id")
239 + .ValueGeneratedOnAdd()
240 + .HasColumnType("uuid");
241 +
242 + b.Property<DateTime>("CreatedAt")
243 + .HasColumnType("timestamp with time zone");
244 +
245 + b.Property<decimal?>("Percentage")
246 + .HasColumnType("numeric");
247 +
248 + b.Property<decimal?>("ShareWeight")
249 + .HasColumnType("numeric");
250 +
251 + b.Property<Guid>("SplitPresetId")
252 + .HasColumnType("uuid");
253 +
254 + b.Property<DateTime>("UpdatedAt")
255 + .HasColumnType("timestamp with time zone");
256 +
257 + b.Property<Guid>("UserId")
258 + .HasColumnType("uuid");
259 +
260 + b.HasKey("Id");
261 +
262 + b.HasIndex("SplitPresetId");
263 +
264 + b.ToTable("SplitPresetMembers", "expenses");
265 + });
266 +
267 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
268 + {
269 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.Currency", "Currency")
270 + .WithMany()
271 + .HasForeignKey("CurrencyId")
272 + .OnDelete(DeleteBehavior.Restrict);
273 +
274 + b.Navigation("Currency");
275 + });
276 +
277 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.ExpenseSplit", b =>
278 + {
279 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.Expense", "Expense")
280 + .WithMany("Splits")
281 + .HasForeignKey("ExpenseId")
282 + .OnDelete(DeleteBehavior.Restrict)
283 + .IsRequired();
284 +
285 + b.Navigation("Expense");
286 + });
287 +
288 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPayment", b =>
289 + {
290 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", "SettlementPlan")
291 + .WithMany("Payments")
292 + .HasForeignKey("SettlementPlanId")
293 + .OnDelete(DeleteBehavior.Restrict)
294 + .IsRequired();
295 +
296 + b.Navigation("SettlementPlan");
297 + });
298 +
299 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPresetMember", b =>
300 + {
301 + b.HasOne("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", "SplitPreset")
302 + .WithMany("Members")
303 + .HasForeignKey("SplitPresetId")
304 + .OnDelete(DeleteBehavior.Restrict)
305 + .IsRequired();
306 +
307 + b.Navigation("SplitPreset");
308 + });
309 +
310 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.Expense", b =>
311 + {
312 + b.Navigation("Splits");
313 + });
314 +
315 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SettlementPlan", b =>
316 + {
317 + b.Navigation("Payments");
318 + });
319 +
320 + modelBuilder.Entity("SplitApp.Modules.Expenses.Domain.Entities.SplitPreset", b =>
321 + {
322 + b.Navigation("Members");
323 + });
324 +#pragma warning restore 612, 618
325 + }
326 + }
327 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Repositories/ExpensesBaseRepository.cs +29 −0
@@ -0,0 +1,29 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Persistence;
4 +
5 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence.Repositories;
6 +
7 +public class ExpensesBaseRepository<TEntity> : IBaseRepository<TEntity> where TEntity : class, IBaseEntity
8 +{
9 + protected readonly ExpensesDbContext DbContext;
10 + protected readonly DbSet<TEntity> DbSet;
11 +
12 + public ExpensesBaseRepository(ExpensesDbContext dbContext)
13 + {
14 + DbContext = dbContext;
15 + DbSet = dbContext.Set<TEntity>();
16 + }
17 +
18 + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await DbSet.ToListAsync();
19 + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await DbSet.FirstOrDefaultAsync(e => e.Id == id);
20 + public virtual TEntity Add(TEntity entity) => DbSet.Add(entity).Entity;
21 + public virtual TEntity Update(TEntity entity) => DbSet.Update(entity).Entity;
22 + public virtual async Task<TEntity?> RemoveAsync(Guid id)
23 + {
24 + var entity = await GetByIdAsync(id);
25 + if (entity == null) return null;
26 + return DbSet.Remove(entity).Entity;
27 + }
28 + public virtual async Task<bool> ExistsAsync(Guid id) => await DbSet.AnyAsync(e => e.Id == id);
29 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
@@ -0,0 +1,14 @@
1 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
2 +
3 +namespace SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +
5 +public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
6 +{
7 + public UtcDateTimeConverter() : base(
8 + v => v.Kind == DateTimeKind.Unspecified
9 + ? DateTime.SpecifyKind(v, DateTimeKind.Utc)
10 + : v.ToUniversalTime(),
11 + v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
12 + {
13 + }
14 +}
added SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj +30 −0
@@ -0,0 +1,30 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Expenses.Domain\SplitApp.Modules.Expenses.Domain.csproj" />
5 + <ProjectReference Include="..\SplitApp.Modules.Expenses.Application\SplitApp.Modules.Expenses.Application.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <FrameworkReference Include="Microsoft.AspNetCore.App" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="MediatR" Version="12.4.1" />
15 + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
16 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
17 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
18 + <PrivateAssets>all</PrivateAssets>
19 + </PackageReference>
20 + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
21 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
22 + </ItemGroup>
23 +
24 + <PropertyGroup>
25 + <TargetFramework>net10.0</TargetFramework>
26 + <ImplicitUsings>enable</ImplicitUsings>
27 + <Nullable>enable</Nullable>
28 + </PropertyGroup>
29 +
30 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor +3 −0
@@ -0,0 +1,3 @@
1 +<div class="my-component">
2 + This component is defined in the <strong>SplitApp.Modules.Trips.Api</strong> library.
3 +</div>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor.css +6 −0
@@ -0,0 +1,6 @@
1 +.my-component {
2 + border: 2px dashed red;
3 + padding: 1em;
4 + margin: 1em 0;
5 + background-image: url('background.png');
6 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/BudgetCategoriesController.cs +146 −0
@@ -0,0 +1,146 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Trips.Api.Dto.v1;
8 +using SplitApp.Modules.Trips.Application.Contracts;
9 +using SplitApp.Modules.Trips.Domain.Entities;
10 +using SplitApp.Modules.Trips.Domain.Enums;
11 +using SplitApp.Shared.Contracts.Expenses.Queries;
12 +using SplitApp.Shared.Kernel.Localization;
13 +
14 +namespace SplitApp.Modules.Trips.Api.Controllers;
15 +
16 +[ApiVersion("1.0")]
17 +[ApiController]
18 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
19 +[Route("api/v{version:apiVersion}/[controller]")]
20 +public class BudgetCategoriesController : ControllerBase
21 +{
22 + private readonly ITripsUnitOfWork _uow;
23 + private readonly IMediator _mediator;
24 +
25 + public BudgetCategoriesController(ITripsUnitOfWork uow, IMediator mediator)
26 + {
27 + _uow = uow;
28 + _mediator = mediator;
29 + }
30 +
31 + [HttpGet("trip/{tripId:guid}")]
32 + public async Task<ActionResult<List<BudgetCategoryDto>>> GetForTrip(Guid tripId)
33 + {
34 + var userId = CurrentUserId();
35 + if (userId == null) return Unauthorized();
36 +
37 + if (!await IsParticipantAsync(tripId, userId.Value)) return Forbid();
38 +
39 + var all = await _uow.BudgetCategories.GetAllAsync();
40 + var categories = all.Where(c => c.TripId == tripId).ToList();
41 +
42 + var spent = await _mediator.Send(new GetBudgetCategorySpentQuery(tripId));
43 +
44 + return Ok(categories.Select(c => MapToDto(c, spent)).ToList());
45 + }
46 +
47 + [HttpPost]
48 + public async Task<ActionResult<BudgetCategoryDto>> Create([FromBody] BudgetCategoryCreateDto dto)
49 + {
50 + var userId = CurrentUserId();
51 + if (userId == null) return Unauthorized();
52 +
53 + if (!await IsOrganizerAsync(dto.TripId, userId.Value)) return Forbid();
54 +
55 + var entity = new BudgetCategory
56 + {
57 + TripId = dto.TripId,
58 + Name = new LangStr(dto.Name),
59 + IconName = dto.IconName,
60 + PlannedAmount = dto.PlannedAmount,
61 + DisplayOrder = dto.DisplayOrder,
62 + };
63 + _uow.BudgetCategories.Add(entity);
64 + await _uow.SaveChangesAsync();
65 +
66 + return CreatedAtAction(null, new { id = entity.Id }, MapToDto(entity, null));
67 + }
68 +
69 + [HttpPut("{id:guid}")]
70 + public async Task<IActionResult> Update(Guid id, [FromBody] BudgetCategoryCreateDto dto)
71 + {
72 + var userId = CurrentUserId();
73 + if (userId == null) return Unauthorized();
74 +
75 + var existing = await _uow.BudgetCategories.GetByIdAsync(id);
76 + if (existing == null) return NotFound();
77 +
78 + if (!await IsOrganizerAsync(existing.TripId, userId.Value)) return Forbid();
79 +
80 + existing.Name = new LangStr(dto.Name);
81 + existing.IconName = dto.IconName;
82 + existing.PlannedAmount = dto.PlannedAmount;
83 + existing.DisplayOrder = dto.DisplayOrder;
84 +
85 + _uow.BudgetCategories.Update(existing);
86 + await _uow.SaveChangesAsync();
87 + return NoContent();
88 + }
89 +
90 + [HttpDelete("{id:guid}")]
91 + public async Task<IActionResult> Delete(Guid id)
92 + {
93 + var userId = CurrentUserId();
94 + if (userId == null) return Unauthorized();
95 +
96 + var existing = await _uow.BudgetCategories.GetByIdAsync(id);
97 + if (existing == null) return NotFound();
98 +
99 + if (!await IsOrganizerAsync(existing.TripId, userId.Value)) return Forbid();
100 +
101 + await _uow.BudgetCategories.RemoveAsync(id);
102 + await _uow.SaveChangesAsync();
103 + return NoContent();
104 + }
105 +
106 + private Guid? CurrentUserId()
107 + {
108 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
109 + return Guid.TryParse(raw, out var id) ? id : null;
110 + }
111 +
112 + private async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
113 + {
114 + var trip = await _uow.Trips.GetByIdAsync(tripId);
115 + if (trip == null) return false;
116 + if (trip.CreatedById == userId) return true;
117 + var all = await _uow.Participants.GetAllAsync();
118 + return all.Any(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
119 + }
120 +
121 + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
122 + {
123 + var trip = await _uow.Trips.GetByIdAsync(tripId);
124 + if (trip == null) return false;
125 + if (trip.CreatedById == userId) return true;
126 + var all = await _uow.Participants.GetAllAsync();
127 + return all.Any(p => p.TripId == tripId
128 + && p.UserId == userId
129 + && p.IsActive
130 + && p.Role == EParticipantRole.Organizer);
131 + }
132 +
133 + private static BudgetCategoryDto MapToDto(BudgetCategory c, IReadOnlyDictionary<Guid, decimal>? spent)
134 + {
135 + return new BudgetCategoryDto
136 + {
137 + Id = c.Id,
138 + TripId = c.TripId,
139 + Name = c.Name.Translate() ?? string.Empty,
140 + IconName = c.IconName,
141 + PlannedAmount = c.PlannedAmount,
142 + SpentAmount = spent != null && spent.TryGetValue(c.Id, out var amt) ? amt : 0m,
143 + DisplayOrder = c.DisplayOrder,
144 + };
145 + }
146 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/InvitationsController.cs +200 −0
@@ -0,0 +1,200 @@
1 +using System.Security.Claims;
2 +using System.Security.Cryptography;
3 +using Asp.Versioning;
4 +using MediatR;
5 +using Microsoft.AspNetCore.Authentication.JwtBearer;
6 +using Microsoft.AspNetCore.Authorization;
7 +using Microsoft.AspNetCore.Mvc;
8 +using SplitApp.Modules.Trips.Api.Dto.v1;
9 +using SplitApp.Modules.Trips.Application.Contracts;
10 +using SplitApp.Modules.Trips.Domain.Entities;
11 +using SplitApp.Modules.Trips.Domain.Enums;
12 +using SplitApp.Shared.Messaging.Integration.Users;
13 +
14 +namespace SplitApp.Modules.Trips.Api.Controllers;
15 +
16 +[ApiVersion("1.0")]
17 +[ApiController]
18 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
19 +[Route("api/v{version:apiVersion}/[controller]")]
20 +public class InvitationsController : ControllerBase
21 +{
22 + private readonly ITripsUnitOfWork _uow;
23 + private readonly IMediator _mediator;
24 + private readonly IUserLookup _users;
25 +
26 + public InvitationsController(ITripsUnitOfWork uow, IMediator mediator, IUserLookup users)
27 + {
28 + _uow = uow;
29 + _mediator = mediator;
30 + _users = users;
31 + }
32 +
33 + [HttpPost]
34 + public async Task<ActionResult<InvitationDto>> Create([FromBody] InvitationCreateDto dto)
35 + {
36 + var userId = CurrentUserId();
37 + if (userId == null) return Unauthorized();
38 +
39 + if (!await IsOrganizerAsync(dto.TripId, userId.Value)) return Forbid();
40 +
41 + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
42 + .Replace("+", "-").Replace("/", "_").TrimEnd('=');
43 +
44 + var invitation = new TripInvitation
45 + {
46 + TripId = dto.TripId,
47 + InvitedByUserId = userId.Value,
48 + Token = token,
49 + Status = EInvitationStatus.Pending,
50 + ExpiresAt = DateTime.UtcNow.AddDays(7),
51 + };
52 +
53 + _uow.Invitations.Add(invitation);
54 + await _uow.SaveChangesAsync();
55 +
56 + return CreatedAtAction(nameof(GetByToken), new { token = invitation.Token }, await BuildDtoAsync(invitation));
57 + }
58 +
59 + [HttpGet("{token}")]
60 + [AllowAnonymous]
61 + public async Task<ActionResult<InvitationDto>> GetByToken(string token)
62 + {
63 + var invitation = await FindByTokenAsync(token);
64 + if (invitation == null) return NotFound();
65 +
66 + return Ok(await BuildDtoAsync(invitation));
67 + }
68 +
69 + [HttpPost("{token}/accept")]
70 + public async Task<IActionResult> Accept(string token)
71 + {
72 + var userId = CurrentUserId();
73 + if (userId == null) return Unauthorized();
74 +
75 + var invitation = await FindByTokenAsync(token);
76 + if (invitation == null) return NotFound();
77 +
78 + if (invitation.Status != EInvitationStatus.Pending)
79 + return BadRequest("Invitation is no longer pending.");
80 +
81 + if (invitation.ExpiresAt < DateTime.UtcNow)
82 + {
83 + invitation.Status = EInvitationStatus.Expired;
84 + _uow.Invitations.Update(invitation);
85 + await _uow.SaveChangesAsync();
86 + return BadRequest("Invitation has expired.");
87 + }
88 +
89 + var allParticipants = await _uow.Participants.GetAllAsync();
90 + var existingActive = allParticipants.FirstOrDefault(p =>
91 + p.TripId == invitation.TripId && p.UserId == userId.Value && p.IsActive);
92 + var existingInactive = allParticipants.FirstOrDefault(p =>
93 + p.TripId == invitation.TripId && p.UserId == userId.Value && !p.IsActive);
94 +
95 + if (existingActive != null)
96 + {
97 + return BadRequest("You are already a participant in this trip.");
98 + }
99 +
100 + if (existingInactive != null)
101 + {
102 + existingInactive.IsActive = true;
103 + existingInactive.LeftAt = null;
104 + _uow.Participants.Update(existingInactive);
105 + }
106 + else
107 + {
108 + _uow.Participants.Add(new TripParticipant
109 + {
110 + TripId = invitation.TripId,
111 + UserId = userId.Value,
112 + Role = EParticipantRole.Participant,
113 + JoinedAt = DateTime.UtcNow,
114 + IsActive = true,
115 + });
116 + }
117 +
118 + invitation.Status = EInvitationStatus.Accepted;
119 + invitation.RespondedAt = DateTime.UtcNow;
120 + _uow.Invitations.Update(invitation);
121 +
122 + await _uow.SaveChangesAsync();
123 + return Ok();
124 + }
125 +
126 + [HttpPost("{token}/decline")]
127 + public async Task<IActionResult> Decline(string token)
128 + {
129 + var invitation = await FindByTokenAsync(token);
130 + if (invitation == null) return NotFound();
131 +
132 + if (invitation.Status != EInvitationStatus.Pending)
133 + return BadRequest("Invitation is no longer pending.");
134 +
135 + invitation.Status = EInvitationStatus.Declined;
136 + invitation.RespondedAt = DateTime.UtcNow;
137 + _uow.Invitations.Update(invitation);
138 + await _uow.SaveChangesAsync();
139 + return Ok();
140 + }
141 +
142 + [HttpPost("{token}/revoke")]
143 + public async Task<IActionResult> Revoke(string token)
144 + {
145 + var userId = CurrentUserId();
146 + if (userId == null) return Unauthorized();
147 +
148 + var invitation = await FindByTokenAsync(token);
149 + if (invitation == null) return NotFound();
150 +
151 + if (!await IsOrganizerAsync(invitation.TripId, userId.Value)) return Forbid();
152 +
153 + invitation.Status = EInvitationStatus.Revoked;
154 + invitation.RespondedAt = DateTime.UtcNow;
155 + _uow.Invitations.Update(invitation);
156 + await _uow.SaveChangesAsync();
157 + return Ok();
158 + }
159 +
160 + private Guid? CurrentUserId()
161 + {
162 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
163 + return Guid.TryParse(raw, out var id) ? id : null;
164 + }
165 +
166 + private async Task<TripInvitation?> FindByTokenAsync(string token)
167 + {
168 + var all = await _uow.Invitations.GetAllAsync();
169 + return all.FirstOrDefault(i => i.Token == token);
170 + }
171 +
172 + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
173 + {
174 + var trip = await _uow.Trips.GetByIdAsync(tripId);
175 + if (trip == null) return false;
176 + if (trip.CreatedById == userId) return true;
177 + var all = await _uow.Participants.GetAllAsync();
178 + return all.Any(p => p.TripId == tripId
179 + && p.UserId == userId
180 + && p.IsActive
181 + && p.Role == EParticipantRole.Organizer);
182 + }
183 +
184 + private async Task<InvitationDto> BuildDtoAsync(TripInvitation invitation)
185 + {
186 + var trip = await _uow.Trips.GetByIdAsync(invitation.TripId);
187 + var inviter = await _users.GetByIdAsync(invitation.InvitedByUserId);
188 +
189 + return new InvitationDto
190 + {
191 + Id = invitation.Id,
192 + TripId = invitation.TripId,
193 + TripName = trip?.Name,
194 + Token = invitation.Token,
195 + Status = invitation.Status.ToString(),
196 + ExpiresAt = invitation.ExpiresAt,
197 + InvitedByUserName = inviter?.DisplayName,
198 + };
199 + }
200 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/PollsController.cs +255 −0
@@ -0,0 +1,255 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Trips.Api.Dto.v1;
8 +using SplitApp.Modules.Trips.Application.Contracts;
9 +using SplitApp.Modules.Trips.Domain.Entities;
10 +using SplitApp.Modules.Trips.Domain.Enums;
11 +
12 +namespace SplitApp.Modules.Trips.Api.Controllers;
13 +
14 +[ApiVersion("1.0")]
15 +[ApiController]
16 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
17 +[Route("api/v{version:apiVersion}/[controller]")]
18 +public class PollsController : ControllerBase
19 +{
20 + private readonly ITripsUnitOfWork _uow;
21 +
22 + public PollsController(ITripsUnitOfWork uow)
23 + {
24 + _uow = uow;
25 + }
26 +
27 + [HttpGet("trip/{tripId:guid}")]
28 + public async Task<ActionResult<List<PollDto>>> GetForTrip(Guid tripId)
29 + {
30 + var userId = CurrentUserId();
31 + if (userId == null) return Unauthorized();
32 +
33 + if (!await IsParticipantAsync(tripId, userId.Value)) return Forbid();
34 +
35 + var allPolls = await _uow.Polls.GetAllAsync();
36 + var polls = allPolls.Where(p => p.TripId == tripId).ToList();
37 + var allOptions = await _uow.PollOptions.GetAllAsync();
38 + var allVotes = await _uow.PollVotes.GetAllAsync();
39 +
40 + return Ok(polls.Select(p => MapPoll(p, allOptions, allVotes, userId.Value)).ToList());
41 + }
42 +
43 + [HttpGet("{id:guid}")]
44 + public async Task<ActionResult<PollDto>> Get(Guid id)
45 + {
46 + var userId = CurrentUserId();
47 + if (userId == null) return Unauthorized();
48 +
49 + var poll = await _uow.Polls.GetByIdAsync(id);
50 + if (poll == null) return NotFound();
51 + if (!await IsParticipantAsync(poll.TripId, userId.Value)) return NotFound();
52 +
53 + var allOptions = await _uow.PollOptions.GetAllAsync();
54 + var allVotes = await _uow.PollVotes.GetAllAsync();
55 + return Ok(MapPoll(poll, allOptions, allVotes, userId.Value));
56 + }
57 +
58 + [HttpPost]
59 + public async Task<ActionResult<PollDto>> Create([FromBody] PollCreateDto dto)
60 + {
61 + var userId = CurrentUserId();
62 + if (userId == null) return Unauthorized();
63 +
64 + if (!await IsParticipantAsync(dto.TripId, userId.Value)) return Forbid();
65 +
66 + if (dto.Options == null || dto.Options.Count < 2)
67 + return BadRequest("Poll must have at least two options.");
68 +
69 + var poll = new TripPoll
70 + {
71 + TripId = dto.TripId,
72 + CreatedByUserId = userId.Value,
73 + Question = dto.Question,
74 + AllowMultipleVotes = dto.AllowMultipleVotes,
75 + IsAnonymous = dto.IsAnonymous,
76 + };
77 + _uow.Polls.Add(poll);
78 +
79 + var order = 0;
80 + foreach (var optionText in dto.Options)
81 + {
82 + _uow.PollOptions.Add(new TripPollOption
83 + {
84 + PollId = poll.Id,
85 + Text = optionText,
86 + DisplayOrder = order++,
87 + });
88 + }
89 +
90 + await _uow.SaveChangesAsync();
91 +
92 + var allOptions = await _uow.PollOptions.GetAllAsync();
93 + var allVotes = await _uow.PollVotes.GetAllAsync();
94 + var dtoResult = MapPoll(poll, allOptions, allVotes, userId.Value);
95 + return CreatedAtAction(nameof(Get), new { id = poll.Id }, dtoResult);
96 + }
97 +
98 + [HttpPost("{id:guid}/vote")]
99 + public async Task<IActionResult> Vote(Guid id, [FromBody] PollVoteRequest request)
100 + {
101 + var userId = CurrentUserId();
102 + if (userId == null) return Unauthorized();
103 +
104 + var poll = await _uow.Polls.GetByIdAsync(id);
105 + if (poll == null) return NotFound();
106 +
107 + if (poll.ClosedAt != null) return BadRequest("Poll is closed.");
108 +
109 + if (!await IsParticipantAsync(poll.TripId, userId.Value)) return Forbid();
110 +
111 + var option = await _uow.PollOptions.GetByIdAsync(request.OptionId);
112 + if (option == null || option.PollId != poll.Id) return BadRequest("Invalid option.");
113 +
114 + var allVotes = await _uow.PollVotes.GetAllAsync();
115 + var pollOptionIds = (await _uow.PollOptions.GetAllAsync())
116 + .Where(o => o.PollId == poll.Id)
117 + .Select(o => o.Id)
118 + .ToHashSet();
119 +
120 + var existingVotesForUser = allVotes
121 + .Where(v => v.UserId == userId.Value && pollOptionIds.Contains(v.PollOptionId))
122 + .ToList();
123 +
124 + if (!poll.AllowMultipleVotes)
125 + {
126 + foreach (var v in existingVotesForUser)
127 + {
128 + await _uow.PollVotes.RemoveAsync(v.Id);
129 + }
130 + }
131 + else if (existingVotesForUser.Any(v => v.PollOptionId == request.OptionId))
132 + {
133 + // Toggle off if same option voted again under multi-vote
134 + var existing = existingVotesForUser.First(v => v.PollOptionId == request.OptionId);
135 + await _uow.PollVotes.RemoveAsync(existing.Id);
136 + await _uow.SaveChangesAsync();
137 + return Ok();
138 + }
139 +
140 + _uow.PollVotes.Add(new TripPollVote
141 + {
142 + PollOptionId = request.OptionId,
143 + UserId = userId.Value,
144 + });
145 +
146 + await _uow.SaveChangesAsync();
147 + return Ok();
148 + }
149 +
150 + [HttpPost("{id:guid}/close")]
151 + public async Task<IActionResult> Close(Guid id)
152 + {
153 + var userId = CurrentUserId();
154 + if (userId == null) return Unauthorized();
155 +
156 + var poll = await _uow.Polls.GetByIdAsync(id);
157 + if (poll == null) return NotFound();
158 +
159 + var canClose = poll.CreatedByUserId == userId.Value
160 + || await IsOrganizerAsync(poll.TripId, userId.Value);
161 + if (!canClose) return Forbid();
162 +
163 + poll.ClosedAt = DateTime.UtcNow;
164 + _uow.Polls.Update(poll);
165 + await _uow.SaveChangesAsync();
166 + return Ok();
167 + }
168 +
169 + [HttpDelete("{id:guid}")]
170 + public async Task<IActionResult> Delete(Guid id)
171 + {
172 + var userId = CurrentUserId();
173 + if (userId == null) return Unauthorized();
174 +
175 + var poll = await _uow.Polls.GetByIdAsync(id);
176 + if (poll == null) return NotFound();
177 +
178 + var canDelete = poll.CreatedByUserId == userId.Value
179 + || await IsOrganizerAsync(poll.TripId, userId.Value);
180 + if (!canDelete) return Forbid();
181 +
182 + var allOptions = await _uow.PollOptions.GetAllAsync();
183 + var allVotes = await _uow.PollVotes.GetAllAsync();
184 + var optionIds = allOptions.Where(o => o.PollId == poll.Id).Select(o => o.Id).ToHashSet();
185 +
186 + foreach (var v in allVotes.Where(v => optionIds.Contains(v.PollOptionId)))
187 + {
188 + await _uow.PollVotes.RemoveAsync(v.Id);
189 + }
190 + foreach (var oid in optionIds)
191 + {
192 + await _uow.PollOptions.RemoveAsync(oid);
193 + }
194 + await _uow.Polls.RemoveAsync(id);
195 + await _uow.SaveChangesAsync();
196 + return NoContent();
197 + }
198 +
199 + private Guid? CurrentUserId()
200 + {
201 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
202 + return Guid.TryParse(raw, out var id) ? id : null;
203 + }
204 +
205 + private async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
206 + {
207 + var trip = await _uow.Trips.GetByIdAsync(tripId);
208 + if (trip == null) return false;
209 + if (trip.CreatedById == userId) return true;
210 + var all = await _uow.Participants.GetAllAsync();
211 + return all.Any(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
212 + }
213 +
214 + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
215 + {
216 + var trip = await _uow.Trips.GetByIdAsync(tripId);
217 + if (trip == null) return false;
218 + if (trip.CreatedById == userId) return true;
219 + var all = await _uow.Participants.GetAllAsync();
220 + return all.Any(p => p.TripId == tripId
221 + && p.UserId == userId
222 + && p.IsActive
223 + && p.Role == EParticipantRole.Organizer);
224 + }
225 +
226 + private static PollDto MapPoll(
227 + TripPoll poll,
228 + IEnumerable<TripPollOption> allOptions,
229 + IEnumerable<TripPollVote> allVotes,
230 + Guid currentUserId)
231 + {
232 + var options = allOptions.Where(o => o.PollId == poll.Id).OrderBy(o => o.DisplayOrder).ToList();
233 + var optionIds = options.Select(o => o.Id).ToHashSet();
234 + var votes = allVotes.Where(v => optionIds.Contains(v.PollOptionId)).ToList();
235 +
236 + return new PollDto
237 + {
238 + Id = poll.Id,
239 + TripId = poll.TripId,
240 + CreatedByUserId = poll.CreatedByUserId,
241 + Question = poll.Question,
242 + AllowMultipleVotes = poll.AllowMultipleVotes,
243 + IsAnonymous = poll.IsAnonymous,
244 + ClosedAt = poll.ClosedAt,
245 + Options = options.Select(o => new PollOptionDto
246 + {
247 + Id = o.Id,
248 + Text = o.Text,
249 + DisplayOrder = o.DisplayOrder,
250 + VoteCount = votes.Count(v => v.PollOptionId == o.Id),
251 + VotedByCurrentUser = votes.Any(v => v.PollOptionId == o.Id && v.UserId == currentUserId),
252 + }).ToList(),
253 + };
254 + }
255 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/TripsController.cs +329 −0
@@ -0,0 +1,329 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Trips.Api.Dto.v1;
8 +using SplitApp.Modules.Trips.Application.Contracts;
9 +using SplitApp.Modules.Trips.Domain.Entities;
10 +using SplitApp.Modules.Trips.Domain.Enums;
11 +using SplitApp.Shared.Contracts.Expenses.Commands;
12 +using SplitApp.Shared.Contracts.Expenses.Queries;
13 +using SplitApp.Shared.Contracts.Trips.Events;
14 +using SplitApp.Shared.Messaging.Integration.Users;
15 +
16 +namespace SplitApp.Modules.Trips.Api.Controllers;
17 +
18 +[ApiVersion("1.0")]
19 +[ApiController]
20 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
21 +[Route("api/v{version:apiVersion}/[controller]")]
22 +public class TripsController : ControllerBase
23 +{
24 + private readonly ITripsUnitOfWork _uow;
25 + private readonly IMediator _mediator;
26 + private readonly IUserLookup _users;
27 +
28 + public TripsController(ITripsUnitOfWork uow, IMediator mediator, IUserLookup users)
29 + {
30 + _uow = uow;
31 + _mediator = mediator;
32 + _users = users;
33 + }
34 +
35 + [HttpGet]
36 + public async Task<ActionResult<IEnumerable<TripDto>>> List()
37 + {
38 + var userId = CurrentUserId();
39 + if (userId == null) return Unauthorized();
40 +
41 + var all = (await _uow.Trips.GetAllAsync()).ToList();
42 + var allParticipants = (await _uow.Participants.GetAllAsync()).ToList();
43 + var participantTripIds = allParticipants
44 + .Where(p => p.UserId == userId.Value && p.IsActive)
45 + .Select(p => p.TripId)
46 + .ToHashSet();
47 +
48 + var visible = all
49 + .Where(t => t.CreatedById == userId.Value || participantTripIds.Contains(t.Id))
50 + .ToList();
51 +
52 + // Cross-module currency lookup so frontend gets defaultCurrencyCode/Symbol.
53 + var currencyIds = visible.Select(t => t.DefaultCurrencyId).Distinct().ToList();
54 + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(currencyIds)))
55 + .ToDictionary(c => c.Id);
56 +
57 + // Per-trip participant counts, no embedded list (keeps payload small for index page).
58 + var perTripParticipantCount = allParticipants
59 + .Where(p => p.IsActive)
60 + .GroupBy(p => p.TripId)
61 + .ToDictionary(g => g.Key, g => g.Count());
62 +
63 + return Ok(visible.Select(t => MapToDto(t, currencies, perTripParticipantCount.GetValueOrDefault(t.Id))));
64 + }
65 +
66 + [HttpGet("{id:guid}")]
67 + public async Task<ActionResult<TripDto>> Get(Guid id)
68 + {
69 + var userId = CurrentUserId();
70 + if (userId == null) return Unauthorized();
71 +
72 + var trip = await _uow.Trips.GetByIdAsync(id);
73 + if (trip == null) return NotFound();
74 + if (!await CanReadAsync(trip, userId.Value)) return Forbid();
75 +
76 + // Currency
77 + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(new[] { trip.DefaultCurrencyId })))
78 + .ToDictionary(c => c.Id);
79 +
80 + // Participants + cross-module user name lookup
81 + var allParticipants = await _uow.Participants.GetAllAsync();
82 + var tripParticipants = allParticipants
83 + .Where(p => p.TripId == trip.Id)
84 + .OrderBy(p => p.JoinedAt)
85 + .ToList();
86 + var participantDtos = await BuildParticipantDtosAsync(tripParticipants);
87 +
88 + var dto = MapToDto(trip, currencies, tripParticipants.Count(p => p.IsActive));
89 + dto.Participants = participantDtos;
90 + return Ok(dto);
91 + }
92 +
93 + [HttpPost]
94 + public async Task<ActionResult<TripDto>> Create([FromBody] TripCreateDto dto)
95 + {
96 + var userId = CurrentUserId();
97 + if (userId == null) return Unauthorized();
98 +
99 + var trip = new Trip
100 + {
101 + Name = dto.Name,
102 + Description = dto.Description,
103 + Destination = dto.Destination,
104 + StartDate = dto.StartDate,
105 + EndDate = dto.EndDate,
106 + DefaultCurrencyId = dto.DefaultCurrencyId,
107 + CreatedById = userId.Value,
108 + Status = ETripStatus.Active,
109 + };
110 + _uow.Trips.Add(trip);
111 +
112 + _uow.Participants.Add(new TripParticipant
113 + {
114 + TripId = trip.Id,
115 + UserId = userId.Value,
116 + Role = EParticipantRole.Organizer,
117 + JoinedAt = DateTime.UtcNow,
118 + IsActive = true,
119 + });
120 +
121 + await _uow.SaveChangesAsync();
122 +
123 + // Reload with full hydration so frontend gets a complete TripDto on POST response.
124 + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(new[] { trip.DefaultCurrencyId })))
125 + .ToDictionary(c => c.Id);
126 + var participants = (await _uow.Participants.GetAllAsync())
127 + .Where(p => p.TripId == trip.Id).OrderBy(p => p.JoinedAt).ToList();
128 + var participantDtos = await BuildParticipantDtosAsync(participants);
129 + var responseDto = MapToDto(trip, currencies, participants.Count(p => p.IsActive));
130 + responseDto.Participants = participantDtos;
131 +
132 + return CreatedAtAction(nameof(Get), new { id = trip.Id, version = "1.0" }, responseDto);
133 + }
134 +
135 + [HttpPut("{id:guid}")]
136 + public async Task<IActionResult> Update(Guid id, [FromBody] TripUpdateDto dto)
137 + {
138 + var userId = CurrentUserId();
139 + if (userId == null) return Unauthorized();
140 + if (dto.Id != Guid.Empty && dto.Id != id) return BadRequest();
141 +
142 + var trip = await _uow.Trips.GetByIdAsync(id);
143 + if (trip == null) return NotFound();
144 + if (trip.CreatedById != userId.Value) return Forbid();
145 +
146 + trip.Name = dto.Name;
147 + trip.Description = dto.Description;
148 + trip.Destination = dto.Destination;
149 + trip.StartDate = dto.StartDate;
150 + trip.EndDate = dto.EndDate;
151 + trip.DefaultCurrencyId = dto.DefaultCurrencyId;
152 + if (!string.IsNullOrEmpty(dto.Status) && Enum.TryParse<ETripStatus>(dto.Status, true, out var newStatus))
153 + {
154 + trip.Status = newStatus;
155 + }
156 + _uow.Trips.Update(trip);
157 + await _uow.SaveChangesAsync();
158 + return NoContent();
159 + }
160 +
161 + [HttpDelete("{id:guid}")]
162 + public async Task<IActionResult> Delete(Guid id)
163 + {
164 + var userId = CurrentUserId();
165 + if (userId == null) return Unauthorized();
166 +
167 + var trip = await _uow.Trips.GetByIdAsync(id);
168 + if (trip == null) return NotFound();
169 + if (trip.CreatedById != userId.Value) return Forbid();
170 +
171 + await _uow.Trips.RemoveAsync(id);
172 + await _uow.SaveChangesAsync();
173 +
174 + await _mediator.Publish(new TripDeletedEvent(id));
175 + return NoContent();
176 + }
177 +
178 + [HttpGet("{tripId:guid}/participants")]
179 + public async Task<ActionResult<List<TripParticipantDto>>> GetParticipants(Guid tripId)
180 + {
181 + var userId = CurrentUserId();
182 + if (userId == null) return Unauthorized();
183 +
184 + var trip = await _uow.Trips.GetByIdAsync(tripId);
185 + if (trip == null) return NotFound();
186 + if (!await CanReadAsync(trip, userId.Value)) return Forbid();
187 +
188 + var rows = (await _uow.Participants.GetAllAsync())
189 + .Where(p => p.TripId == tripId)
190 + .OrderBy(p => p.JoinedAt)
191 + .ToList();
192 + return Ok(await BuildParticipantDtosAsync(rows));
193 + }
194 +
195 + [HttpPost("{id:guid}/finalize")]
196 + public async Task<IActionResult> Finalize(Guid id)
197 + {
198 + var userId = CurrentUserId();
199 + if (userId == null) return Unauthorized();
200 +
201 + var trip = await _uow.Trips.GetByIdAsync(id);
202 + if (trip == null) return NotFound();
203 + if (trip.CreatedById != userId.Value) return Forbid();
204 + if (trip.Status != ETripStatus.Active) return BadRequest(new { error = "Trip must be active to finalize." });
205 +
206 + trip.Status = ETripStatus.Finalizing;
207 + _uow.Trips.Update(trip);
208 + await _uow.SaveChangesAsync();
209 +
210 + // Auto-create the settlement plan + concrete payments (phase-2 parity).
211 + // The Vue front shows real Mark Paid / Confirm Receipt UI only when latestPlan is non-null;
212 + // without this step it falls back to a "preview" view with no per-user actions.
213 + var planId = await _mediator.Send(new CalculateSettlementCommand(id, userId.Value));
214 +
215 + // No outstanding balances → trip is already settled. Mark accordingly.
216 + if (planId == null)
217 + {
218 + trip.Status = ETripStatus.Settled;
219 + _uow.Trips.Update(trip);
220 + await _uow.SaveChangesAsync();
221 + }
222 +
223 + return Ok();
224 + }
225 +
226 + [HttpPost("{id:guid}/reopen")]
227 + public async Task<IActionResult> Reopen(Guid id)
228 + {
229 + var userId = CurrentUserId();
230 + if (userId == null) return Unauthorized();
231 +
232 + var trip = await _uow.Trips.GetByIdAsync(id);
233 + if (trip == null) return NotFound();
234 + if (trip.CreatedById != userId.Value) return Forbid();
235 + if (trip.Status != ETripStatus.Finalizing && trip.Status != ETripStatus.Settled)
236 + return BadRequest(new { error = "Trip can only be reopened from Finalizing or Settled." });
237 +
238 + // Drop the existing settlement plan + payments so the next Finalize starts fresh.
239 + // Returns false if any payment is already Confirmed — phase-2 parity.
240 + var removed = await _mediator.Send(new RemoveSettlementPlanCommand(id));
241 + if (!removed)
242 + {
243 + return BadRequest(new { error = "Cannot reopen: settlement has confirmed payments." });
244 + }
245 +
246 + trip.Status = ETripStatus.Active;
247 + _uow.Trips.Update(trip);
248 + await _uow.SaveChangesAsync();
249 + return Ok();
250 + }
251 +
252 + [HttpDelete("{tripId:guid}/participants/{userId:guid}")]
253 + public async Task<IActionResult> RemoveParticipant(Guid tripId, Guid userId)
254 + {
255 + var currentUserId = CurrentUserId();
256 + if (currentUserId == null) return Unauthorized();
257 +
258 + var trip = await _uow.Trips.GetByIdAsync(tripId);
259 + if (trip == null) return NotFound();
260 + if (trip.CreatedById != currentUserId.Value) return Forbid();
261 + if (userId == currentUserId.Value) return BadRequest(new { error = "Cannot remove yourself." });
262 +
263 + var participant = (await _uow.Participants.GetAllAsync())
264 + .FirstOrDefault(p => p.TripId == tripId && p.UserId == userId);
265 + if (participant == null) return NotFound();
266 + if (participant.Role == EParticipantRole.Organizer) return BadRequest(new { error = "Cannot remove an organizer." });
267 +
268 + participant.IsActive = false;
269 + participant.LeftAt = DateTime.UtcNow;
270 + _uow.Participants.Update(participant);
271 + await _uow.SaveChangesAsync();
272 + return NoContent();
273 + }
274 +
275 + private Guid? CurrentUserId()
276 + {
277 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
278 + return Guid.TryParse(raw, out var id) ? id : null;
279 + }
280 +
281 + private async Task<bool> CanReadAsync(Trip trip, Guid userId)
282 + {
283 + if (trip.CreatedById == userId) return true;
284 + var allParticipants = await _uow.Participants.GetAllAsync();
285 + return allParticipants.Any(p => p.TripId == trip.Id && p.UserId == userId && p.IsActive);
286 + }
287 +
288 + private async Task<List<TripParticipantDto>> BuildParticipantDtosAsync(IList<TripParticipant> rows)
289 + {
290 + if (rows.Count == 0) return new List<TripParticipantDto>();
291 + var userIds = rows.Select(p => p.UserId).Distinct().ToList();
292 + var users = await _users.GetByIdsAsync(userIds);
293 + return rows.Select(p =>
294 + {
295 + users.TryGetValue(p.UserId, out var u);
296 + return new TripParticipantDto
297 + {
298 + Id = p.Id,
299 + TripId = p.TripId,
300 + UserId = p.UserId,
301 + UserName = u?.DisplayName,
302 + UserEmail = u?.Email,
303 + Role = p.Role.ToString(),
304 + Nickname = p.Nickname,
305 + JoinedAt = p.JoinedAt,
306 + IsActive = p.IsActive,
307 + };
308 + }).ToList();
309 + }
310 +
311 + private static TripDto MapToDto(
312 + Trip trip,
313 + IDictionary<Guid, SplitApp.Shared.Contracts.Expenses.CurrencyDto> currencies,
314 + int participantCount) => new()
315 + {
316 + Id = trip.Id,
317 + Name = trip.Name,
318 + Description = trip.Description,
319 + Destination = trip.Destination,
320 + StartDate = trip.StartDate,
321 + EndDate = trip.EndDate,
322 + Status = trip.Status.ToString(),
323 + DefaultCurrencyId = trip.DefaultCurrencyId,
324 + DefaultCurrencyCode = currencies.TryGetValue(trip.DefaultCurrencyId, out var c) ? c.Code : null,
325 + DefaultCurrencySymbol = currencies.TryGetValue(trip.DefaultCurrencyId, out var c2) ? c2.Symbol : null,
326 + CreatedById = trip.CreatedById,
327 + ParticipantCount = participantCount,
328 + };
329 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/WishlistController.cs +242 −0
@@ -0,0 +1,242 @@
1 +using System.Security.Claims;
2 +using Asp.Versioning;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using SplitApp.Modules.Trips.Api.Dto.v1;
8 +using SplitApp.Modules.Trips.Application.Contracts;
9 +using SplitApp.Modules.Trips.Domain.Entities;
10 +using SplitApp.Modules.Trips.Domain.Enums;
11 +using SplitApp.Shared.Messaging.Integration.Users;
12 +
13 +namespace SplitApp.Modules.Trips.Api.Controllers;
14 +
15 +[ApiVersion("1.0")]
16 +[ApiController]
17 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
18 +[Route("api/v{version:apiVersion}/[controller]")]
19 +public class WishlistController : ControllerBase
20 +{
21 + private readonly ITripsUnitOfWork _uow;
22 + private readonly IMediator _mediator;
23 + private readonly IUserLookup _users;
24 +
25 + public WishlistController(ITripsUnitOfWork uow, IMediator mediator, IUserLookup users)
26 + {
27 + _uow = uow;
28 + _mediator = mediator;
29 + _users = users;
30 + }
31 +
32 + [HttpGet("trip/{tripId:guid}")]
33 + public async Task<ActionResult<List<WishlistItemDto>>> GetForTrip(Guid tripId)
34 + {
35 + var userId = CurrentUserId();
36 + if (userId == null) return Unauthorized();
37 +
38 + if (!await IsParticipantAsync(tripId, userId.Value)) return Forbid();
39 +
40 + var allItems = await _uow.WishlistItems.GetAllAsync();
41 + var items = allItems.Where(i => i.TripId == tripId).OrderBy(i => i.DisplayOrder).ToList();
42 + var allVotes = await _uow.WishlistVotes.GetAllAsync();
43 +
44 + var addedByIds = items.Select(i => i.AddedByUserId).Distinct().ToList();
45 + var users = await _users.GetByIdsAsync(addedByIds);
46 + var nameLookup = users.ToDictionary(kv => kv.Key, kv => kv.Value.DisplayName);
47 +
48 + return Ok(items.Select(i => MapItem(i, allVotes, userId.Value, nameLookup)).ToList());
49 + }
50 +
51 + [HttpPost]
52 + public async Task<ActionResult<WishlistItemDto>> Create([FromBody] WishlistItemCreateDto dto)
53 + {
54 + var userId = CurrentUserId();
55 + if (userId == null) return Unauthorized();
56 +
57 + if (!await IsParticipantAsync(dto.TripId, userId.Value)) return Forbid();
58 + if (!Enum.TryParse<EWishlistCategory>(dto.Category, true, out var category))
59 + return BadRequest($"Unknown category '{dto.Category}'.");
60 + if (!Enum.TryParse<EWishlistPriority>(dto.Priority, true, out var priority))
61 + return BadRequest($"Unknown priority '{dto.Priority}'.");
62 +
63 + var entity = new TripWishlistItem
64 + {
65 + TripId = dto.TripId,
66 + AddedByUserId = userId.Value,
67 + Title = dto.Title,
68 + Description = dto.Description,
69 + Category = category,
70 + Priority = priority,
71 + EstimatedCost = dto.EstimatedCost,
72 + Url = dto.Url,
73 + Location = dto.Location,
74 + IsCompleted = false,
75 + DisplayOrder = 0,
76 + };
77 + _uow.WishlistItems.Add(entity);
78 + await _uow.SaveChangesAsync();
79 +
80 + var votes = await _uow.WishlistVotes.GetAllAsync();
81 + return CreatedAtAction(null, new { id = entity.Id }, MapItem(entity, votes, userId.Value, null));
82 + }
83 +
84 + [HttpPut("{id:guid}")]
85 + public async Task<IActionResult> Update(Guid id, [FromBody] WishlistItemCreateDto dto)
86 + {
87 + var userId = CurrentUserId();
88 + if (userId == null) return Unauthorized();
89 +
90 + var existing = await _uow.WishlistItems.GetByIdAsync(id);
91 + if (existing == null) return NotFound();
92 +
93 + var canEdit = existing.AddedByUserId == userId.Value
94 + || await IsOrganizerAsync(existing.TripId, userId.Value);
95 + if (!canEdit) return Forbid();
96 +
97 + if (!Enum.TryParse<EWishlistCategory>(dto.Category, true, out var category))
98 + return BadRequest($"Unknown category '{dto.Category}'.");
99 + if (!Enum.TryParse<EWishlistPriority>(dto.Priority, true, out var priority))
100 + return BadRequest($"Unknown priority '{dto.Priority}'.");
101 +
102 + existing.Title = dto.Title;
103 + existing.Description = dto.Description;
104 + existing.Category = category;
105 + existing.Priority = priority;
106 + existing.EstimatedCost = dto.EstimatedCost;
107 + existing.Url = dto.Url;
108 + existing.Location = dto.Location;
109 +
110 + _uow.WishlistItems.Update(existing);
111 + await _uow.SaveChangesAsync();
112 + return NoContent();
113 + }
114 +
115 + [HttpDelete("{id:guid}")]
116 + public async Task<IActionResult> Delete(Guid id)
117 + {
118 + var userId = CurrentUserId();
119 + if (userId == null) return Unauthorized();
120 +
121 + var existing = await _uow.WishlistItems.GetByIdAsync(id);
122 + if (existing == null) return NotFound();
123 +
124 + var canDelete = existing.AddedByUserId == userId.Value
125 + || await IsOrganizerAsync(existing.TripId, userId.Value);
126 + if (!canDelete) return Forbid();
127 +
128 + var allVotes = await _uow.WishlistVotes.GetAllAsync();
129 + foreach (var v in allVotes.Where(v => v.WishlistItemId == id))
130 + {
131 + await _uow.WishlistVotes.RemoveAsync(v.Id);
132 + }
133 + await _uow.WishlistItems.RemoveAsync(id);
134 + await _uow.SaveChangesAsync();
135 + return NoContent();
136 + }
137 +
138 + [HttpPost("{id:guid}/vote")]
139 + public async Task<IActionResult> ToggleVote(Guid id)
140 + {
141 + var userId = CurrentUserId();
142 + if (userId == null) return Unauthorized();
143 +
144 + var item = await _uow.WishlistItems.GetByIdAsync(id);
145 + if (item == null) return NotFound();
146 +
147 + if (!await IsParticipantAsync(item.TripId, userId.Value)) return Forbid();
148 +
149 + var allVotes = await _uow.WishlistVotes.GetAllAsync();
150 + var existing = allVotes.FirstOrDefault(v => v.WishlistItemId == id && v.UserId == userId.Value);
151 + if (existing != null)
152 + {
153 + await _uow.WishlistVotes.RemoveAsync(existing.Id);
154 + }
155 + else
156 + {
157 + _uow.WishlistVotes.Add(new TripWishlistVote
158 + {
159 + WishlistItemId = id,
160 + UserId = userId.Value,
161 + IsInterested = true,
162 + });
163 + }
164 +
165 + await _uow.SaveChangesAsync();
166 + return Ok();
167 + }
168 +
169 + [HttpPost("{id:guid}/complete")]
170 + public async Task<IActionResult> ToggleComplete(Guid id)
171 + {
172 + var userId = CurrentUserId();
173 + if (userId == null) return Unauthorized();
174 +
175 + var item = await _uow.WishlistItems.GetByIdAsync(id);
176 + if (item == null) return NotFound();
177 +
178 + var canToggle = item.AddedByUserId == userId.Value
179 + || await IsOrganizerAsync(item.TripId, userId.Value);
180 + if (!canToggle) return Forbid();
181 +
182 + item.IsCompleted = !item.IsCompleted;
183 + item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
184 + _uow.WishlistItems.Update(item);
185 + await _uow.SaveChangesAsync();
186 + return Ok();
187 + }
188 +
189 + private Guid? CurrentUserId()
190 + {
191 + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier);
192 + return Guid.TryParse(raw, out var id) ? id : null;
193 + }
194 +
195 + private async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
196 + {
197 + var trip = await _uow.Trips.GetByIdAsync(tripId);
198 + if (trip == null) return false;
199 + if (trip.CreatedById == userId) return true;
200 + var all = await _uow.Participants.GetAllAsync();
201 + return all.Any(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
202 + }
203 +
204 + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
205 + {
206 + var trip = await _uow.Trips.GetByIdAsync(tripId);
207 + if (trip == null) return false;
208 + if (trip.CreatedById == userId) return true;
209 + var all = await _uow.Participants.GetAllAsync();
210 + return all.Any(p => p.TripId == tripId
211 + && p.UserId == userId
212 + && p.IsActive
213 + && p.Role == EParticipantRole.Organizer);
214 + }
215 +
216 + private static WishlistItemDto MapItem(
217 + TripWishlistItem item,
218 + IEnumerable<TripWishlistVote> allVotes,
219 + Guid currentUserId,
220 + IDictionary<Guid, string>? userNames)
221 + {
222 + var votes = allVotes.Where(v => v.WishlistItemId == item.Id).ToList();
223 + return new WishlistItemDto
224 + {
225 + Id = item.Id,
226 + TripId = item.TripId,
227 + AddedByUserId = item.AddedByUserId,
228 + AddedByUserName = userNames != null && userNames.TryGetValue(item.AddedByUserId, out var name) ? name : null,
229 + Title = item.Title,
230 + Description = item.Description,
231 + Category = item.Category.ToString(),
232 + Priority = item.Priority.ToString(),
233 + EstimatedCost = item.EstimatedCost,
234 + Url = item.Url,
235 + Location = item.Location,
236 + IsCompleted = item.IsCompleted,
237 + VoteCount = votes.Count,
238 + UserHasVoted = votes.Any(v => v.UserId == currentUserId),
239 + DisplayOrder = item.DisplayOrder,
240 + };
241 + }
242 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/BudgetCategoryDto.cs +21 −0
@@ -0,0 +1,21 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class BudgetCategoryDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public string Name { get; set; } = default!;
8 + public string? IconName { get; set; }
9 + public decimal? PlannedAmount { get; set; }
10 + public decimal SpentAmount { get; set; }
11 + public int DisplayOrder { get; set; }
12 +}
13 +
14 +public class BudgetCategoryCreateDto
15 +{
16 + public Guid TripId { get; set; }
17 + public string Name { get; set; } = default!;
18 + public string? IconName { get; set; }
19 + public decimal? PlannedAmount { get; set; }
20 + public int DisplayOrder { get; set; }
21 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/InvitationDto.cs +17 −0
@@ -0,0 +1,17 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class InvitationDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public string? TripName { get; set; }
8 + public string Token { get; set; } = default!;
9 + public string Status { get; set; } = default!;
10 + public DateTime ExpiresAt { get; set; }
11 + public string? InvitedByUserName { get; set; }
12 +}
13 +
14 +public class InvitationCreateDto
15 +{
16 + public Guid TripId { get; set; }
17 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/PollDto.cs +36 −0
@@ -0,0 +1,36 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class PollDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public Guid CreatedByUserId { get; set; }
8 + public string Question { get; set; } = default!;
9 + public bool AllowMultipleVotes { get; set; }
10 + public bool IsAnonymous { get; set; }
11 + public DateTime? ClosedAt { get; set; }
12 + public List<PollOptionDto>? Options { get; set; }
13 +}
14 +
15 +public class PollOptionDto
16 +{
17 + public Guid Id { get; set; }
18 + public string Text { get; set; } = default!;
19 + public int VoteCount { get; set; }
20 + public bool VotedByCurrentUser { get; set; }
21 + public int DisplayOrder { get; set; }
22 +}
23 +
24 +public class PollCreateDto
25 +{
26 + public Guid TripId { get; set; }
27 + public string Question { get; set; } = default!;
28 + public bool AllowMultipleVotes { get; set; }
29 + public bool IsAnonymous { get; set; }
30 + public List<string> Options { get; set; } = default!;
31 +}
32 +
33 +public class PollVoteRequest
34 +{
35 + public Guid OptionId { get; set; }
36 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/TripDto.cs +53 −0
@@ -0,0 +1,53 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class TripDto
4 +{
5 + public Guid Id { get; set; }
6 + public string Name { get; set; } = default!;
7 + public string? Description { get; set; }
8 + public string? Destination { get; set; }
9 + public DateTime? StartDate { get; set; }
10 + public DateTime? EndDate { get; set; }
11 + public string Status { get; set; } = default!;
12 + public Guid DefaultCurrencyId { get; set; }
13 + public string? DefaultCurrencyCode { get; set; }
14 + public string? DefaultCurrencySymbol { get; set; }
15 + public Guid CreatedById { get; set; }
16 + public int ParticipantCount { get; set; }
17 + public List<TripParticipantDto>? Participants { get; set; }
18 +}
19 +
20 +public class TripParticipantDto
21 +{
22 + public Guid Id { get; set; }
23 + public Guid TripId { get; set; }
24 + public Guid UserId { get; set; }
25 + public string? UserName { get; set; }
26 + public string? UserEmail { get; set; }
27 + public string Role { get; set; } = default!;
28 + public string? Nickname { get; set; }
29 + public DateTime JoinedAt { get; set; }
30 + public bool IsActive { get; set; }
31 +}
32 +
33 +public class TripCreateDto
34 +{
35 + public string Name { get; set; } = default!;
36 + public string? Description { get; set; }
37 + public string? Destination { get; set; }
38 + public DateTime? StartDate { get; set; }
39 + public DateTime? EndDate { get; set; }
40 + public Guid DefaultCurrencyId { get; set; }
41 +}
42 +
43 +public class TripUpdateDto
44 +{
45 + public Guid Id { get; set; }
46 + public string Name { get; set; } = default!;
47 + public string? Description { get; set; }
48 + public string? Destination { get; set; }
49 + public DateTime? StartDate { get; set; }
50 + public DateTime? EndDate { get; set; }
51 + public string? Status { get; set; }
52 + public Guid DefaultCurrencyId { get; set; }
53 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/WishlistItemDto.cs +32 −0
@@ -0,0 +1,32 @@
1 +namespace SplitApp.Modules.Trips.Api.Dto.v1;
2 +
3 +public class WishlistItemDto
4 +{
5 + public Guid Id { get; set; }
6 + public Guid TripId { get; set; }
7 + public Guid AddedByUserId { get; set; }
8 + public string? AddedByUserName { get; set; }
9 + public string Title { get; set; } = default!;
10 + public string? Description { get; set; }
11 + public string Category { get; set; } = default!;
12 + public string Priority { get; set; } = default!;
13 + public decimal? EstimatedCost { get; set; }
14 + public string? Url { get; set; }
15 + public string? Location { get; set; }
16 + public bool IsCompleted { get; set; }
17 + public int VoteCount { get; set; }
18 + public bool UserHasVoted { get; set; }
19 + public int DisplayOrder { get; set; }
20 +}
21 +
22 +public class WishlistItemCreateDto
23 +{
24 + public Guid TripId { get; set; }
25 + public string Title { get; set; } = default!;
26 + public string? Description { get; set; }
27 + public string Category { get; set; } = default!;
28 + public string Priority { get; set; } = default!;
29 + public decimal? EstimatedCost { get; set; }
30 + public string? Url { get; set; }
31 + public string? Location { get; set; }
32 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/ExampleJsInterop.cs +31 −0
@@ -0,0 +1,31 @@
1 +using Microsoft.JSInterop;
2 +
3 +namespace SplitApp.Modules.Trips.Api;
4 +
5 +// This class provides an example of how JavaScript functionality can be wrapped
6 +// in a .NET class for easy consumption. The associated JavaScript module is
7 +// loaded on demand when first needed.
8 +//
9 +// This class can be registered as scoped DI service and then injected into Blazor
10 +// components for use.
11 +
12 +public class ExampleJsInterop(IJSRuntime jsRuntime) : IAsyncDisposable
13 +{
14 + private readonly Lazy<Task<IJSObjectReference>> moduleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
15 + "import", "./_content/SplitApp.Modules.Trips.Api/exampleJsInterop.js").AsTask());
16 +
17 + public async ValueTask<string> Prompt(string message)
18 + {
19 + var module = await moduleTask.Value;
20 + return await module.InvokeAsync<string>("showPrompt", message);
21 + }
22 +
23 + public async ValueTask DisposeAsync()
24 + {
25 + if (moduleTask.IsValueCreated)
26 + {
27 + var module = await moduleTask.Value;
28 + await module.DisposeAsync();
29 + }
30 + }
31 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/SplitApp.Modules.Trips.Api.csproj +27 −0
@@ -0,0 +1,27 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Razor">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + </PropertyGroup>
8 +
9 +
10 + <ItemGroup>
11 + <SupportedPlatform Include="browser" />
12 + </ItemGroup>
13 +
14 + <ItemGroup>
15 + <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
16 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
18 + </ItemGroup>
19 +
20 + <ItemGroup>
21 + <ProjectReference Include="..\SplitApp.Modules.Trips.Application\SplitApp.Modules.Trips.Application.csproj" />
22 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
23 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
24 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Messaging\SplitApp.Shared.Messaging.csproj" />
25 + </ItemGroup>
26 +
27 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/_Imports.razor +1 −0
@@ -0,0 +1 @@
1 +@using Microsoft.AspNetCore.Components.Web
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/background.png +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/exampleJsInterop.js +6 −0
@@ -0,0 +1,6 @@
1 +// This is a JavaScript module that is loaded on demand. It can export any number of
2 +// functions, and may import other JavaScript modules if required.
3 +
4 +export function showPrompt(message) {
5 + return prompt(message, 'Type anything here');
6 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Contracts/ITripsUnitOfWork.cs +18 −0
@@ -0,0 +1,18 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Persistence;
4 +
5 +namespace SplitApp.Modules.Trips.Application.Contracts;
6 +
7 +public interface ITripsUnitOfWork : IUnitOfWork
8 +{
9 + IBaseRepository<Trip> Trips { get; }
10 + IBaseRepository<TripParticipant> Participants { get; }
11 + IBaseRepository<TripInvitation> Invitations { get; }
12 + IBaseRepository<BudgetCategory> BudgetCategories { get; }
13 + IBaseRepository<TripPoll> Polls { get; }
14 + IBaseRepository<TripPollOption> PollOptions { get; }
15 + IBaseRepository<TripPollVote> PollVotes { get; }
16 + IBaseRepository<TripWishlistItem> WishlistItems { get; }
17 + IBaseRepository<TripWishlistVote> WishlistVotes { get; }
18 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Handlers/GetTripByIdHandler.cs +23 −0
@@ -0,0 +1,23 @@
1 +using MediatR;
2 +using SplitApp.Modules.Trips.Application.Contracts;
3 +using SplitApp.Shared.Contracts.Trips;
4 +using SplitApp.Shared.Contracts.Trips.Queries;
5 +
6 +namespace SplitApp.Modules.Trips.Application.Handlers;
7 +
8 +public class GetTripByIdHandler : IRequestHandler<GetTripByIdQuery, TripSummaryDto?>
9 +{
10 + private readonly ITripsUnitOfWork _uow;
11 +
12 + public GetTripByIdHandler(ITripsUnitOfWork uow)
13 + {
14 + _uow = uow;
15 + }
16 +
17 + public async Task<TripSummaryDto?> Handle(GetTripByIdQuery request, CancellationToken cancellationToken)
18 + {
19 + var trip = await _uow.Trips.GetByIdAsync(request.TripId);
20 + if (trip == null) return null;
21 + return new TripSummaryDto(trip.Id, trip.CreatedById, trip.Name, trip.DefaultCurrencyId);
22 + }
23 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/SplitApp.Modules.Trips.Application.csproj +19 −0
@@ -0,0 +1,19 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Trips.Domain\SplitApp.Modules.Trips.Domain.csproj" />
5 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="MediatR" Version="12.4.1" />
11 + </ItemGroup>
12 +
13 + <PropertyGroup>
14 + <TargetFramework>net10.0</TargetFramework>
15 + <ImplicitUsings>enable</ImplicitUsings>
16 + <Nullable>enable</Nullable>
17 + </PropertyGroup>
18 +
19 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/TripsModuleMarker.cs +4 −0
@@ -0,0 +1,4 @@
1 +namespace SplitApp.Modules.Trips.Application;
2 +
3 +/// <summary>Assembly marker for MediatR handler scanning. Must remain in the Application assembly.</summary>
4 +public sealed class TripsModuleMarker;
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/BudgetCategory.cs +25 −0
@@ -0,0 +1,25 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Shared.Kernel.Domain;
5 +using SplitApp.Shared.Kernel.Localization;
6 +
7 +namespace SplitApp.Modules.Trips.Domain.Entities;
8 +
9 +public class BudgetCategory : BaseEntity
10 +{
11 + public Guid TripId { get; set; }
12 + public Trip? Trip { get; set; }
13 +
14 + public LangStr Name { get; set; } = new();
15 +
16 + [MaxLength(100)]
17 + public string? IconName { get; set; }
18 +
19 + public decimal? PlannedAmount { get; set; }
20 +
21 + public int DisplayOrder { get; set; }
22 +
23 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
24 + [NotMapped] public ICollection<Expense>? Expenses { get; set; }
25 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/Trip.cs +56 −0
@@ -0,0 +1,56 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Shared.Contracts.Users;
6 +using SplitApp.Shared.Kernel.Domain;
7 +
8 +namespace SplitApp.Modules.Trips.Domain.Entities;
9 +
10 +public class Trip : BaseEntity, IValidatableObject
11 +{
12 + [MaxLength(200)]
13 + public string Name { get; set; } = default!;
14 +
15 + public string? Description { get; set; }
16 +
17 + [MaxLength(200)]
18 + public string? Destination { get; set; }
19 +
20 + public DateTime? StartDate { get; set; }
21 +
22 + public DateTime? EndDate { get; set; }
23 +
24 + public ETripStatus Status { get; set; } = ETripStatus.Active;
25 +
26 + /// <summary>FK to expenses.Currency.</summary>
27 + public Guid DefaultCurrencyId { get; set; }
28 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
29 + [NotMapped] public Currency? DefaultCurrency { get; set; }
30 +
31 + /// <summary>FK to users.AspNetUsers.</summary>
32 + public Guid CreatedById { get; set; }
33 + /// <summary>Cross-module nav — populated by WebApp facade via IUserLookup (RabbitMQ RPC), never by EF (NotMapped).</summary>
34 + [NotMapped] public UserDto? CreatedBy { get; set; }
35 +
36 + public ICollection<TripParticipant>? Participants { get; set; }
37 + public ICollection<BudgetCategory>? BudgetCategories { get; set; }
38 + public ICollection<TripWishlistItem>? WishlistItems { get; set; }
39 + public ICollection<TripPoll>? Polls { get; set; }
40 + public ICollection<TripInvitation>? Invitations { get; set; }
41 +
42 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
43 + [NotMapped] public ICollection<Expense>? Expenses { get; set; }
44 + /// <summary>Cross-module nav — populated by WebApp facade, never by EF (NotMapped).</summary>
45 + [NotMapped] public ICollection<SettlementPlan>? SettlementPlans { get; set; }
46 +
47 + public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
48 + {
49 + if (StartDate.HasValue && EndDate.HasValue && EndDate.Value < StartDate.Value)
50 + {
51 + yield return new ValidationResult(
52 + "End date must be on or after start date.",
53 + new[] { nameof(EndDate) });
54 + }
55 + }
56 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripInvitation.cs +24 −0
@@ -0,0 +1,24 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Shared.Contracts.Users;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Trips.Domain.Entities;
8 +
9 +public class TripInvitation : BaseEntity
10 +{
11 + public Guid TripId { get; set; }
12 + public Trip? Trip { get; set; }
13 +
14 + public Guid InvitedByUserId { get; set; }
15 + [NotMapped] public UserDto? InvitedByUser { get; set; }
16 +
17 + [MaxLength(256)]
18 + public string Token { get; set; } = default!;
19 +
20 + public EInvitationStatus Status { get; set; } = EInvitationStatus.Pending;
21 +
22 + public DateTime ExpiresAt { get; set; }
23 + public DateTime? RespondedAt { get; set; }
24 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripParticipant.cs +27 −0
@@ -0,0 +1,27 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Shared.Contracts.Users;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Trips.Domain.Entities;
8 +
9 +public class TripParticipant : BaseEntity
10 +{
11 + public Guid TripId { get; set; }
12 + public Trip? Trip { get; set; }
13 +
14 + public Guid UserId { get; set; }
15 + [NotMapped] public UserDto? User { get; set; }
16 +
17 + public EParticipantRole Role { get; set; } = EParticipantRole.Participant;
18 +
19 + [MaxLength(100)]
20 + public string? Nickname { get; set; }
21 +
22 + public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
23 +
24 + public DateTime? LeftAt { get; set; }
25 +
26 + public bool IsActive { get; set; } = true;
27 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPoll.cs +26 −0
@@ -0,0 +1,26 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Shared.Contracts.Users;
4 +using SplitApp.Shared.Kernel.Domain;
5 +
6 +namespace SplitApp.Modules.Trips.Domain.Entities;
7 +
8 +public class TripPoll : BaseEntity
9 +{
10 + public Guid TripId { get; set; }
11 + public Trip? Trip { get; set; }
12 +
13 + public Guid CreatedByUserId { get; set; }
14 + [NotMapped] public UserDto? CreatedByUser { get; set; }
15 +
16 + [MaxLength(500)]
17 + public string Question { get; set; } = default!;
18 +
19 + public bool AllowMultipleVotes { get; set; }
20 +
21 + public bool IsAnonymous { get; set; }
22 +
23 + public DateTime? ClosedAt { get; set; }
24 +
25 + public ICollection<TripPollOption>? Options { get; set; }
26 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollOption.cs +17 −0
@@ -0,0 +1,17 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using SplitApp.Shared.Kernel.Domain;
3 +
4 +namespace SplitApp.Modules.Trips.Domain.Entities;
5 +
6 +public class TripPollOption : BaseEntity
7 +{
8 + public Guid PollId { get; set; }
9 + public TripPoll? Poll { get; set; }
10 +
11 + [MaxLength(300)]
12 + public string Text { get; set; } = default!;
13 +
14 + public int DisplayOrder { get; set; }
15 +
16 + public ICollection<TripPollVote>? Votes { get; set; }
17 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollVote.cs +14 −0
@@ -0,0 +1,14 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Shared.Contracts.Users;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Trips.Domain.Entities;
6 +
7 +public class TripPollVote : BaseEntity
8 +{
9 + public Guid PollOptionId { get; set; }
10 + public TripPollOption? PollOption { get; set; }
11 +
12 + public Guid UserId { get; set; }
13 + [NotMapped] public UserDto? User { get; set; }
14 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistItem.cs +40 −0
@@ -0,0 +1,40 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using System.ComponentModel.DataAnnotations.Schema;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Shared.Contracts.Users;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Trips.Domain.Entities;
8 +
9 +public class TripWishlistItem : BaseEntity
10 +{
11 + public Guid TripId { get; set; }
12 + public Trip? Trip { get; set; }
13 +
14 + public Guid AddedByUserId { get; set; }
15 + [NotMapped] public UserDto? AddedByUser { get; set; }
16 +
17 + [MaxLength(200)]
18 + public string Title { get; set; } = default!;
19 +
20 + public string? Description { get; set; }
21 +
22 + public EWishlistCategory Category { get; set; }
23 +
24 + public EWishlistPriority Priority { get; set; }
25 +
26 + public decimal? EstimatedCost { get; set; }
27 +
28 + [MaxLength(500)]
29 + public string? Url { get; set; }
30 +
31 + [MaxLength(300)]
32 + public string? Location { get; set; }
33 +
34 + public bool IsCompleted { get; set; }
35 + public DateTime? CompletedAt { get; set; }
36 +
37 + public int DisplayOrder { get; set; }
38 +
39 + public ICollection<TripWishlistVote>? Votes { get; set; }
40 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistVote.cs +16 −0
@@ -0,0 +1,16 @@
1 +using System.ComponentModel.DataAnnotations.Schema;
2 +using SplitApp.Shared.Contracts.Users;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Trips.Domain.Entities;
6 +
7 +public class TripWishlistVote : BaseEntity
8 +{
9 + public Guid WishlistItemId { get; set; }
10 + public TripWishlistItem? WishlistItem { get; set; }
11 +
12 + public Guid UserId { get; set; }
13 + [NotMapped] public UserDto? User { get; set; }
14 +
15 + public bool IsInterested { get; set; } = true;
16 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EInvitationStatus.cs +10 −0
@@ -0,0 +1,10 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum EInvitationStatus
4 +{
5 + Pending,
6 + Accepted,
7 + Declined,
8 + Expired,
9 + Revoked
10 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EParticipantRole.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum EParticipantRole
4 +{
5 + Organizer,
6 + Participant
7 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/ETripStatus.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum ETripStatus
4 +{
5 + Active,
6 + Settled,
7 + Archived,
8 + Finalizing
9 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistCategory.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum EWishlistCategory
4 +{
5 + Place,
6 + Activity,
7 + Restaurant,
8 + Other
9 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistPriority.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Modules.Trips.Domain.Enums;
2 +
3 +public enum EWishlistPriority
4 +{
5 + MustDo,
6 + NiceToHave,
7 + Optional
8 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/SplitApp.Modules.Trips.Domain.csproj +15 −0
@@ -0,0 +1,15 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
5 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
6 + <ProjectReference Include="..\..\Expenses\SplitApp.Modules.Expenses.Domain\SplitApp.Modules.Expenses.Domain.csproj" />
7 + </ItemGroup>
8 +
9 + <PropertyGroup>
10 + <TargetFramework>net10.0</TargetFramework>
11 + <ImplicitUsings>enable</ImplicitUsings>
12 + <Nullable>enable</Nullable>
13 + </PropertyGroup>
14 +
15 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetBudgetCategoryNamesByIdsHandler.cs +30 −0
@@ -0,0 +1,30 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Trips;
5 +using SplitApp.Shared.Contracts.Trips.Queries;
6 +
7 +namespace SplitApp.Modules.Trips.Infrastructure.Handlers;
8 +
9 +public class GetBudgetCategoryNamesByIdsHandler
10 + : IRequestHandler<GetBudgetCategoryNamesByIdsQuery, IReadOnlyList<BudgetCategoryNameDto>>
11 +{
12 + private readonly TripsDbContext _db;
13 +
14 + public GetBudgetCategoryNamesByIdsHandler(TripsDbContext db) => _db = db;
15 +
16 + public async Task<IReadOnlyList<BudgetCategoryNameDto>> Handle(
17 + GetBudgetCategoryNamesByIdsQuery request,
18 + CancellationToken cancellationToken)
19 + {
20 + if (request.CategoryIds.Count == 0) return Array.Empty<BudgetCategoryNameDto>();
21 + var ids = request.CategoryIds.Distinct().ToList();
22 + var rows = await _db.BudgetCategories
23 + .Where(c => ids.Contains(c.Id))
24 + .ToListAsync(cancellationToken);
25 + // LangStr.Translate() reads CurrentUICulture — gives the culture-appropriate name.
26 + return rows
27 + .Select(c => new BudgetCategoryNameDto(c.Id, c.Name.Translate() ?? c.Name.ToString()))
28 + .ToList();
29 + }
30 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetTripParticipantsHandler.cs +27 −0
@@ -0,0 +1,27 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Trips;
5 +using SplitApp.Shared.Contracts.Trips.Queries;
6 +
7 +namespace SplitApp.Modules.Trips.Application.Handlers;
8 +
9 +public class GetTripParticipantsHandler : IRequestHandler<GetTripParticipantsQuery, IReadOnlyList<TripParticipantDto>>
10 +{
11 + private readonly TripsDbContext _db;
12 +
13 + public GetTripParticipantsHandler(TripsDbContext db)
14 + {
15 + _db = db;
16 + }
17 +
18 + public async Task<IReadOnlyList<TripParticipantDto>> Handle(GetTripParticipantsQuery request, CancellationToken cancellationToken)
19 + {
20 + var rows = await _db.TripParticipants
21 + .Where(p => p.TripId == request.TripId)
22 + .ToListAsync(cancellationToken);
23 + return rows
24 + .Select(p => new TripParticipantDto(p.Id, p.TripId, p.UserId, p.Role.ToString(), p.Nickname, p.JoinedAt, p.IsActive))
25 + .ToList();
26 + }
27 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/IsTripParticipantHandler.cs +25 −0
@@ -0,0 +1,25 @@
1 +using MediatR;
2 +using Microsoft.EntityFrameworkCore;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +using SplitApp.Shared.Contracts.Trips.Queries;
5 +
6 +namespace SplitApp.Modules.Trips.Infrastructure.Handlers;
7 +
8 +public class IsTripParticipantHandler : IRequestHandler<IsTripParticipantQuery, bool>
9 +{
10 + private readonly TripsDbContext _db;
11 +
12 + public IsTripParticipantHandler(TripsDbContext db)
13 + {
14 + _db = db;
15 + }
16 +
17 + public Task<bool> Handle(IsTripParticipantQuery request, CancellationToken cancellationToken)
18 + {
19 + return _db.TripParticipants
20 + .AnyAsync(p => p.TripId == request.TripId
21 + && p.UserId == request.UserId
22 + && p.IsActive,
23 + cancellationToken);
24 + }
25 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/SettlementPlanCompletedHandler.cs +32 −0
@@ -0,0 +1,32 @@
1 +using MediatR;
2 +using SplitApp.Modules.Trips.Application.Contracts;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Shared.Contracts.Expenses.Events;
5 +
6 +namespace SplitApp.Modules.Trips.Infrastructure.Handlers;
7 +
8 +/// <summary>
9 +/// When the Expenses module finishes settling every payment in a plan it publishes
10 +/// <see cref="SettlementPlanCompletedEvent"/>. The Trips module owns trip lifecycle,
11 +/// so it advances trips that were "Finalizing" into "Settled".
12 +/// </summary>
13 +public class SettlementPlanCompletedHandler : INotificationHandler<SettlementPlanCompletedEvent>
14 +{
15 + private readonly ITripsUnitOfWork _uow;
16 +
17 + public SettlementPlanCompletedHandler(ITripsUnitOfWork uow)
18 + {
19 + _uow = uow;
20 + }
21 +
22 + public async Task Handle(SettlementPlanCompletedEvent notification, CancellationToken cancellationToken)
23 + {
24 + var trip = await _uow.Trips.GetByIdAsync(notification.TripId);
25 + if (trip == null) return;
26 + if (trip.Status != ETripStatus.Finalizing) return;
27 +
28 + trip.Status = ETripStatus.Settled;
29 + _uow.Trips.Update(trip);
30 + await _uow.SaveChangesAsync();
31 + }
32 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.Designer.cs +495 −0
@@ -0,0 +1,495 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Migrations;
6 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
7 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
8 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
9 +
10 +#nullable disable
11 +
12 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence.Migrations
13 +{
14 + [DbContext(typeof(TripsDbContext))]
15 + [Migration("20260430134536_Init")]
16 + partial class Init
17 + {
18 + /// <inheritdoc />
19 + protected override void BuildTargetModel(ModelBuilder modelBuilder)
20 + {
21 +#pragma warning disable 612, 618
22 + modelBuilder
23 + .HasDefaultSchema("trips")
24 + .HasAnnotation("ProductVersion", "10.0.5")
25 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
26 +
27 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
28 +
29 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.BudgetCategory", b =>
30 + {
31 + b.Property<Guid>("Id")
32 + .ValueGeneratedOnAdd()
33 + .HasColumnType("uuid");
34 +
35 + b.Property<DateTime>("CreatedAt")
36 + .HasColumnType("timestamp with time zone");
37 +
38 + b.Property<int>("DisplayOrder")
39 + .HasColumnType("integer");
40 +
41 + b.Property<string>("IconName")
42 + .HasMaxLength(100)
43 + .HasColumnType("character varying(100)");
44 +
45 + b.Property<string>("Name")
46 + .IsRequired()
47 + .HasMaxLength(1024)
48 + .HasColumnType("character varying(1024)");
49 +
50 + b.Property<decimal?>("PlannedAmount")
51 + .HasColumnType("numeric");
52 +
53 + b.Property<Guid>("TripId")
54 + .HasColumnType("uuid");
55 +
56 + b.Property<DateTime>("UpdatedAt")
57 + .HasColumnType("timestamp with time zone");
58 +
59 + b.HasKey("Id");
60 +
61 + b.HasIndex("TripId");
62 +
63 + b.ToTable("BudgetCategories", "trips");
64 + });
65 +
66 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.Trip", b =>
67 + {
68 + b.Property<Guid>("Id")
69 + .ValueGeneratedOnAdd()
70 + .HasColumnType("uuid");
71 +
72 + b.Property<DateTime>("CreatedAt")
73 + .HasColumnType("timestamp with time zone");
74 +
75 + b.Property<Guid>("CreatedById")
76 + .HasColumnType("uuid");
77 +
78 + b.Property<Guid>("DefaultCurrencyId")
79 + .HasColumnType("uuid");
80 +
81 + b.Property<string>("Description")
82 + .HasColumnType("text");
83 +
84 + b.Property<string>("Destination")
85 + .HasMaxLength(200)
86 + .HasColumnType("character varying(200)");
87 +
88 + b.Property<DateTime?>("EndDate")
89 + .HasColumnType("timestamp with time zone");
90 +
91 + b.Property<string>("Name")
92 + .IsRequired()
93 + .HasMaxLength(200)
94 + .HasColumnType("character varying(200)");
95 +
96 + b.Property<DateTime?>("StartDate")
97 + .HasColumnType("timestamp with time zone");
98 +
99 + b.Property<int>("Status")
100 + .HasColumnType("integer");
101 +
102 + b.Property<DateTime>("UpdatedAt")
103 + .HasColumnType("timestamp with time zone");
104 +
105 + b.HasKey("Id");
106 +
107 + b.ToTable("Trips", "trips");
108 + });
109 +
110 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripInvitation", b =>
111 + {
112 + b.Property<Guid>("Id")
113 + .ValueGeneratedOnAdd()
114 + .HasColumnType("uuid");
115 +
116 + b.Property<DateTime>("CreatedAt")
117 + .HasColumnType("timestamp with time zone");
118 +
119 + b.Property<DateTime>("ExpiresAt")
120 + .HasColumnType("timestamp with time zone");
121 +
122 + b.Property<Guid>("InvitedByUserId")
123 + .HasColumnType("uuid");
124 +
125 + b.Property<DateTime?>("RespondedAt")
126 + .HasColumnType("timestamp with time zone");
127 +
128 + b.Property<int>("Status")
129 + .HasColumnType("integer");
130 +
131 + b.Property<string>("Token")
132 + .IsRequired()
133 + .HasMaxLength(256)
134 + .HasColumnType("character varying(256)");
135 +
136 + b.Property<Guid>("TripId")
137 + .HasColumnType("uuid");
138 +
139 + b.Property<DateTime>("UpdatedAt")
140 + .HasColumnType("timestamp with time zone");
141 +
142 + b.HasKey("Id");
143 +
144 + b.HasIndex("Token")
145 + .IsUnique();
146 +
147 + b.HasIndex("TripId");
148 +
149 + b.ToTable("TripInvitations", "trips");
150 + });
151 +
152 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripParticipant", b =>
153 + {
154 + b.Property<Guid>("Id")
155 + .ValueGeneratedOnAdd()
156 + .HasColumnType("uuid");
157 +
158 + b.Property<DateTime>("CreatedAt")
159 + .HasColumnType("timestamp with time zone");
160 +
161 + b.Property<bool>("IsActive")
162 + .HasColumnType("boolean");
163 +
164 + b.Property<DateTime>("JoinedAt")
165 + .HasColumnType("timestamp with time zone");
166 +
167 + b.Property<DateTime?>("LeftAt")
168 + .HasColumnType("timestamp with time zone");
169 +
170 + b.Property<string>("Nickname")
171 + .HasMaxLength(100)
172 + .HasColumnType("character varying(100)");
173 +
174 + b.Property<int>("Role")
175 + .HasColumnType("integer");
176 +
177 + b.Property<Guid>("TripId")
178 + .HasColumnType("uuid");
179 +
180 + b.Property<DateTime>("UpdatedAt")
181 + .HasColumnType("timestamp with time zone");
182 +
183 + b.Property<Guid>("UserId")
184 + .HasColumnType("uuid");
185 +
186 + b.HasKey("Id");
187 +
188 + b.HasIndex("TripId", "UserId")
189 + .IsUnique();
190 +
191 + b.ToTable("TripParticipants", "trips");
192 + });
193 +
194 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
195 + {
196 + b.Property<Guid>("Id")
197 + .ValueGeneratedOnAdd()
198 + .HasColumnType("uuid");
199 +
200 + b.Property<bool>("AllowMultipleVotes")
201 + .HasColumnType("boolean");
202 +
203 + b.Property<DateTime?>("ClosedAt")
204 + .HasColumnType("timestamp with time zone");
205 +
206 + b.Property<DateTime>("CreatedAt")
207 + .HasColumnType("timestamp with time zone");
208 +
209 + b.Property<Guid>("CreatedByUserId")
210 + .HasColumnType("uuid");
211 +
212 + b.Property<bool>("IsAnonymous")
213 + .HasColumnType("boolean");
214 +
215 + b.Property<string>("Question")
216 + .IsRequired()
217 + .HasMaxLength(500)
218 + .HasColumnType("character varying(500)");
219 +
220 + b.Property<Guid>("TripId")
221 + .HasColumnType("uuid");
222 +
223 + b.Property<DateTime>("UpdatedAt")
224 + .HasColumnType("timestamp with time zone");
225 +
226 + b.HasKey("Id");
227 +
228 + b.HasIndex("TripId");
229 +
230 + b.ToTable("TripPolls", "trips");
231 + });
232 +
233 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
234 + {
235 + b.Property<Guid>("Id")
236 + .ValueGeneratedOnAdd()
237 + .HasColumnType("uuid");
238 +
239 + b.Property<DateTime>("CreatedAt")
240 + .HasColumnType("timestamp with time zone");
241 +
242 + b.Property<int>("DisplayOrder")
243 + .HasColumnType("integer");
244 +
245 + b.Property<Guid>("PollId")
246 + .HasColumnType("uuid");
247 +
248 + b.Property<string>("Text")
249 + .IsRequired()
250 + .HasMaxLength(300)
251 + .HasColumnType("character varying(300)");
252 +
253 + b.Property<DateTime>("UpdatedAt")
254 + .HasColumnType("timestamp with time zone");
255 +
256 + b.HasKey("Id");
257 +
258 + b.HasIndex("PollId");
259 +
260 + b.ToTable("TripPollOptions", "trips");
261 + });
262 +
263 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollVote", b =>
264 + {
265 + b.Property<Guid>("Id")
266 + .ValueGeneratedOnAdd()
267 + .HasColumnType("uuid");
268 +
269 + b.Property<DateTime>("CreatedAt")
270 + .HasColumnType("timestamp with time zone");
271 +
272 + b.Property<Guid>("PollOptionId")
273 + .HasColumnType("uuid");
274 +
275 + b.Property<DateTime>("UpdatedAt")
276 + .HasColumnType("timestamp with time zone");
277 +
278 + b.Property<Guid>("UserId")
279 + .HasColumnType("uuid");
280 +
281 + b.HasKey("Id");
282 +
283 + b.HasIndex("PollOptionId", "UserId")
284 + .IsUnique();
285 +
286 + b.ToTable("TripPollVotes", "trips");
287 + });
288 +
289 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
290 + {
291 + b.Property<Guid>("Id")
292 + .ValueGeneratedOnAdd()
293 + .HasColumnType("uuid");
294 +
295 + b.Property<Guid>("AddedByUserId")
296 + .HasColumnType("uuid");
297 +
298 + b.Property<int>("Category")
299 + .HasColumnType("integer");
300 +
301 + b.Property<DateTime?>("CompletedAt")
302 + .HasColumnType("timestamp with time zone");
303 +
304 + b.Property<DateTime>("CreatedAt")
305 + .HasColumnType("timestamp with time zone");
306 +
307 + b.Property<string>("Description")
308 + .HasColumnType("text");
309 +
310 + b.Property<int>("DisplayOrder")
311 + .HasColumnType("integer");
312 +
313 + b.Property<decimal?>("EstimatedCost")
314 + .HasColumnType("numeric");
315 +
316 + b.Property<bool>("IsCompleted")
317 + .HasColumnType("boolean");
318 +
319 + b.Property<string>("Location")
320 + .HasMaxLength(300)
321 + .HasColumnType("character varying(300)");
322 +
323 + b.Property<int>("Priority")
324 + .HasColumnType("integer");
325 +
326 + b.Property<string>("Title")
327 + .IsRequired()
328 + .HasMaxLength(200)
329 + .HasColumnType("character varying(200)");
330 +
331 + b.Property<Guid>("TripId")
332 + .HasColumnType("uuid");
333 +
334 + b.Property<DateTime>("UpdatedAt")
335 + .HasColumnType("timestamp with time zone");
336 +
337 + b.Property<string>("Url")
338 + .HasMaxLength(500)
339 + .HasColumnType("character varying(500)");
340 +
341 + b.HasKey("Id");
342 +
343 + b.HasIndex("TripId");
344 +
345 + b.ToTable("TripWishlistItems", "trips");
346 + });
347 +
348 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistVote", b =>
349 + {
350 + b.Property<Guid>("Id")
351 + .ValueGeneratedOnAdd()
352 + .HasColumnType("uuid");
353 +
354 + b.Property<DateTime>("CreatedAt")
355 + .HasColumnType("timestamp with time zone");
356 +
357 + b.Property<bool>("IsInterested")
358 + .HasColumnType("boolean");
359 +
360 + b.Property<DateTime>("UpdatedAt")
361 + .HasColumnType("timestamp with time zone");
362 +
363 + b.Property<Guid>("UserId")
364 + .HasColumnType("uuid");
365 +
366 + b.Property<Guid>("WishlistItemId")
367 + .HasColumnType("uuid");
368 +
369 + b.HasKey("Id");
370 +
371 + b.HasIndex("WishlistItemId", "UserId")
372 + .IsUnique();
373 +
374 + b.ToTable("TripWishlistVotes", "trips");
375 + });
376 +
377 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.BudgetCategory", b =>
378 + {
379 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
380 + .WithMany("BudgetCategories")
381 + .HasForeignKey("TripId")
382 + .OnDelete(DeleteBehavior.Restrict)
383 + .IsRequired();
384 +
385 + b.Navigation("Trip");
386 + });
387 +
388 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripInvitation", b =>
389 + {
390 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
391 + .WithMany("Invitations")
392 + .HasForeignKey("TripId")
393 + .OnDelete(DeleteBehavior.Restrict)
394 + .IsRequired();
395 +
396 + b.Navigation("Trip");
397 + });
398 +
399 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripParticipant", b =>
400 + {
401 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
402 + .WithMany("Participants")
403 + .HasForeignKey("TripId")
404 + .OnDelete(DeleteBehavior.Restrict)
405 + .IsRequired();
406 +
407 + b.Navigation("Trip");
408 + });
409 +
410 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
411 + {
412 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
413 + .WithMany("Polls")
414 + .HasForeignKey("TripId")
415 + .OnDelete(DeleteBehavior.Restrict)
416 + .IsRequired();
417 +
418 + b.Navigation("Trip");
419 + });
420 +
421 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
422 + {
423 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripPoll", "Poll")
424 + .WithMany("Options")
425 + .HasForeignKey("PollId")
426 + .OnDelete(DeleteBehavior.Restrict)
427 + .IsRequired();
428 +
429 + b.Navigation("Poll");
430 + });
431 +
432 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollVote", b =>
433 + {
434 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", "PollOption")
435 + .WithMany("Votes")
436 + .HasForeignKey("PollOptionId")
437 + .OnDelete(DeleteBehavior.Restrict)
438 + .IsRequired();
439 +
440 + b.Navigation("PollOption");
441 + });
442 +
443 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
444 + {
445 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
446 + .WithMany("WishlistItems")
447 + .HasForeignKey("TripId")
448 + .OnDelete(DeleteBehavior.Restrict)
449 + .IsRequired();
450 +
451 + b.Navigation("Trip");
452 + });
453 +
454 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistVote", b =>
455 + {
456 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", "WishlistItem")
457 + .WithMany("Votes")
458 + .HasForeignKey("WishlistItemId")
459 + .OnDelete(DeleteBehavior.Restrict)
460 + .IsRequired();
461 +
462 + b.Navigation("WishlistItem");
463 + });
464 +
465 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.Trip", b =>
466 + {
467 + b.Navigation("BudgetCategories");
468 +
469 + b.Navigation("Invitations");
470 +
471 + b.Navigation("Participants");
472 +
473 + b.Navigation("Polls");
474 +
475 + b.Navigation("WishlistItems");
476 + });
477 +
478 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
479 + {
480 + b.Navigation("Options");
481 + });
482 +
483 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
484 + {
485 + b.Navigation("Votes");
486 + });
487 +
488 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
489 + {
490 + b.Navigation("Votes");
491 + });
492 +#pragma warning restore 612, 618
493 + }
494 + }
495 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.cs +350 −0
@@ -0,0 +1,350 @@
1 +using System;
2 +using Microsoft.EntityFrameworkCore.Migrations;
3 +
4 +#nullable disable
5 +
6 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence.Migrations
7 +{
8 + /// <inheritdoc />
9 + public partial class Init : Migration
10 + {
11 + /// <inheritdoc />
12 + protected override void Up(MigrationBuilder migrationBuilder)
13 + {
14 + migrationBuilder.EnsureSchema(
15 + name: "trips");
16 +
17 + migrationBuilder.CreateTable(
18 + name: "Trips",
19 + schema: "trips",
20 + columns: table => new
21 + {
22 + Id = table.Column<Guid>(type: "uuid", nullable: false),
23 + Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
24 + Description = table.Column<string>(type: "text", nullable: true),
25 + Destination = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
26 + StartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
27 + EndDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
28 + Status = table.Column<int>(type: "integer", nullable: false),
29 + DefaultCurrencyId = table.Column<Guid>(type: "uuid", nullable: false),
30 + CreatedById = table.Column<Guid>(type: "uuid", nullable: false),
31 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
32 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
33 + },
34 + constraints: table =>
35 + {
36 + table.PrimaryKey("PK_Trips", x => x.Id);
37 + });
38 +
39 + migrationBuilder.CreateTable(
40 + name: "BudgetCategories",
41 + schema: "trips",
42 + columns: table => new
43 + {
44 + Id = table.Column<Guid>(type: "uuid", nullable: false),
45 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
46 + Name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
47 + IconName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
48 + PlannedAmount = table.Column<decimal>(type: "numeric", nullable: true),
49 + DisplayOrder = table.Column<int>(type: "integer", nullable: false),
50 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
51 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
52 + },
53 + constraints: table =>
54 + {
55 + table.PrimaryKey("PK_BudgetCategories", x => x.Id);
56 + table.ForeignKey(
57 + name: "FK_BudgetCategories_Trips_TripId",
58 + column: x => x.TripId,
59 + principalSchema: "trips",
60 + principalTable: "Trips",
61 + principalColumn: "Id",
62 + onDelete: ReferentialAction.Restrict);
63 + });
64 +
65 + migrationBuilder.CreateTable(
66 + name: "TripInvitations",
67 + schema: "trips",
68 + columns: table => new
69 + {
70 + Id = table.Column<Guid>(type: "uuid", nullable: false),
71 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
72 + InvitedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
73 + Token = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
74 + Status = table.Column<int>(type: "integer", nullable: false),
75 + ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
76 + RespondedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
77 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
78 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
79 + },
80 + constraints: table =>
81 + {
82 + table.PrimaryKey("PK_TripInvitations", x => x.Id);
83 + table.ForeignKey(
84 + name: "FK_TripInvitations_Trips_TripId",
85 + column: x => x.TripId,
86 + principalSchema: "trips",
87 + principalTable: "Trips",
88 + principalColumn: "Id",
89 + onDelete: ReferentialAction.Restrict);
90 + });
91 +
92 + migrationBuilder.CreateTable(
93 + name: "TripParticipants",
94 + schema: "trips",
95 + columns: table => new
96 + {
97 + Id = table.Column<Guid>(type: "uuid", nullable: false),
98 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
99 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
100 + Role = table.Column<int>(type: "integer", nullable: false),
101 + Nickname = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
102 + JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
103 + LeftAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
104 + IsActive = table.Column<bool>(type: "boolean", nullable: false),
105 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
106 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
107 + },
108 + constraints: table =>
109 + {
110 + table.PrimaryKey("PK_TripParticipants", x => x.Id);
111 + table.ForeignKey(
112 + name: "FK_TripParticipants_Trips_TripId",
113 + column: x => x.TripId,
114 + principalSchema: "trips",
115 + principalTable: "Trips",
116 + principalColumn: "Id",
117 + onDelete: ReferentialAction.Restrict);
118 + });
119 +
120 + migrationBuilder.CreateTable(
121 + name: "TripPolls",
122 + schema: "trips",
123 + columns: table => new
124 + {
125 + Id = table.Column<Guid>(type: "uuid", nullable: false),
126 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
127 + CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
128 + Question = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
129 + AllowMultipleVotes = table.Column<bool>(type: "boolean", nullable: false),
130 + IsAnonymous = table.Column<bool>(type: "boolean", nullable: false),
131 + ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
132 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
133 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
134 + },
135 + constraints: table =>
136 + {
137 + table.PrimaryKey("PK_TripPolls", x => x.Id);
138 + table.ForeignKey(
139 + name: "FK_TripPolls_Trips_TripId",
140 + column: x => x.TripId,
141 + principalSchema: "trips",
142 + principalTable: "Trips",
143 + principalColumn: "Id",
144 + onDelete: ReferentialAction.Restrict);
145 + });
146 +
147 + migrationBuilder.CreateTable(
148 + name: "TripWishlistItems",
149 + schema: "trips",
150 + columns: table => new
151 + {
152 + Id = table.Column<Guid>(type: "uuid", nullable: false),
153 + TripId = table.Column<Guid>(type: "uuid", nullable: false),
154 + AddedByUserId = table.Column<Guid>(type: "uuid", nullable: false),
155 + Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
156 + Description = table.Column<string>(type: "text", nullable: true),
157 + Category = table.Column<int>(type: "integer", nullable: false),
158 + Priority = table.Column<int>(type: "integer", nullable: false),
159 + EstimatedCost = table.Column<decimal>(type: "numeric", nullable: true),
160 + Url = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
161 + Location = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
162 + IsCompleted = table.Column<bool>(type: "boolean", nullable: false),
163 + CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
164 + DisplayOrder = table.Column<int>(type: "integer", nullable: false),
165 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
166 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
167 + },
168 + constraints: table =>
169 + {
170 + table.PrimaryKey("PK_TripWishlistItems", x => x.Id);
171 + table.ForeignKey(
172 + name: "FK_TripWishlistItems_Trips_TripId",
173 + column: x => x.TripId,
174 + principalSchema: "trips",
175 + principalTable: "Trips",
176 + principalColumn: "Id",
177 + onDelete: ReferentialAction.Restrict);
178 + });
179 +
180 + migrationBuilder.CreateTable(
181 + name: "TripPollOptions",
182 + schema: "trips",
183 + columns: table => new
184 + {
185 + Id = table.Column<Guid>(type: "uuid", nullable: false),
186 + PollId = table.Column<Guid>(type: "uuid", nullable: false),
187 + Text = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
188 + DisplayOrder = table.Column<int>(type: "integer", nullable: false),
189 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
190 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
191 + },
192 + constraints: table =>
193 + {
194 + table.PrimaryKey("PK_TripPollOptions", x => x.Id);
195 + table.ForeignKey(
196 + name: "FK_TripPollOptions_TripPolls_PollId",
197 + column: x => x.PollId,
198 + principalSchema: "trips",
199 + principalTable: "TripPolls",
200 + principalColumn: "Id",
201 + onDelete: ReferentialAction.Restrict);
202 + });
203 +
204 + migrationBuilder.CreateTable(
205 + name: "TripWishlistVotes",
206 + schema: "trips",
207 + columns: table => new
208 + {
209 + Id = table.Column<Guid>(type: "uuid", nullable: false),
210 + WishlistItemId = table.Column<Guid>(type: "uuid", nullable: false),
211 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
212 + IsInterested = table.Column<bool>(type: "boolean", nullable: false),
213 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
214 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
215 + },
216 + constraints: table =>
217 + {
218 + table.PrimaryKey("PK_TripWishlistVotes", x => x.Id);
219 + table.ForeignKey(
220 + name: "FK_TripWishlistVotes_TripWishlistItems_WishlistItemId",
221 + column: x => x.WishlistItemId,
222 + principalSchema: "trips",
223 + principalTable: "TripWishlistItems",
224 + principalColumn: "Id",
225 + onDelete: ReferentialAction.Restrict);
226 + });
227 +
228 + migrationBuilder.CreateTable(
229 + name: "TripPollVotes",
230 + schema: "trips",
231 + columns: table => new
232 + {
233 + Id = table.Column<Guid>(type: "uuid", nullable: false),
234 + PollOptionId = table.Column<Guid>(type: "uuid", nullable: false),
235 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
236 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
237 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
238 + },
239 + constraints: table =>
240 + {
241 + table.PrimaryKey("PK_TripPollVotes", x => x.Id);
242 + table.ForeignKey(
243 + name: "FK_TripPollVotes_TripPollOptions_PollOptionId",
244 + column: x => x.PollOptionId,
245 + principalSchema: "trips",
246 + principalTable: "TripPollOptions",
247 + principalColumn: "Id",
248 + onDelete: ReferentialAction.Restrict);
249 + });
250 +
251 + migrationBuilder.CreateIndex(
252 + name: "IX_BudgetCategories_TripId",
253 + schema: "trips",
254 + table: "BudgetCategories",
255 + column: "TripId");
256 +
257 + migrationBuilder.CreateIndex(
258 + name: "IX_TripInvitations_Token",
259 + schema: "trips",
260 + table: "TripInvitations",
261 + column: "Token",
262 + unique: true);
263 +
264 + migrationBuilder.CreateIndex(
265 + name: "IX_TripInvitations_TripId",
266 + schema: "trips",
267 + table: "TripInvitations",
268 + column: "TripId");
269 +
270 + migrationBuilder.CreateIndex(
271 + name: "IX_TripParticipants_TripId_UserId",
272 + schema: "trips",
273 + table: "TripParticipants",
274 + columns: new[] { "TripId", "UserId" },
275 + unique: true);
276 +
277 + migrationBuilder.CreateIndex(
278 + name: "IX_TripPollOptions_PollId",
279 + schema: "trips",
280 + table: "TripPollOptions",
281 + column: "PollId");
282 +
283 + migrationBuilder.CreateIndex(
284 + name: "IX_TripPolls_TripId",
285 + schema: "trips",
286 + table: "TripPolls",
287 + column: "TripId");
288 +
289 + migrationBuilder.CreateIndex(
290 + name: "IX_TripPollVotes_PollOptionId_UserId",
291 + schema: "trips",
292 + table: "TripPollVotes",
293 + columns: new[] { "PollOptionId", "UserId" },
294 + unique: true);
295 +
296 + migrationBuilder.CreateIndex(
297 + name: "IX_TripWishlistItems_TripId",
298 + schema: "trips",
299 + table: "TripWishlistItems",
300 + column: "TripId");
301 +
302 + migrationBuilder.CreateIndex(
303 + name: "IX_TripWishlistVotes_WishlistItemId_UserId",
304 + schema: "trips",
305 + table: "TripWishlistVotes",
306 + columns: new[] { "WishlistItemId", "UserId" },
307 + unique: true);
308 + }
309 +
310 + /// <inheritdoc />
311 + protected override void Down(MigrationBuilder migrationBuilder)
312 + {
313 + migrationBuilder.DropTable(
314 + name: "BudgetCategories",
315 + schema: "trips");
316 +
317 + migrationBuilder.DropTable(
318 + name: "TripInvitations",
319 + schema: "trips");
320 +
321 + migrationBuilder.DropTable(
322 + name: "TripParticipants",
323 + schema: "trips");
324 +
325 + migrationBuilder.DropTable(
326 + name: "TripPollVotes",
327 + schema: "trips");
328 +
329 + migrationBuilder.DropTable(
330 + name: "TripWishlistVotes",
331 + schema: "trips");
332 +
333 + migrationBuilder.DropTable(
334 + name: "TripPollOptions",
335 + schema: "trips");
336 +
337 + migrationBuilder.DropTable(
338 + name: "TripWishlistItems",
339 + schema: "trips");
340 +
341 + migrationBuilder.DropTable(
342 + name: "TripPolls",
343 + schema: "trips");
344 +
345 + migrationBuilder.DropTable(
346 + name: "Trips",
347 + schema: "trips");
348 + }
349 + }
350 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/TripsDbContextModelSnapshot.cs +492 −0
@@ -0,0 +1,492 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
6 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
7 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
8 +
9 +#nullable disable
10 +
11 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence.Migrations
12 +{
13 + [DbContext(typeof(TripsDbContext))]
14 + partial class TripsDbContextModelSnapshot : ModelSnapshot
15 + {
16 + protected override void BuildModel(ModelBuilder modelBuilder)
17 + {
18 +#pragma warning disable 612, 618
19 + modelBuilder
20 + .HasDefaultSchema("trips")
21 + .HasAnnotation("ProductVersion", "10.0.5")
22 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
23 +
24 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
25 +
26 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.BudgetCategory", b =>
27 + {
28 + b.Property<Guid>("Id")
29 + .ValueGeneratedOnAdd()
30 + .HasColumnType("uuid");
31 +
32 + b.Property<DateTime>("CreatedAt")
33 + .HasColumnType("timestamp with time zone");
34 +
35 + b.Property<int>("DisplayOrder")
36 + .HasColumnType("integer");
37 +
38 + b.Property<string>("IconName")
39 + .HasMaxLength(100)
40 + .HasColumnType("character varying(100)");
41 +
42 + b.Property<string>("Name")
43 + .IsRequired()
44 + .HasMaxLength(1024)
45 + .HasColumnType("character varying(1024)");
46 +
47 + b.Property<decimal?>("PlannedAmount")
48 + .HasColumnType("numeric");
49 +
50 + b.Property<Guid>("TripId")
51 + .HasColumnType("uuid");
52 +
53 + b.Property<DateTime>("UpdatedAt")
54 + .HasColumnType("timestamp with time zone");
55 +
56 + b.HasKey("Id");
57 +
58 + b.HasIndex("TripId");
59 +
60 + b.ToTable("BudgetCategories", "trips");
61 + });
62 +
63 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.Trip", b =>
64 + {
65 + b.Property<Guid>("Id")
66 + .ValueGeneratedOnAdd()
67 + .HasColumnType("uuid");
68 +
69 + b.Property<DateTime>("CreatedAt")
70 + .HasColumnType("timestamp with time zone");
71 +
72 + b.Property<Guid>("CreatedById")
73 + .HasColumnType("uuid");
74 +
75 + b.Property<Guid>("DefaultCurrencyId")
76 + .HasColumnType("uuid");
77 +
78 + b.Property<string>("Description")
79 + .HasColumnType("text");
80 +
81 + b.Property<string>("Destination")
82 + .HasMaxLength(200)
83 + .HasColumnType("character varying(200)");
84 +
85 + b.Property<DateTime?>("EndDate")
86 + .HasColumnType("timestamp with time zone");
87 +
88 + b.Property<string>("Name")
89 + .IsRequired()
90 + .HasMaxLength(200)
91 + .HasColumnType("character varying(200)");
92 +
93 + b.Property<DateTime?>("StartDate")
94 + .HasColumnType("timestamp with time zone");
95 +
96 + b.Property<int>("Status")
97 + .HasColumnType("integer");
98 +
99 + b.Property<DateTime>("UpdatedAt")
100 + .HasColumnType("timestamp with time zone");
101 +
102 + b.HasKey("Id");
103 +
104 + b.ToTable("Trips", "trips");
105 + });
106 +
107 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripInvitation", b =>
108 + {
109 + b.Property<Guid>("Id")
110 + .ValueGeneratedOnAdd()
111 + .HasColumnType("uuid");
112 +
113 + b.Property<DateTime>("CreatedAt")
114 + .HasColumnType("timestamp with time zone");
115 +
116 + b.Property<DateTime>("ExpiresAt")
117 + .HasColumnType("timestamp with time zone");
118 +
119 + b.Property<Guid>("InvitedByUserId")
120 + .HasColumnType("uuid");
121 +
122 + b.Property<DateTime?>("RespondedAt")
123 + .HasColumnType("timestamp with time zone");
124 +
125 + b.Property<int>("Status")
126 + .HasColumnType("integer");
127 +
128 + b.Property<string>("Token")
129 + .IsRequired()
130 + .HasMaxLength(256)
131 + .HasColumnType("character varying(256)");
132 +
133 + b.Property<Guid>("TripId")
134 + .HasColumnType("uuid");
135 +
136 + b.Property<DateTime>("UpdatedAt")
137 + .HasColumnType("timestamp with time zone");
138 +
139 + b.HasKey("Id");
140 +
141 + b.HasIndex("Token")
142 + .IsUnique();
143 +
144 + b.HasIndex("TripId");
145 +
146 + b.ToTable("TripInvitations", "trips");
147 + });
148 +
149 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripParticipant", b =>
150 + {
151 + b.Property<Guid>("Id")
152 + .ValueGeneratedOnAdd()
153 + .HasColumnType("uuid");
154 +
155 + b.Property<DateTime>("CreatedAt")
156 + .HasColumnType("timestamp with time zone");
157 +
158 + b.Property<bool>("IsActive")
159 + .HasColumnType("boolean");
160 +
161 + b.Property<DateTime>("JoinedAt")
162 + .HasColumnType("timestamp with time zone");
163 +
164 + b.Property<DateTime?>("LeftAt")
165 + .HasColumnType("timestamp with time zone");
166 +
167 + b.Property<string>("Nickname")
168 + .HasMaxLength(100)
169 + .HasColumnType("character varying(100)");
170 +
171 + b.Property<int>("Role")
172 + .HasColumnType("integer");
173 +
174 + b.Property<Guid>("TripId")
175 + .HasColumnType("uuid");
176 +
177 + b.Property<DateTime>("UpdatedAt")
178 + .HasColumnType("timestamp with time zone");
179 +
180 + b.Property<Guid>("UserId")
181 + .HasColumnType("uuid");
182 +
183 + b.HasKey("Id");
184 +
185 + b.HasIndex("TripId", "UserId")
186 + .IsUnique();
187 +
188 + b.ToTable("TripParticipants", "trips");
189 + });
190 +
191 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
192 + {
193 + b.Property<Guid>("Id")
194 + .ValueGeneratedOnAdd()
195 + .HasColumnType("uuid");
196 +
197 + b.Property<bool>("AllowMultipleVotes")
198 + .HasColumnType("boolean");
199 +
200 + b.Property<DateTime?>("ClosedAt")
201 + .HasColumnType("timestamp with time zone");
202 +
203 + b.Property<DateTime>("CreatedAt")
204 + .HasColumnType("timestamp with time zone");
205 +
206 + b.Property<Guid>("CreatedByUserId")
207 + .HasColumnType("uuid");
208 +
209 + b.Property<bool>("IsAnonymous")
210 + .HasColumnType("boolean");
211 +
212 + b.Property<string>("Question")
213 + .IsRequired()
214 + .HasMaxLength(500)
215 + .HasColumnType("character varying(500)");
216 +
217 + b.Property<Guid>("TripId")
218 + .HasColumnType("uuid");
219 +
220 + b.Property<DateTime>("UpdatedAt")
221 + .HasColumnType("timestamp with time zone");
222 +
223 + b.HasKey("Id");
224 +
225 + b.HasIndex("TripId");
226 +
227 + b.ToTable("TripPolls", "trips");
228 + });
229 +
230 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
231 + {
232 + b.Property<Guid>("Id")
233 + .ValueGeneratedOnAdd()
234 + .HasColumnType("uuid");
235 +
236 + b.Property<DateTime>("CreatedAt")
237 + .HasColumnType("timestamp with time zone");
238 +
239 + b.Property<int>("DisplayOrder")
240 + .HasColumnType("integer");
241 +
242 + b.Property<Guid>("PollId")
243 + .HasColumnType("uuid");
244 +
245 + b.Property<string>("Text")
246 + .IsRequired()
247 + .HasMaxLength(300)
248 + .HasColumnType("character varying(300)");
249 +
250 + b.Property<DateTime>("UpdatedAt")
251 + .HasColumnType("timestamp with time zone");
252 +
253 + b.HasKey("Id");
254 +
255 + b.HasIndex("PollId");
256 +
257 + b.ToTable("TripPollOptions", "trips");
258 + });
259 +
260 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollVote", b =>
261 + {
262 + b.Property<Guid>("Id")
263 + .ValueGeneratedOnAdd()
264 + .HasColumnType("uuid");
265 +
266 + b.Property<DateTime>("CreatedAt")
267 + .HasColumnType("timestamp with time zone");
268 +
269 + b.Property<Guid>("PollOptionId")
270 + .HasColumnType("uuid");
271 +
272 + b.Property<DateTime>("UpdatedAt")
273 + .HasColumnType("timestamp with time zone");
274 +
275 + b.Property<Guid>("UserId")
276 + .HasColumnType("uuid");
277 +
278 + b.HasKey("Id");
279 +
280 + b.HasIndex("PollOptionId", "UserId")
281 + .IsUnique();
282 +
283 + b.ToTable("TripPollVotes", "trips");
284 + });
285 +
286 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
287 + {
288 + b.Property<Guid>("Id")
289 + .ValueGeneratedOnAdd()
290 + .HasColumnType("uuid");
291 +
292 + b.Property<Guid>("AddedByUserId")
293 + .HasColumnType("uuid");
294 +
295 + b.Property<int>("Category")
296 + .HasColumnType("integer");
297 +
298 + b.Property<DateTime?>("CompletedAt")
299 + .HasColumnType("timestamp with time zone");
300 +
301 + b.Property<DateTime>("CreatedAt")
302 + .HasColumnType("timestamp with time zone");
303 +
304 + b.Property<string>("Description")
305 + .HasColumnType("text");
306 +
307 + b.Property<int>("DisplayOrder")
308 + .HasColumnType("integer");
309 +
310 + b.Property<decimal?>("EstimatedCost")
311 + .HasColumnType("numeric");
312 +
313 + b.Property<bool>("IsCompleted")
314 + .HasColumnType("boolean");
315 +
316 + b.Property<string>("Location")
317 + .HasMaxLength(300)
318 + .HasColumnType("character varying(300)");
319 +
320 + b.Property<int>("Priority")
321 + .HasColumnType("integer");
322 +
323 + b.Property<string>("Title")
324 + .IsRequired()
325 + .HasMaxLength(200)
326 + .HasColumnType("character varying(200)");
327 +
328 + b.Property<Guid>("TripId")
329 + .HasColumnType("uuid");
330 +
331 + b.Property<DateTime>("UpdatedAt")
332 + .HasColumnType("timestamp with time zone");
333 +
334 + b.Property<string>("Url")
335 + .HasMaxLength(500)
336 + .HasColumnType("character varying(500)");
337 +
338 + b.HasKey("Id");
339 +
340 + b.HasIndex("TripId");
341 +
342 + b.ToTable("TripWishlistItems", "trips");
343 + });
344 +
345 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistVote", b =>
346 + {
347 + b.Property<Guid>("Id")
348 + .ValueGeneratedOnAdd()
349 + .HasColumnType("uuid");
350 +
351 + b.Property<DateTime>("CreatedAt")
352 + .HasColumnType("timestamp with time zone");
353 +
354 + b.Property<bool>("IsInterested")
355 + .HasColumnType("boolean");
356 +
357 + b.Property<DateTime>("UpdatedAt")
358 + .HasColumnType("timestamp with time zone");
359 +
360 + b.Property<Guid>("UserId")
361 + .HasColumnType("uuid");
362 +
363 + b.Property<Guid>("WishlistItemId")
364 + .HasColumnType("uuid");
365 +
366 + b.HasKey("Id");
367 +
368 + b.HasIndex("WishlistItemId", "UserId")
369 + .IsUnique();
370 +
371 + b.ToTable("TripWishlistVotes", "trips");
372 + });
373 +
374 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.BudgetCategory", b =>
375 + {
376 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
377 + .WithMany("BudgetCategories")
378 + .HasForeignKey("TripId")
379 + .OnDelete(DeleteBehavior.Restrict)
380 + .IsRequired();
381 +
382 + b.Navigation("Trip");
383 + });
384 +
385 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripInvitation", b =>
386 + {
387 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
388 + .WithMany("Invitations")
389 + .HasForeignKey("TripId")
390 + .OnDelete(DeleteBehavior.Restrict)
391 + .IsRequired();
392 +
393 + b.Navigation("Trip");
394 + });
395 +
396 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripParticipant", b =>
397 + {
398 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
399 + .WithMany("Participants")
400 + .HasForeignKey("TripId")
401 + .OnDelete(DeleteBehavior.Restrict)
402 + .IsRequired();
403 +
404 + b.Navigation("Trip");
405 + });
406 +
407 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
408 + {
409 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
410 + .WithMany("Polls")
411 + .HasForeignKey("TripId")
412 + .OnDelete(DeleteBehavior.Restrict)
413 + .IsRequired();
414 +
415 + b.Navigation("Trip");
416 + });
417 +
418 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
419 + {
420 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripPoll", "Poll")
421 + .WithMany("Options")
422 + .HasForeignKey("PollId")
423 + .OnDelete(DeleteBehavior.Restrict)
424 + .IsRequired();
425 +
426 + b.Navigation("Poll");
427 + });
428 +
429 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollVote", b =>
430 + {
431 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", "PollOption")
432 + .WithMany("Votes")
433 + .HasForeignKey("PollOptionId")
434 + .OnDelete(DeleteBehavior.Restrict)
435 + .IsRequired();
436 +
437 + b.Navigation("PollOption");
438 + });
439 +
440 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
441 + {
442 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.Trip", "Trip")
443 + .WithMany("WishlistItems")
444 + .HasForeignKey("TripId")
445 + .OnDelete(DeleteBehavior.Restrict)
446 + .IsRequired();
447 +
448 + b.Navigation("Trip");
449 + });
450 +
451 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistVote", b =>
452 + {
453 + b.HasOne("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", "WishlistItem")
454 + .WithMany("Votes")
455 + .HasForeignKey("WishlistItemId")
456 + .OnDelete(DeleteBehavior.Restrict)
457 + .IsRequired();
458 +
459 + b.Navigation("WishlistItem");
460 + });
461 +
462 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.Trip", b =>
463 + {
464 + b.Navigation("BudgetCategories");
465 +
466 + b.Navigation("Invitations");
467 +
468 + b.Navigation("Participants");
469 +
470 + b.Navigation("Polls");
471 +
472 + b.Navigation("WishlistItems");
473 + });
474 +
475 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPoll", b =>
476 + {
477 + b.Navigation("Options");
478 + });
479 +
480 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripPollOption", b =>
481 + {
482 + b.Navigation("Votes");
483 + });
484 +
485 + modelBuilder.Entity("SplitApp.Modules.Trips.Domain.Entities.TripWishlistItem", b =>
486 + {
487 + b.Navigation("Votes");
488 + });
489 +#pragma warning restore 612, 618
490 + }
491 + }
492 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Repositories/TripsBaseRepository.cs +29 −0
@@ -0,0 +1,29 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Persistence;
4 +
5 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence.Repositories;
6 +
7 +public class TripsBaseRepository<TEntity> : IBaseRepository<TEntity> where TEntity : class, IBaseEntity
8 +{
9 + protected readonly TripsDbContext DbContext;
10 + protected readonly DbSet<TEntity> DbSet;
11 +
12 + public TripsBaseRepository(TripsDbContext dbContext)
13 + {
14 + DbContext = dbContext;
15 + DbSet = dbContext.Set<TEntity>();
16 + }
17 +
18 + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await DbSet.ToListAsync();
19 + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await DbSet.FirstOrDefaultAsync(e => e.Id == id);
20 + public virtual TEntity Add(TEntity entity) => DbSet.Add(entity).Entity;
21 + public virtual TEntity Update(TEntity entity) => DbSet.Update(entity).Entity;
22 + public virtual async Task<TEntity?> RemoveAsync(Guid id)
23 + {
24 + var entity = await GetByIdAsync(id);
25 + if (entity == null) return null;
26 + return DbSet.Remove(entity).Entity;
27 + }
28 + public virtual async Task<bool> ExistsAsync(Guid id) => await DbSet.AnyAsync(e => e.Id == id);
29 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsDbContext.cs +96 −0
@@ -0,0 +1,96 @@
1 +using System.Text.Json;
2 +using Microsoft.EntityFrameworkCore;
3 +using Microsoft.EntityFrameworkCore.ChangeTracking;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence;
9 +
10 +public class TripsDbContext : DbContext
11 +{
12 + public DbSet<Trip> Trips { get; set; } = default!;
13 + public DbSet<TripParticipant> TripParticipants { get; set; } = default!;
14 + public DbSet<TripInvitation> TripInvitations { get; set; } = default!;
15 + public DbSet<BudgetCategory> BudgetCategories { get; set; } = default!;
16 + public DbSet<TripWishlistItem> TripWishlistItems { get; set; } = default!;
17 + public DbSet<TripWishlistVote> TripWishlistVotes { get; set; } = default!;
18 + public DbSet<TripPoll> TripPolls { get; set; } = default!;
19 + public DbSet<TripPollOption> TripPollOptions { get; set; } = default!;
20 + public DbSet<TripPollVote> TripPollVotes { get; set; } = default!;
21 +
22 + public TripsDbContext(DbContextOptions<TripsDbContext> options) : base(options)
23 + {
24 + }
25 +
26 + public override int SaveChanges()
27 + {
28 + UpdateTimestamps();
29 + return base.SaveChanges();
30 + }
31 +
32 + public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
33 + {
34 + UpdateTimestamps();
35 + return base.SaveChangesAsync(cancellationToken);
36 + }
37 +
38 + private void UpdateTimestamps()
39 + {
40 + var entries = ChangeTracker.Entries<BaseEntity>();
41 + foreach (var entry in entries)
42 + {
43 + if (entry.State == EntityState.Modified)
44 + {
45 + entry.Entity.UpdatedAt = DateTime.UtcNow;
46 + }
47 + }
48 + }
49 +
50 + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
51 + {
52 + base.ConfigureConventions(configurationBuilder);
53 + configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeConverter>();
54 + }
55 +
56 + protected override void OnModelCreating(ModelBuilder builder)
57 + {
58 + base.OnModelCreating(builder);
59 +
60 + builder.HasDefaultSchema("trips");
61 +
62 + foreach (var relationship in builder.Model
63 + .GetEntityTypes()
64 + .SelectMany(e => e.GetForeignKeys()))
65 + {
66 + relationship.DeleteBehavior = DeleteBehavior.Restrict;
67 + }
68 +
69 + builder.Entity<TripInvitation>()
70 + .HasIndex(i => i.Token)
71 + .IsUnique();
72 +
73 + builder.Entity<TripParticipant>()
74 + .HasIndex(tp => new { tp.TripId, tp.UserId })
75 + .IsUnique();
76 +
77 + builder.Entity<TripWishlistVote>()
78 + .HasIndex(v => new { v.WishlistItemId, v.UserId })
79 + .IsUnique();
80 +
81 + builder.Entity<TripPollVote>()
82 + .HasIndex(v => new { v.PollOptionId, v.UserId })
83 + .IsUnique();
84 +
85 + builder.Entity<BudgetCategory>()
86 + .Property(c => c.Name)
87 + .HasConversion(
88 + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
89 + v => JsonSerializer.Deserialize<LangStr>(v, (JsonSerializerOptions?)null) ?? new LangStr())
90 + .HasMaxLength(1024)
91 + .Metadata.SetValueComparer(new ValueComparer<LangStr>(
92 + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null),
93 + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(),
94 + v => JsonSerializer.Deserialize<LangStr>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!));
95 + }
96 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsUnitOfWork.cs +38 −0
@@ -0,0 +1,38 @@
1 +using SplitApp.Modules.Trips.Application.Contracts;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence.Repositories;
4 +using SplitApp.Shared.Kernel.Domain;
5 +using SplitApp.Shared.Kernel.Persistence;
6 +
7 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence;
8 +
9 +public class TripsUnitOfWork : ITripsUnitOfWork
10 +{
11 + private readonly TripsDbContext _db;
12 +
13 + public TripsUnitOfWork(TripsDbContext db)
14 + {
15 + _db = db;
16 + Trips = new TripsBaseRepository<Trip>(db);
17 + Participants = new TripsBaseRepository<TripParticipant>(db);
18 + Invitations = new TripsBaseRepository<TripInvitation>(db);
19 + BudgetCategories = new TripsBaseRepository<BudgetCategory>(db);
20 + Polls = new TripsBaseRepository<TripPoll>(db);
21 + PollOptions = new TripsBaseRepository<TripPollOption>(db);
22 + PollVotes = new TripsBaseRepository<TripPollVote>(db);
23 + WishlistItems = new TripsBaseRepository<TripWishlistItem>(db);
24 + WishlistVotes = new TripsBaseRepository<TripWishlistVote>(db);
25 + }
26 +
27 + public IBaseRepository<Trip> Trips { get; }
28 + public IBaseRepository<TripParticipant> Participants { get; }
29 + public IBaseRepository<TripInvitation> Invitations { get; }
30 + public IBaseRepository<BudgetCategory> BudgetCategories { get; }
31 + public IBaseRepository<TripPoll> Polls { get; }
32 + public IBaseRepository<TripPollOption> PollOptions { get; }
33 + public IBaseRepository<TripPollVote> PollVotes { get; }
34 + public IBaseRepository<TripWishlistItem> WishlistItems { get; }
35 + public IBaseRepository<TripWishlistVote> WishlistVotes { get; }
36 +
37 + public Task<int> SaveChangesAsync() => _db.SaveChangesAsync();
38 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
@@ -0,0 +1,14 @@
1 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
2 +
3 +namespace SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +
5 +public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
6 +{
7 + public UtcDateTimeConverter() : base(
8 + v => v.Kind == DateTimeKind.Unspecified
9 + ? DateTime.SpecifyKind(v, DateTimeKind.Utc)
10 + : v.ToUniversalTime(),
11 + v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
12 + {
13 + }
14 +}
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj +30 −0
@@ -0,0 +1,30 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Trips.Domain\SplitApp.Modules.Trips.Domain.csproj" />
5 + <ProjectReference Include="..\SplitApp.Modules.Trips.Application\SplitApp.Modules.Trips.Application.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <FrameworkReference Include="Microsoft.AspNetCore.App" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="MediatR" Version="12.4.1" />
15 + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
16 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
17 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
18 + <PrivateAssets>all</PrivateAssets>
19 + </PackageReference>
20 + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
21 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
22 + </ItemGroup>
23 +
24 + <PropertyGroup>
25 + <TargetFramework>net10.0</TargetFramework>
26 + <ImplicitUsings>enable</ImplicitUsings>
27 + <Nullable>enable</Nullable>
28 + </PropertyGroup>
29 +
30 +</Project>
added SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/TripsModuleExtensions.cs +40 −0
@@ -0,0 +1,40 @@
1 +using Microsoft.AspNetCore.Builder;
2 +using Microsoft.EntityFrameworkCore;
3 +using Microsoft.Extensions.Configuration;
4 +using Microsoft.Extensions.DependencyInjection;
5 +using SplitApp.Modules.Trips.Application;
6 +using SplitApp.Modules.Trips.Application.Contracts;
7 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
8 +
9 +namespace SplitApp.Modules.Trips.Infrastructure;
10 +
11 +public static class TripsModuleExtensions
12 +{
13 + public static IServiceCollection AddTripsModule(
14 + this IServiceCollection services,
15 + IConfiguration configuration)
16 + {
17 + services.AddDbContext<TripsDbContext>(opt =>
18 + {
19 + opt.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
20 + });
21 +
22 + services.AddScoped<ITripsUnitOfWork, TripsUnitOfWork>();
23 +
24 + services.AddMediatR(cfg =>
25 + {
26 + cfg.RegisterServicesFromAssemblyContaining<TripsModuleMarker>();
27 + cfg.RegisterServicesFromAssembly(typeof(TripsModuleExtensions).Assembly);
28 + });
29 +
30 + return services;
31 + }
32 +
33 + public static IApplicationBuilder UseTripsModule(this IApplicationBuilder app)
34 + {
35 + using var scope = app.ApplicationServices.CreateScope();
36 + var db = scope.ServiceProvider.GetRequiredService<TripsDbContext>();
37 + db.Database.Migrate();
38 + return app;
39 + }
40 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor +3 −0
@@ -0,0 +1,3 @@
1 +<div class="my-component">
2 + This component is defined in the <strong>SplitApp.Modules.Users.Api</strong> library.
3 +</div>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor.css +6 −0
@@ -0,0 +1,6 @@
1 +.my-component {
2 + border: 2px dashed red;
3 + padding: 1em;
4 + margin: 1em 0;
5 + background-image: url('background.png');
6 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Controllers/AccountController.cs +145 −0
@@ -0,0 +1,145 @@
1 +using System.Net;
2 +using System.Security.Claims;
3 +using Asp.Versioning;
4 +using Microsoft.AspNetCore.Authentication.JwtBearer;
5 +using Microsoft.AspNetCore.Authorization;
6 +using Microsoft.AspNetCore.Mvc;
7 +using Microsoft.Extensions.Logging;
8 +using SplitApp.Modules.Users.Api.Dto.v1;
9 +using SplitApp.Modules.Users.Application.Services;
10 +
11 +namespace SplitApp.Modules.Users.Api.Controllers;
12 +
13 +[ApiVersion("1.0")]
14 +[ApiController]
15 +[Route("/api/v{version:apiVersion}/identity/[controller]/[action]")]
16 +public class AccountController : ControllerBase
17 +{
18 + private readonly IIdentityService _identityService;
19 + private readonly ILogger<AccountController> _logger;
20 +
21 + public AccountController(IIdentityService identityService, ILogger<AccountController> logger)
22 + {
23 + _identityService = identityService;
24 + _logger = logger;
25 + }
26 +
27 + [HttpPost]
28 + [Produces("application/json")]
29 + [Consumes("application/json")]
30 + [ProducesResponseType<JWTResponse>((int)HttpStatusCode.OK)]
31 + [ProducesResponseType<RestApiErrorResponse>((int)HttpStatusCode.BadRequest)]
32 + public async Task<ActionResult<JWTResponse>> Register(
33 + [FromBody] RegisterInfo registrationData,
34 + [FromQuery] int expiresInSeconds)
35 + {
36 + var result = await _identityService.RegisterAsync(new RegisterRequest
37 + {
38 + Email = registrationData.Email,
39 + Password = registrationData.Password,
40 + FirstName = registrationData.Firstname,
41 + LastName = registrationData.Lastname,
42 + ExpiresInSeconds = expiresInSeconds
43 + });
44 +
45 + if (!result.Success)
46 + {
47 + _logger.LogWarning("WebApi register failed for {Email}: {Error}", registrationData.Email, result.Error);
48 + return MapErrorResult(result);
49 + }
50 +
51 + return Ok(MapJwtPayload(result.Payload!));
52 + }
53 +
54 + [HttpPost]
55 + public async Task<ActionResult<JWTResponse>> Login(
56 + [FromBody] LoginInfo loginInfo,
57 + [FromQuery] int expiresInSeconds)
58 + {
59 + var result = await _identityService.LoginAsync(new LoginRequest
60 + {
61 + Email = loginInfo.Email,
62 + Password = loginInfo.Password,
63 + ExpiresInSeconds = expiresInSeconds
64 + });
65 +
66 + if (!result.Success)
67 + {
68 + _logger.LogWarning("WebApi login failed for {Email}: {Error}", loginInfo.Email, result.Error);
69 + return MapErrorResult(result);
70 + }
71 +
72 + return Ok(MapJwtPayload(result.Payload!));
73 + }
74 +
75 + [HttpPost]
76 + public async Task<ActionResult<JWTResponse>> RefreshTokenData(
77 + [FromBody] TokenRefreshInfo tokenRefreshInfo,
78 + [FromQuery] int expiresInSeconds)
79 + {
80 + var result = await _identityService.RefreshTokenAsync(new RefreshRequest
81 + {
82 + Jwt = tokenRefreshInfo.Jwt,
83 + RefreshToken = tokenRefreshInfo.RefreshToken,
84 + ExpiresInSeconds = expiresInSeconds
85 + });
86 +
87 + if (!result.Success)
88 + {
89 + return MapErrorResult(result);
90 + }
91 +
92 + return Ok(MapJwtPayload(result.Payload!));
93 + }
94 +
95 + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
96 + [HttpPost]
97 + public async Task<ActionResult> Logout([FromBody] LogoutInfo logout)
98 + {
99 + var userIdStr = User.FindFirstValue(ClaimTypes.NameIdentifier);
100 + if (userIdStr == null || !Guid.TryParse(userIdStr, out var userId))
101 + {
102 + return BadRequest(new RestApiErrorResponse
103 + {
104 + Status = HttpStatusCode.BadRequest,
105 + Error = "Invalid refresh token"
106 + });
107 + }
108 +
109 + var result = await _identityService.LogoutAsync(new LogoutRequest
110 + {
111 + UserId = userId,
112 + RefreshToken = logout.RefreshToken
113 + });
114 +
115 + if (!result.Success)
116 + {
117 + return MapErrorResult(result);
118 + }
119 +
120 + return Ok(new { TokenDeleteCount = result.TokensDeleted ?? 0 });
121 + }
122 +
123 + private ActionResult MapErrorResult(IdentityServiceResult result)
124 + {
125 + var error = new RestApiErrorResponse
126 + {
127 + Status = result.ErrorKind == IdentityServiceErrorKind.NotFound
128 + ? HttpStatusCode.NotFound
129 + : HttpStatusCode.BadRequest,
130 + Error = result.Error ?? "Unknown error"
131 + };
132 +
133 + return result.ErrorKind == IdentityServiceErrorKind.NotFound
134 + ? NotFound(error)
135 + : BadRequest(error);
136 + }
137 +
138 + private static JWTResponse MapJwtPayload(IdentityJwtPayload payload) => new()
139 + {
140 + Jwt = payload.Jwt,
141 + RefreshToken = payload.RefreshToken,
142 + FirstName = payload.FirstName,
143 + LastName = payload.LastName
144 + };
145 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Controllers/AdminUsersController.cs +139 −0
@@ -0,0 +1,139 @@
1 +using Asp.Versioning;
2 +using Microsoft.AspNetCore.Authentication.JwtBearer;
3 +using Microsoft.AspNetCore.Authorization;
4 +using Microsoft.AspNetCore.Identity;
5 +using Microsoft.AspNetCore.Mvc;
6 +using SplitApp.Modules.Users.Api.Dto.v1.Admin;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.Shared.Messaging;
9 +using SplitApp.Shared.Messaging.Integration.Users;
10 +
11 +namespace SplitApp.Modules.Users.Api.Controllers;
12 +
13 +/// <summary>
14 +/// Admin REST surface used by the monolith's Admin/UsersController. Protected by JWT bearer
15 +/// + role=admin. User deletion publishes <see cref="UserDeletedEvent"/> on the bus so
16 +/// downstream services can cascade-clean.
17 +/// </summary>
18 +[ApiVersion("1.0")]
19 +[ApiController]
20 +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme, Roles = "admin")]
21 +[Route("/api/v{version:apiVersion}/identity/admin/users")]
22 +public class AdminUsersController : ControllerBase
23 +{
24 + private readonly UserManager<AppUser> _userManager;
25 + private readonly RoleManager<AppRole> _roleManager;
26 + private readonly IMessageBus _bus;
27 +
28 + public AdminUsersController(
29 + UserManager<AppUser> userManager,
30 + RoleManager<AppRole> roleManager,
31 + IMessageBus bus)
32 + {
33 + _userManager = userManager;
34 + _roleManager = roleManager;
35 + _bus = bus;
36 + }
37 +
38 + [HttpGet]
39 + [Produces("application/json")]
40 + public async Task<ActionResult<IEnumerable<AdminUserListItem>>> List()
41 + {
42 + var users = _userManager.Users.OrderBy(u => u.Email).ToList();
43 + var items = new List<AdminUserListItem>(users.Count);
44 + foreach (var u in users)
45 + {
46 + var roles = await _userManager.GetRolesAsync(u);
47 + items.Add(new AdminUserListItem(u.Id, u.Email ?? "", u.FirstName, u.LastName, roles.ToArray()));
48 + }
49 + return Ok(items);
50 + }
51 +
52 + [HttpGet("{id:guid}")]
53 + [Produces("application/json")]
54 + public async Task<ActionResult<AdminUserDetails>> Get(Guid id)
55 + {
56 + var user = await _userManager.FindByIdAsync(id.ToString());
57 + if (user is null) return NotFound();
58 + var roles = await _userManager.GetRolesAsync(user);
59 + return Ok(new AdminUserDetails(user.Id, user.Email ?? "", user.FirstName, user.LastName, roles.ToArray()));
60 + }
61 +
62 + [HttpPut("{id:guid}")]
63 + [Consumes("application/json")]
64 + [Produces("application/json")]
65 + public async Task<ActionResult<AdminUserDetails>> Update(Guid id, [FromBody] AdminUserUpdateRequest req)
66 + {
67 + var user = await _userManager.FindByIdAsync(id.ToString());
68 + if (user is null) return NotFound();
69 +
70 + user.FirstName = req.FirstName;
71 + user.LastName = req.LastName;
72 +
73 + var result = await _userManager.UpdateAsync(user);
74 + if (!result.Succeeded)
75 + {
76 + return BadRequest(new { errors = result.Errors.Select(e => e.Description) });
77 + }
78 +
79 + var roles = await _userManager.GetRolesAsync(user);
80 + return Ok(new AdminUserDetails(user.Id, user.Email ?? "", user.FirstName, user.LastName, roles.ToArray()));
81 + }
82 +
83 + [HttpDelete("{id:guid}")]
84 + public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
85 + {
86 + var user = await _userManager.FindByIdAsync(id.ToString());
87 + if (user is null) return NotFound();
88 +
89 + var result = await _userManager.DeleteAsync(user);
90 + if (!result.Succeeded)
91 + {
92 + return BadRequest(new { errors = result.Errors.Select(e => e.Description) });
93 + }
94 +
95 + // Fan-out so monolith subscribers can clean cross-service references (Trips/Expenses).
96 + await _bus.PublishEventAsync(new UserDeletedEvent(id), ct);
97 + return NoContent();
98 + }
99 +
100 + [HttpGet("{id:guid}/roles")]
101 + [Produces("application/json")]
102 + public async Task<ActionResult<IEnumerable<string>>> GetRoles(Guid id)
103 + {
104 + var user = await _userManager.FindByIdAsync(id.ToString());
105 + if (user is null) return NotFound();
106 + var roles = await _userManager.GetRolesAsync(user);
107 + return Ok(roles);
108 + }
109 +
110 + [HttpPut("{id:guid}/roles")]
111 + [Consumes("application/json")]
112 + public async Task<IActionResult> SetRoles(Guid id, [FromBody] AdminUserRolesUpdateRequest req)
113 + {
114 + var user = await _userManager.FindByIdAsync(id.ToString());
115 + if (user is null) return NotFound();
116 +
117 + var current = await _userManager.GetRolesAsync(user);
118 + var desired = req.RoleNames ?? Array.Empty<string>();
119 +
120 + foreach (var add in desired.Except(current, StringComparer.OrdinalIgnoreCase))
121 + {
122 + if (!await _roleManager.RoleExistsAsync(add)) continue;
123 + await _userManager.AddToRoleAsync(user, add);
124 + }
125 + foreach (var remove in current.Except(desired, StringComparer.OrdinalIgnoreCase))
126 + {
127 + await _userManager.RemoveFromRoleAsync(user, remove);
128 + }
129 + return NoContent();
130 + }
131 +
132 + [HttpGet("/api/v{version:apiVersion}/identity/admin/roles")]
133 + [Produces("application/json")]
134 + public ActionResult<IEnumerable<AdminRoleInfo>> ListRoles()
135 + {
136 + var roles = _roleManager.Roles.OrderBy(r => r.Name).ToList();
137 + return Ok(roles.Select(r => new AdminRoleInfo(r.Name ?? "")));
138 + }
139 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/Admin/AdminUserDtos.cs +21 −0
@@ -0,0 +1,21 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1.Admin;
2 +
3 +public record AdminUserListItem(
4 + Guid Id,
5 + string Email,
6 + string FirstName,
7 + string LastName,
8 + string[] Roles);
9 +
10 +public record AdminUserDetails(
11 + Guid Id,
12 + string Email,
13 + string FirstName,
14 + string LastName,
15 + string[] Roles);
16 +
17 +public record AdminUserUpdateRequest(string FirstName, string LastName);
18 +
19 +public record AdminUserRolesUpdateRequest(string[] RoleNames);
20 +
21 +public record AdminRoleInfo(string Name);
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/JWTResponse.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class JWTResponse
4 +{
5 + public string Jwt { get; set; } = default!;
6 + public string RefreshToken { get; set; } = default!;
7 + public string FirstName { get; set; } = default!;
8 + public string LastName { get; set; } = default!;
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LoginInfo.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class LoginInfo
4 +{
5 + public string Email { get; set; } = default!;
6 + public string Password { get; set; } = default!;
7 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LogoutInfo.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class LogoutInfo
4 +{
5 + public string RefreshToken { get; set; } = default!;
6 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RegisterInfo.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class RegisterInfo
4 +{
5 + public string Email { get; set; } = default!;
6 + public string Password { get; set; } = default!;
7 + public string Firstname { get; set; } = default!;
8 + public string Lastname { get; set; } = default!;
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RestApiErrorResponse.cs +9 −0
@@ -0,0 +1,9 @@
1 +using System.Net;
2 +
3 +namespace SplitApp.Modules.Users.Api.Dto.v1;
4 +
5 +public class RestApiErrorResponse
6 +{
7 + public HttpStatusCode Status { get; set; }
8 + public string Error { get; set; } = default!;
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/TokenRefreshInfo.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Modules.Users.Api.Dto.v1;
2 +
3 +public class TokenRefreshInfo
4 +{
5 + public string Jwt { get; set; } = default!;
6 + public string RefreshToken { get; set; } = default!;
7 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/ExampleJsInterop.cs +31 −0
@@ -0,0 +1,31 @@
1 +using Microsoft.JSInterop;
2 +
3 +namespace SplitApp.Modules.Users.Api;
4 +
5 +// This class provides an example of how JavaScript functionality can be wrapped
6 +// in a .NET class for easy consumption. The associated JavaScript module is
7 +// loaded on demand when first needed.
8 +//
9 +// This class can be registered as scoped DI service and then injected into Blazor
10 +// components for use.
11 +
12 +public class ExampleJsInterop(IJSRuntime jsRuntime) : IAsyncDisposable
13 +{
14 + private readonly Lazy<Task<IJSObjectReference>> moduleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
15 + "import", "./_content/SplitApp.Modules.Users.Api/exampleJsInterop.js").AsTask());
16 +
17 + public async ValueTask<string> Prompt(string message)
18 + {
19 + var module = await moduleTask.Value;
20 + return await module.InvokeAsync<string>("showPrompt", message);
21 + }
22 +
23 + public async ValueTask DisposeAsync()
24 + {
25 + if (moduleTask.IsValueCreated)
26 + {
27 + var module = await moduleTask.Value;
28 + await module.DisposeAsync();
29 + }
30 + }
31 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/SplitApp.Modules.Users.Api.csproj +28 −0
@@ -0,0 +1,28 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Razor">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + </PropertyGroup>
8 +
9 +
10 + <ItemGroup>
11 + <SupportedPlatform Include="browser" />
12 + </ItemGroup>
13 +
14 + <ItemGroup>
15 + <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
16 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.0" />
18 + <PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.3.9" />
19 + </ItemGroup>
20 +
21 + <ItemGroup>
22 + <ProjectReference Include="..\SplitApp.Modules.Users.Application\SplitApp.Modules.Users.Application.csproj" />
23 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
24 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
25 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Messaging\SplitApp.Shared.Messaging.csproj" />
26 + </ItemGroup>
27 +
28 +</Project>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/_Imports.razor +1 −0
@@ -0,0 +1 @@
1 +@using Microsoft.AspNetCore.Components.Web
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/background.png +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/exampleJsInterop.js +6 −0
@@ -0,0 +1,6 @@
1 +// This is a JavaScript module that is loaded on demand. It can export any number of
2 +// functions, and may import other JavaScript modules if required.
3 +
4 +export function showPrompt(message) {
5 + return prompt(message, 'Type anything here');
6 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IRefreshTokenRepository.cs +12 −0
@@ -0,0 +1,12 @@
1 +using SplitApp.Modules.Users.Domain.Entities;
2 +using SplitApp.Shared.Kernel.Persistence;
3 +
4 +namespace SplitApp.Modules.Users.Application.Contracts;
5 +
6 +public interface IRefreshTokenRepository : IBaseRepository<AppRefreshToken>
7 +{
8 + Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue);
9 + Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue);
10 + Task<int> RemoveExpiredForUserAsync(Guid userId);
11 + void Remove(AppRefreshToken token);
12 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUserRepository.cs +12 −0
@@ -0,0 +1,12 @@
1 +using SplitApp.Modules.Users.Domain.Entities;
2 +using SplitApp.Shared.Kernel.Persistence;
3 +
4 +namespace SplitApp.Modules.Users.Application.Contracts;
5 +
6 +public interface IUserRepository : IBaseRepository<AppUser>
7 +{
8 + Task<int> CountAsync();
9 + Task<IEnumerable<AppUser>> GetRecentAsync(int take);
10 + Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId);
11 + Task<IReadOnlyList<AppUser>> GetByIdsAsync(IReadOnlyCollection<Guid> ids);
12 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUsersUnitOfWork.cs +9 −0
@@ -0,0 +1,9 @@
1 +using SplitApp.Shared.Kernel.Persistence;
2 +
3 +namespace SplitApp.Modules.Users.Application.Contracts;
4 +
5 +public interface IUsersUnitOfWork : IUnitOfWork
6 +{
7 + IUserRepository Users { get; }
8 + IRefreshTokenRepository RefreshTokens { get; }
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IIdentityService.cs +9 −0
@@ -0,0 +1,9 @@
1 +namespace SplitApp.Modules.Users.Application.Services;
2 +
3 +public interface IIdentityService
4 +{
5 + Task<IdentityServiceResult> RegisterAsync(RegisterRequest request);
6 + Task<IdentityServiceResult> LoginAsync(LoginRequest request);
7 + Task<IdentityServiceResult> RefreshTokenAsync(RefreshRequest request);
8 + Task<IdentityServiceResult> LogoutAsync(LogoutRequest request);
9 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityService.cs +270 −0
@@ -0,0 +1,270 @@
1 +using System.IdentityModel.Tokens.Jwt;
2 +using System.Security.Claims;
3 +using MediatR;
4 +using Microsoft.AspNetCore.Identity;
5 +using Microsoft.Extensions.Configuration;
6 +using SplitApp.Modules.Users.Application.Contracts;
7 +using SplitApp.Modules.Users.Domain.Entities;
8 +using SplitApp.Shared.Kernel.Auth;
9 +
10 +namespace SplitApp.Modules.Users.Application.Services;
11 +
12 +public class IdentityService : IIdentityService
13 +{
14 + private readonly IUsersUnitOfWork _uow;
15 + private readonly UserManager<AppUser> _userManager;
16 + private readonly IConfiguration _configuration;
17 + private readonly IMediator _mediator;
18 +
19 + public IdentityService(
20 + IUsersUnitOfWork uow,
21 + UserManager<AppUser> userManager,
22 + IConfiguration configuration,
23 + IMediator mediator)
24 + {
25 + _uow = uow;
26 + _userManager = userManager;
27 + _configuration = configuration;
28 + _mediator = mediator;
29 + }
30 +
31 + public async Task<IdentityServiceResult> RegisterAsync(RegisterRequest request)
32 + {
33 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
34 +
35 + var existing = await _userManager.FindByEmailAsync(request.Email);
36 + if (existing != null)
37 + {
38 + return IdentityServiceResult.Fail(
39 + $"User with email {request.Email} is already registered",
40 + IdentityServiceErrorKind.BadRequest);
41 + }
42 +
43 + var refreshToken = new AppRefreshToken();
44 + var appUser = new AppUser
45 + {
46 + Email = request.Email,
47 + UserName = request.Email,
48 + FirstName = request.FirstName,
49 + LastName = request.LastName,
50 + RefreshTokens = new List<AppRefreshToken> { refreshToken }
51 + };
52 + refreshToken.AppUser = appUser;
53 +
54 + var createResult = await _userManager.CreateAsync(appUser, request.Password);
55 + if (!createResult.Succeeded)
56 + {
57 + return IdentityServiceResult.Fail(
58 + createResult.Errors.First().Description,
59 + IdentityServiceErrorKind.BadRequest);
60 + }
61 +
62 + await _userManager.AddToRoleAsync(appUser, "user");
63 +
64 + var claimsResult = await _userManager.AddClaimsAsync(appUser, new List<Claim>
65 + {
66 + new(ClaimTypes.GivenName, appUser.FirstName),
67 + new(ClaimTypes.Surname, appUser.LastName)
68 + });
69 + if (!claimsResult.Succeeded)
70 + {
71 + return IdentityServiceResult.Fail(
72 + claimsResult.Errors.First().Description,
73 + IdentityServiceErrorKind.BadRequest);
74 + }
75 +
76 + var reloaded = await _userManager.FindByEmailAsync(appUser.Email);
77 + if (reloaded == null)
78 + {
79 + return IdentityServiceResult.Fail(
80 + $"User with email {request.Email} is not found after registration",
81 + IdentityServiceErrorKind.BadRequest);
82 + }
83 +
84 + var jwt = await GenerateJwtAsync(reloaded, expiresInSeconds);
85 +
86 + return IdentityServiceResult.Ok(new IdentityJwtPayload
87 + {
88 + Jwt = jwt,
89 + RefreshToken = refreshToken.RefreshToken,
90 + FirstName = reloaded.FirstName,
91 + LastName = reloaded.LastName
92 + });
93 + }
94 +
95 + public async Task<IdentityServiceResult> LoginAsync(LoginRequest request)
96 + {
97 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
98 +
99 + var appUser = await _userManager.FindByEmailAsync(request.Email);
100 + if (appUser == null)
101 + {
102 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
103 + }
104 +
105 + var passwordOk = await _userManager.CheckPasswordAsync(appUser, request.Password);
106 + if (!passwordOk)
107 + {
108 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
109 + }
110 +
111 + await _uow.RefreshTokens.RemoveExpiredForUserAsync(appUser.Id);
112 +
113 + var refreshToken = new AppRefreshToken
114 + {
115 + AppUserId = appUser.Id
116 + };
117 + _uow.RefreshTokens.Add(refreshToken);
118 + await _uow.SaveChangesAsync();
119 +
120 + var jwt = await GenerateJwtAsync(appUser, expiresInSeconds);
121 +
122 + return IdentityServiceResult.Ok(new IdentityJwtPayload
123 + {
124 + Jwt = jwt,
125 + RefreshToken = refreshToken.RefreshToken,
126 + FirstName = appUser.FirstName,
127 + LastName = appUser.LastName
128 + });
129 + }
130 +
131 + public async Task<IdentityServiceResult> RefreshTokenAsync(RefreshRequest request)
132 + {
133 + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds);
134 +
135 + JwtSecurityToken? jwt;
136 + try
137 + {
138 + jwt = new JwtSecurityTokenHandler().ReadJwtToken(request.Jwt);
139 + }
140 + catch (Exception)
141 + {
142 + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest);
143 + }
144 +
145 + if (jwt == null)
146 + {
147 + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest);
148 + }
149 +
150 + if (!IdentityHelpers.ValidateJWT(
151 + request.Jwt,
152 + _configuration.GetValue<string>("JWT:Key")!,
153 + _configuration.GetValue<string>("JWT:Issuer")!,
154 + _configuration.GetValue<string>("JWT:Audience")!))
155 + {
156 + return IdentityServiceResult.Fail("JWT validation fail", IdentityServiceErrorKind.BadRequest);
157 + }
158 +
159 + var userEmail = jwt.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value;
160 + if (userEmail == null)
161 + {
162 + return IdentityServiceResult.Fail("No email in jwt", IdentityServiceErrorKind.BadRequest);
163 + }
164 +
165 + var appUser = await _userManager.FindByEmailAsync(userEmail);
166 + if (appUser == null)
167 + {
168 + return IdentityServiceResult.Fail($"User with email {userEmail} not found", IdentityServiceErrorKind.NotFound);
169 + }
170 +
171 + var matchingTokens = (await _uow.RefreshTokens
172 + .GetUserActiveTokensAsync(appUser.Id, request.RefreshToken)).ToList();
173 +
174 + if (matchingTokens.Count == 0)
175 + {
176 + return IdentityServiceResult.Fail(
177 + "RefreshTokens collection is null or empty - 0",
178 + IdentityServiceErrorKind.NotFound);
179 + }
180 +
181 + if (matchingTokens.Count != 1)
182 + {
183 + return IdentityServiceResult.Fail(
184 + "More than one valid refresh token found",
185 + IdentityServiceErrorKind.NotFound);
186 + }
187 +
188 + var refreshToken = matchingTokens.First();
189 + if (refreshToken.RefreshToken == request.RefreshToken)
190 + {
191 + refreshToken.PreviousRefreshToken = refreshToken.RefreshToken;
192 + refreshToken.PreviousExpirationDT = DateTime.UtcNow.AddMinutes(1);
193 + refreshToken.RefreshToken = Guid.NewGuid().ToString();
194 + refreshToken.ExpirationDT = DateTime.UtcNow.AddDays(7);
195 + _uow.RefreshTokens.Update(refreshToken);
196 + await _uow.SaveChangesAsync();
197 + }
198 +
199 + var newJwt = await GenerateJwtAsync(appUser, expiresInSeconds);
200 +
201 + return IdentityServiceResult.Ok(new IdentityJwtPayload
202 + {
203 + Jwt = newJwt,
204 + RefreshToken = refreshToken.RefreshToken,
205 + FirstName = appUser.FirstName,
206 + LastName = appUser.LastName
207 + });
208 + }
209 +
210 + public async Task<IdentityServiceResult> LogoutAsync(LogoutRequest request)
211 + {
212 + var appUser = await _uow.Users.GetByIdAsync(request.UserId);
213 + if (appUser == null)
214 + {
215 + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound);
216 + }
217 +
218 + var tokens = (await _uow.RefreshTokens
219 + .GetUserTokensByValueAsync(request.UserId, request.RefreshToken)).ToList();
220 +
221 + foreach (var token in tokens)
222 + {
223 + _uow.RefreshTokens.Remove(token);
224 + }
225 +
226 + var deleteCount = await _uow.SaveChangesAsync();
227 + return IdentityServiceResult.Logout(deleteCount);
228 + }
229 +
230 + private int ResolveExpiresInSeconds(int requested)
231 + {
232 + if (requested <= 0) requested = int.MaxValue;
233 + var configured = _configuration.GetValue<int>("JWT:ExpiresInSeconds");
234 + return requested < configured ? requested : configured;
235 + }
236 +
237 + private async Task<string> GenerateJwtAsync(AppUser user, int expiresInSeconds)
238 + {
239 + var claims = new List<Claim>
240 + {
241 + new(ClaimTypes.NameIdentifier, user.Id.ToString()),
242 + new(ClaimTypes.Email, user.Email ?? ""),
243 + new(ClaimTypes.Name, user.UserName ?? user.Email ?? ""),
244 + new(ClaimTypes.GivenName, user.FirstName),
245 + new(ClaimTypes.Surname, user.LastName)
246 + };
247 +
248 + var userClaims = await _userManager.GetClaimsAsync(user);
249 + foreach (var c in userClaims)
250 + {
251 + if (!claims.Any(existing => existing.Type == c.Type && existing.Value == c.Value))
252 + {
253 + claims.Add(c);
254 + }
255 + }
256 +
257 + var roles = await _userManager.GetRolesAsync(user);
258 + foreach (var role in roles)
259 + {
260 + claims.Add(new Claim(ClaimTypes.Role, role));
261 + }
262 +
263 + return IdentityHelpers.GenerateJwt(
264 + claims,
265 + _configuration.GetValue<string>("JWT:Key")!,
266 + _configuration.GetValue<string>("JWT:Issuer")!,
267 + _configuration.GetValue<string>("JWT:Audience")!,
268 + expiresInSeconds);
269 + }
270 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityServiceModels.cs +63 −0
@@ -0,0 +1,63 @@
1 +namespace SplitApp.Modules.Users.Application.Services;
2 +
3 +public class IdentityServiceResult
4 +{
5 + public bool Success { get; init; }
6 + public string? Error { get; init; }
7 + public IdentityServiceErrorKind ErrorKind { get; init; } = IdentityServiceErrorKind.None;
8 + public IdentityJwtPayload? Payload { get; init; }
9 + public int? TokensDeleted { get; init; }
10 +
11 + public static IdentityServiceResult Ok(IdentityJwtPayload payload) =>
12 + new() { Success = true, Payload = payload };
13 +
14 + public static IdentityServiceResult Logout(int tokensDeleted) =>
15 + new() { Success = true, TokensDeleted = tokensDeleted };
16 +
17 + public static IdentityServiceResult Fail(string error, IdentityServiceErrorKind kind) =>
18 + new() { Success = false, Error = error, ErrorKind = kind };
19 +}
20 +
21 +public enum IdentityServiceErrorKind
22 +{
23 + None = 0,
24 + BadRequest = 400,
25 + NotFound = 404
26 +}
27 +
28 +public class IdentityJwtPayload
29 +{
30 + public string Jwt { get; init; } = default!;
31 + public string RefreshToken { get; init; } = default!;
32 + public string FirstName { get; init; } = default!;
33 + public string LastName { get; init; } = default!;
34 +}
35 +
36 +public class RegisterRequest
37 +{
38 + public string Email { get; init; } = default!;
39 + public string Password { get; init; } = default!;
40 + public string FirstName { get; init; } = default!;
41 + public string LastName { get; init; } = default!;
42 + public int ExpiresInSeconds { get; init; }
43 +}
44 +
45 +public class LoginRequest
46 +{
47 + public string Email { get; init; } = default!;
48 + public string Password { get; init; } = default!;
49 + public int ExpiresInSeconds { get; init; }
50 +}
51 +
52 +public class RefreshRequest
53 +{
54 + public string Jwt { get; init; } = default!;
55 + public string RefreshToken { get; init; } = default!;
56 + public int ExpiresInSeconds { get; init; }
57 +}
58 +
59 +public class LogoutRequest
60 +{
61 + public Guid UserId { get; init; }
62 + public string RefreshToken { get; init; } = default!;
63 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/SplitApp.Modules.Users.Application.csproj +23 −0
@@ -0,0 +1,23 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj" />
5 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <FrameworkReference Include="Microsoft.AspNetCore.App" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="MediatR" Version="12.4.1" />
15 + </ItemGroup>
16 +
17 + <PropertyGroup>
18 + <TargetFramework>net10.0</TargetFramework>
19 + <ImplicitUsings>enable</ImplicitUsings>
20 + <Nullable>enable</Nullable>
21 + </PropertyGroup>
22 +
23 +</Project>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/UsersModuleMarker.cs +4 −0
@@ -0,0 +1,4 @@
1 +namespace SplitApp.Modules.Users.Application;
2 +
3 +/// <summary>Assembly marker for MediatR handler scanning. Must remain in the Application assembly.</summary>
4 +public sealed class UsersModuleMarker;
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRefreshToken.cs +20 −0
@@ -0,0 +1,20 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using SplitApp.Shared.Kernel.Domain;
3 +
4 +namespace SplitApp.Modules.Users.Domain.Entities;
5 +
6 +public class AppRefreshToken : BaseEntity
7 +{
8 + [MaxLength(64)]
9 + public string RefreshToken { get; set; } = Guid.NewGuid().ToString();
10 +
11 + public DateTime ExpirationDT { get; set; } = DateTime.UtcNow.AddDays(7);
12 +
13 + [MaxLength(64)]
14 + public string? PreviousRefreshToken { get; set; }
15 +
16 + public DateTime PreviousExpirationDT { get; set; } = DateTime.UtcNow.AddDays(7);
17 +
18 + public Guid AppUserId { get; set; }
19 + public AppUser? AppUser { get; set; }
20 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRole.cs +8 −0
@@ -0,0 +1,8 @@
1 +using Microsoft.AspNetCore.Identity;
2 +using SplitApp.Shared.Kernel.Domain;
3 +
4 +namespace SplitApp.Modules.Users.Domain.Entities;
5 +
6 +public class AppRole : IdentityRole<Guid>, IBaseEntity
7 +{
8 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppUser.cs +16 −0
@@ -0,0 +1,16 @@
1 +using System.ComponentModel.DataAnnotations;
2 +using Microsoft.AspNetCore.Identity;
3 +using SplitApp.Shared.Kernel.Domain;
4 +
5 +namespace SplitApp.Modules.Users.Domain.Entities;
6 +
7 +public class AppUser : IdentityUser<Guid>, IBaseEntity
8 +{
9 + [MaxLength(128)]
10 + public string FirstName { get; set; } = "";
11 +
12 + [MaxLength(128)]
13 + public string LastName { get; set; } = "";
14 +
15 + public ICollection<AppRefreshToken>? RefreshTokens { get; set; }
16 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/SplitApp.Modules.Users.Domain.csproj +17 −0
@@ -0,0 +1,17 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
5 + </ItemGroup>
6 +
7 + <ItemGroup>
8 + <PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.5" />
9 + </ItemGroup>
10 +
11 + <PropertyGroup>
12 + <TargetFramework>net10.0</TargetFramework>
13 + <ImplicitUsings>enable</ImplicitUsings>
14 + <Nullable>enable</Nullable>
15 + </PropertyGroup>
16 +
17 +</Project>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.Designer.cs +359 −0
@@ -0,0 +1,359 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Migrations;
6 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
7 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
8 +using SplitApp.Modules.Users.Infrastructure.Persistence;
9 +
10 +#nullable disable
11 +
12 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Migrations
13 +{
14 + [DbContext(typeof(UsersDbContext))]
15 + [Migration("20260430134045_Init")]
16 + partial class Init
17 + {
18 + /// <inheritdoc />
19 + protected override void BuildTargetModel(ModelBuilder modelBuilder)
20 + {
21 +#pragma warning disable 612, 618
22 + modelBuilder
23 + .HasDefaultSchema("users")
24 + .HasAnnotation("ProductVersion", "10.0.5")
25 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
26 +
27 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
28 +
29 + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
30 + {
31 + b.Property<int>("Id")
32 + .ValueGeneratedOnAdd()
33 + .HasColumnType("integer");
34 +
35 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
36 +
37 + b.Property<string>("FriendlyName")
38 + .HasColumnType("text");
39 +
40 + b.Property<string>("Xml")
41 + .HasColumnType("text");
42 +
43 + b.HasKey("Id");
44 +
45 + b.ToTable("DataProtectionKeys", "users");
46 + });
47 +
48 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
49 + {
50 + b.Property<int>("Id")
51 + .ValueGeneratedOnAdd()
52 + .HasColumnType("integer");
53 +
54 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
55 +
56 + b.Property<string>("ClaimType")
57 + .HasColumnType("text");
58 +
59 + b.Property<string>("ClaimValue")
60 + .HasColumnType("text");
61 +
62 + b.Property<Guid>("RoleId")
63 + .HasColumnType("uuid");
64 +
65 + b.HasKey("Id");
66 +
67 + b.HasIndex("RoleId");
68 +
69 + b.ToTable("AspNetRoleClaims", "users");
70 + });
71 +
72 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
73 + {
74 + b.Property<int>("Id")
75 + .ValueGeneratedOnAdd()
76 + .HasColumnType("integer");
77 +
78 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
79 +
80 + b.Property<string>("ClaimType")
81 + .HasColumnType("text");
82 +
83 + b.Property<string>("ClaimValue")
84 + .HasColumnType("text");
85 +
86 + b.Property<Guid>("UserId")
87 + .HasColumnType("uuid");
88 +
89 + b.HasKey("Id");
90 +
91 + b.HasIndex("UserId");
92 +
93 + b.ToTable("AspNetUserClaims", "users");
94 + });
95 +
96 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
97 + {
98 + b.Property<string>("LoginProvider")
99 + .HasColumnType("text");
100 +
101 + b.Property<string>("ProviderKey")
102 + .HasColumnType("text");
103 +
104 + b.Property<string>("ProviderDisplayName")
105 + .HasColumnType("text");
106 +
107 + b.Property<Guid>("UserId")
108 + .HasColumnType("uuid");
109 +
110 + b.HasKey("LoginProvider", "ProviderKey");
111 +
112 + b.HasIndex("UserId");
113 +
114 + b.ToTable("AspNetUserLogins", "users");
115 + });
116 +
117 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
118 + {
119 + b.Property<Guid>("UserId")
120 + .HasColumnType("uuid");
121 +
122 + b.Property<Guid>("RoleId")
123 + .HasColumnType("uuid");
124 +
125 + b.HasKey("UserId", "RoleId");
126 +
127 + b.HasIndex("RoleId");
128 +
129 + b.ToTable("AspNetUserRoles", "users");
130 + });
131 +
132 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
133 + {
134 + b.Property<Guid>("UserId")
135 + .HasColumnType("uuid");
136 +
137 + b.Property<string>("LoginProvider")
138 + .HasColumnType("text");
139 +
140 + b.Property<string>("Name")
141 + .HasColumnType("text");
142 +
143 + b.Property<string>("Value")
144 + .HasColumnType("text");
145 +
146 + b.HasKey("UserId", "LoginProvider", "Name");
147 +
148 + b.ToTable("AspNetUserTokens", "users");
149 + });
150 +
151 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRefreshToken", b =>
152 + {
153 + b.Property<Guid>("Id")
154 + .ValueGeneratedOnAdd()
155 + .HasColumnType("uuid");
156 +
157 + b.Property<Guid>("AppUserId")
158 + .HasColumnType("uuid");
159 +
160 + b.Property<DateTime>("CreatedAt")
161 + .HasColumnType("timestamp with time zone");
162 +
163 + b.Property<DateTime>("ExpirationDT")
164 + .HasColumnType("timestamp with time zone");
165 +
166 + b.Property<DateTime>("PreviousExpirationDT")
167 + .HasColumnType("timestamp with time zone");
168 +
169 + b.Property<string>("PreviousRefreshToken")
170 + .HasMaxLength(64)
171 + .HasColumnType("character varying(64)");
172 +
173 + b.Property<string>("RefreshToken")
174 + .IsRequired()
175 + .HasMaxLength(64)
176 + .HasColumnType("character varying(64)");
177 +
178 + b.Property<DateTime>("UpdatedAt")
179 + .HasColumnType("timestamp with time zone");
180 +
181 + b.HasKey("Id");
182 +
183 + b.HasIndex("AppUserId");
184 +
185 + b.ToTable("RefreshTokens", "users");
186 + });
187 +
188 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRole", b =>
189 + {
190 + b.Property<Guid>("Id")
191 + .ValueGeneratedOnAdd()
192 + .HasColumnType("uuid");
193 +
194 + b.Property<string>("ConcurrencyStamp")
195 + .IsConcurrencyToken()
196 + .HasColumnType("text");
197 +
198 + b.Property<string>("Name")
199 + .HasMaxLength(256)
200 + .HasColumnType("character varying(256)");
201 +
202 + b.Property<string>("NormalizedName")
203 + .HasMaxLength(256)
204 + .HasColumnType("character varying(256)");
205 +
206 + b.HasKey("Id");
207 +
208 + b.HasIndex("NormalizedName")
209 + .IsUnique()
210 + .HasDatabaseName("RoleNameIndex");
211 +
212 + b.ToTable("AspNetRoles", "users");
213 + });
214 +
215 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppUser", b =>
216 + {
217 + b.Property<Guid>("Id")
218 + .ValueGeneratedOnAdd()
219 + .HasColumnType("uuid");
220 +
221 + b.Property<int>("AccessFailedCount")
222 + .HasColumnType("integer");
223 +
224 + b.Property<string>("ConcurrencyStamp")
225 + .IsConcurrencyToken()
226 + .HasColumnType("text");
227 +
228 + b.Property<string>("Email")
229 + .HasMaxLength(256)
230 + .HasColumnType("character varying(256)");
231 +
232 + b.Property<bool>("EmailConfirmed")
233 + .HasColumnType("boolean");
234 +
235 + b.Property<string>("FirstName")
236 + .IsRequired()
237 + .HasMaxLength(128)
238 + .HasColumnType("character varying(128)");
239 +
240 + b.Property<string>("LastName")
241 + .IsRequired()
242 + .HasMaxLength(128)
243 + .HasColumnType("character varying(128)");
244 +
245 + b.Property<bool>("LockoutEnabled")
246 + .HasColumnType("boolean");
247 +
248 + b.Property<DateTimeOffset?>("LockoutEnd")
249 + .HasColumnType("timestamp with time zone");
250 +
251 + b.Property<string>("NormalizedEmail")
252 + .HasMaxLength(256)
253 + .HasColumnType("character varying(256)");
254 +
255 + b.Property<string>("NormalizedUserName")
256 + .HasMaxLength(256)
257 + .HasColumnType("character varying(256)");
258 +
259 + b.Property<string>("PasswordHash")
260 + .HasColumnType("text");
261 +
262 + b.Property<string>("PhoneNumber")
263 + .HasColumnType("text");
264 +
265 + b.Property<bool>("PhoneNumberConfirmed")
266 + .HasColumnType("boolean");
267 +
268 + b.Property<string>("SecurityStamp")
269 + .HasColumnType("text");
270 +
271 + b.Property<bool>("TwoFactorEnabled")
272 + .HasColumnType("boolean");
273 +
274 + b.Property<string>("UserName")
275 + .HasMaxLength(256)
276 + .HasColumnType("character varying(256)");
277 +
278 + b.HasKey("Id");
279 +
280 + b.HasIndex("NormalizedEmail")
281 + .HasDatabaseName("EmailIndex");
282 +
283 + b.HasIndex("NormalizedUserName")
284 + .IsUnique()
285 + .HasDatabaseName("UserNameIndex");
286 +
287 + b.ToTable("AspNetUsers", "users");
288 + });
289 +
290 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
291 + {
292 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppRole", null)
293 + .WithMany()
294 + .HasForeignKey("RoleId")
295 + .OnDelete(DeleteBehavior.Restrict)
296 + .IsRequired();
297 + });
298 +
299 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
300 + {
301 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
302 + .WithMany()
303 + .HasForeignKey("UserId")
304 + .OnDelete(DeleteBehavior.Restrict)
305 + .IsRequired();
306 + });
307 +
308 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
309 + {
310 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
311 + .WithMany()
312 + .HasForeignKey("UserId")
313 + .OnDelete(DeleteBehavior.Restrict)
314 + .IsRequired();
315 + });
316 +
317 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
318 + {
319 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppRole", null)
320 + .WithMany()
321 + .HasForeignKey("RoleId")
322 + .OnDelete(DeleteBehavior.Restrict)
323 + .IsRequired();
324 +
325 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
326 + .WithMany()
327 + .HasForeignKey("UserId")
328 + .OnDelete(DeleteBehavior.Restrict)
329 + .IsRequired();
330 + });
331 +
332 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
333 + {
334 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
335 + .WithMany()
336 + .HasForeignKey("UserId")
337 + .OnDelete(DeleteBehavior.Restrict)
338 + .IsRequired();
339 + });
340 +
341 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRefreshToken", b =>
342 + {
343 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", "AppUser")
344 + .WithMany("RefreshTokens")
345 + .HasForeignKey("AppUserId")
346 + .OnDelete(DeleteBehavior.Restrict)
347 + .IsRequired();
348 +
349 + b.Navigation("AppUser");
350 + });
351 +
352 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppUser", b =>
353 + {
354 + b.Navigation("RefreshTokens");
355 + });
356 +#pragma warning restore 612, 618
357 + }
358 + }
359 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.cs +310 −0
@@ -0,0 +1,310 @@
1 +using System;
2 +using Microsoft.EntityFrameworkCore.Migrations;
3 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
4 +
5 +#nullable disable
6 +
7 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Migrations
8 +{
9 + /// <inheritdoc />
10 + public partial class Init : Migration
11 + {
12 + /// <inheritdoc />
13 + protected override void Up(MigrationBuilder migrationBuilder)
14 + {
15 + migrationBuilder.EnsureSchema(
16 + name: "users");
17 +
18 + migrationBuilder.CreateTable(
19 + name: "AspNetRoles",
20 + schema: "users",
21 + columns: table => new
22 + {
23 + Id = table.Column<Guid>(type: "uuid", nullable: false),
24 + Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
25 + NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
26 + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true)
27 + },
28 + constraints: table =>
29 + {
30 + table.PrimaryKey("PK_AspNetRoles", x => x.Id);
31 + });
32 +
33 + migrationBuilder.CreateTable(
34 + name: "AspNetUsers",
35 + schema: "users",
36 + columns: table => new
37 + {
38 + Id = table.Column<Guid>(type: "uuid", nullable: false),
39 + FirstName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
40 + LastName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
41 + UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
42 + NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
43 + Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
44 + NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
45 + EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
46 + PasswordHash = table.Column<string>(type: "text", nullable: true),
47 + SecurityStamp = table.Column<string>(type: "text", nullable: true),
48 + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
49 + PhoneNumber = table.Column<string>(type: "text", nullable: true),
50 + PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
51 + TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
52 + LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
53 + LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
54 + AccessFailedCount = table.Column<int>(type: "integer", nullable: false)
55 + },
56 + constraints: table =>
57 + {
58 + table.PrimaryKey("PK_AspNetUsers", x => x.Id);
59 + });
60 +
61 + migrationBuilder.CreateTable(
62 + name: "DataProtectionKeys",
63 + schema: "users",
64 + columns: table => new
65 + {
66 + Id = table.Column<int>(type: "integer", nullable: false)
67 + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
68 + FriendlyName = table.Column<string>(type: "text", nullable: true),
69 + Xml = table.Column<string>(type: "text", nullable: true)
70 + },
71 + constraints: table =>
72 + {
73 + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
74 + });
75 +
76 + migrationBuilder.CreateTable(
77 + name: "AspNetRoleClaims",
78 + schema: "users",
79 + columns: table => new
80 + {
81 + Id = table.Column<int>(type: "integer", nullable: false)
82 + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
83 + RoleId = table.Column<Guid>(type: "uuid", nullable: false),
84 + ClaimType = table.Column<string>(type: "text", nullable: true),
85 + ClaimValue = table.Column<string>(type: "text", nullable: true)
86 + },
87 + constraints: table =>
88 + {
89 + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
90 + table.ForeignKey(
91 + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
92 + column: x => x.RoleId,
93 + principalSchema: "users",
94 + principalTable: "AspNetRoles",
95 + principalColumn: "Id",
96 + onDelete: ReferentialAction.Restrict);
97 + });
98 +
99 + migrationBuilder.CreateTable(
100 + name: "AspNetUserClaims",
101 + schema: "users",
102 + columns: table => new
103 + {
104 + Id = table.Column<int>(type: "integer", nullable: false)
105 + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
106 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
107 + ClaimType = table.Column<string>(type: "text", nullable: true),
108 + ClaimValue = table.Column<string>(type: "text", nullable: true)
109 + },
110 + constraints: table =>
111 + {
112 + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
113 + table.ForeignKey(
114 + name: "FK_AspNetUserClaims_AspNetUsers_UserId",
115 + column: x => x.UserId,
116 + principalSchema: "users",
117 + principalTable: "AspNetUsers",
118 + principalColumn: "Id",
119 + onDelete: ReferentialAction.Restrict);
120 + });
121 +
122 + migrationBuilder.CreateTable(
123 + name: "AspNetUserLogins",
124 + schema: "users",
125 + columns: table => new
126 + {
127 + LoginProvider = table.Column<string>(type: "text", nullable: false),
128 + ProviderKey = table.Column<string>(type: "text", nullable: false),
129 + ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
130 + UserId = table.Column<Guid>(type: "uuid", nullable: false)
131 + },
132 + constraints: table =>
133 + {
134 + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
135 + table.ForeignKey(
136 + name: "FK_AspNetUserLogins_AspNetUsers_UserId",
137 + column: x => x.UserId,
138 + principalSchema: "users",
139 + principalTable: "AspNetUsers",
140 + principalColumn: "Id",
141 + onDelete: ReferentialAction.Restrict);
142 + });
143 +
144 + migrationBuilder.CreateTable(
145 + name: "AspNetUserRoles",
146 + schema: "users",
147 + columns: table => new
148 + {
149 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
150 + RoleId = table.Column<Guid>(type: "uuid", nullable: false)
151 + },
152 + constraints: table =>
153 + {
154 + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
155 + table.ForeignKey(
156 + name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
157 + column: x => x.RoleId,
158 + principalSchema: "users",
159 + principalTable: "AspNetRoles",
160 + principalColumn: "Id",
161 + onDelete: ReferentialAction.Restrict);
162 + table.ForeignKey(
163 + name: "FK_AspNetUserRoles_AspNetUsers_UserId",
164 + column: x => x.UserId,
165 + principalSchema: "users",
166 + principalTable: "AspNetUsers",
167 + principalColumn: "Id",
168 + onDelete: ReferentialAction.Restrict);
169 + });
170 +
171 + migrationBuilder.CreateTable(
172 + name: "AspNetUserTokens",
173 + schema: "users",
174 + columns: table => new
175 + {
176 + UserId = table.Column<Guid>(type: "uuid", nullable: false),
177 + LoginProvider = table.Column<string>(type: "text", nullable: false),
178 + Name = table.Column<string>(type: "text", nullable: false),
179 + Value = table.Column<string>(type: "text", nullable: true)
180 + },
181 + constraints: table =>
182 + {
183 + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
184 + table.ForeignKey(
185 + name: "FK_AspNetUserTokens_AspNetUsers_UserId",
186 + column: x => x.UserId,
187 + principalSchema: "users",
188 + principalTable: "AspNetUsers",
189 + principalColumn: "Id",
190 + onDelete: ReferentialAction.Restrict);
191 + });
192 +
193 + migrationBuilder.CreateTable(
194 + name: "RefreshTokens",
195 + schema: "users",
196 + columns: table => new
197 + {
198 + Id = table.Column<Guid>(type: "uuid", nullable: false),
199 + RefreshToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
200 + ExpirationDT = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
201 + PreviousRefreshToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
202 + PreviousExpirationDT = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
203 + AppUserId = table.Column<Guid>(type: "uuid", nullable: false),
204 + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
205 + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
206 + },
207 + constraints: table =>
208 + {
209 + table.PrimaryKey("PK_RefreshTokens", x => x.Id);
210 + table.ForeignKey(
211 + name: "FK_RefreshTokens_AspNetUsers_AppUserId",
212 + column: x => x.AppUserId,
213 + principalSchema: "users",
214 + principalTable: "AspNetUsers",
215 + principalColumn: "Id",
216 + onDelete: ReferentialAction.Restrict);
217 + });
218 +
219 + migrationBuilder.CreateIndex(
220 + name: "IX_AspNetRoleClaims_RoleId",
221 + schema: "users",
222 + table: "AspNetRoleClaims",
223 + column: "RoleId");
224 +
225 + migrationBuilder.CreateIndex(
226 + name: "RoleNameIndex",
227 + schema: "users",
228 + table: "AspNetRoles",
229 + column: "NormalizedName",
230 + unique: true);
231 +
232 + migrationBuilder.CreateIndex(
233 + name: "IX_AspNetUserClaims_UserId",
234 + schema: "users",
235 + table: "AspNetUserClaims",
236 + column: "UserId");
237 +
238 + migrationBuilder.CreateIndex(
239 + name: "IX_AspNetUserLogins_UserId",
240 + schema: "users",
241 + table: "AspNetUserLogins",
242 + column: "UserId");
243 +
244 + migrationBuilder.CreateIndex(
245 + name: "IX_AspNetUserRoles_RoleId",
246 + schema: "users",
247 + table: "AspNetUserRoles",
248 + column: "RoleId");
249 +
250 + migrationBuilder.CreateIndex(
251 + name: "EmailIndex",
252 + schema: "users",
253 + table: "AspNetUsers",
254 + column: "NormalizedEmail");
255 +
256 + migrationBuilder.CreateIndex(
257 + name: "UserNameIndex",
258 + schema: "users",
259 + table: "AspNetUsers",
260 + column: "NormalizedUserName",
261 + unique: true);
262 +
263 + migrationBuilder.CreateIndex(
264 + name: "IX_RefreshTokens_AppUserId",
265 + schema: "users",
266 + table: "RefreshTokens",
267 + column: "AppUserId");
268 + }
269 +
270 + /// <inheritdoc />
271 + protected override void Down(MigrationBuilder migrationBuilder)
272 + {
273 + migrationBuilder.DropTable(
274 + name: "AspNetRoleClaims",
275 + schema: "users");
276 +
277 + migrationBuilder.DropTable(
278 + name: "AspNetUserClaims",
279 + schema: "users");
280 +
281 + migrationBuilder.DropTable(
282 + name: "AspNetUserLogins",
283 + schema: "users");
284 +
285 + migrationBuilder.DropTable(
286 + name: "AspNetUserRoles",
287 + schema: "users");
288 +
289 + migrationBuilder.DropTable(
290 + name: "AspNetUserTokens",
291 + schema: "users");
292 +
293 + migrationBuilder.DropTable(
294 + name: "DataProtectionKeys",
295 + schema: "users");
296 +
297 + migrationBuilder.DropTable(
298 + name: "RefreshTokens",
299 + schema: "users");
300 +
301 + migrationBuilder.DropTable(
302 + name: "AspNetRoles",
303 + schema: "users");
304 +
305 + migrationBuilder.DropTable(
306 + name: "AspNetUsers",
307 + schema: "users");
308 + }
309 + }
310 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs +356 −0
@@ -0,0 +1,356 @@
1 +// <auto-generated />
2 +using System;
3 +using Microsoft.EntityFrameworkCore;
4 +using Microsoft.EntityFrameworkCore.Infrastructure;
5 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
6 +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
7 +using SplitApp.Modules.Users.Infrastructure.Persistence;
8 +
9 +#nullable disable
10 +
11 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Migrations
12 +{
13 + [DbContext(typeof(UsersDbContext))]
14 + partial class UsersDbContextModelSnapshot : ModelSnapshot
15 + {
16 + protected override void BuildModel(ModelBuilder modelBuilder)
17 + {
18 +#pragma warning disable 612, 618
19 + modelBuilder
20 + .HasDefaultSchema("users")
21 + .HasAnnotation("ProductVersion", "10.0.5")
22 + .HasAnnotation("Relational:MaxIdentifierLength", 63);
23 +
24 + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
25 +
26 + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
27 + {
28 + b.Property<int>("Id")
29 + .ValueGeneratedOnAdd()
30 + .HasColumnType("integer");
31 +
32 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
33 +
34 + b.Property<string>("FriendlyName")
35 + .HasColumnType("text");
36 +
37 + b.Property<string>("Xml")
38 + .HasColumnType("text");
39 +
40 + b.HasKey("Id");
41 +
42 + b.ToTable("DataProtectionKeys", "users");
43 + });
44 +
45 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
46 + {
47 + b.Property<int>("Id")
48 + .ValueGeneratedOnAdd()
49 + .HasColumnType("integer");
50 +
51 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
52 +
53 + b.Property<string>("ClaimType")
54 + .HasColumnType("text");
55 +
56 + b.Property<string>("ClaimValue")
57 + .HasColumnType("text");
58 +
59 + b.Property<Guid>("RoleId")
60 + .HasColumnType("uuid");
61 +
62 + b.HasKey("Id");
63 +
64 + b.HasIndex("RoleId");
65 +
66 + b.ToTable("AspNetRoleClaims", "users");
67 + });
68 +
69 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
70 + {
71 + b.Property<int>("Id")
72 + .ValueGeneratedOnAdd()
73 + .HasColumnType("integer");
74 +
75 + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
76 +
77 + b.Property<string>("ClaimType")
78 + .HasColumnType("text");
79 +
80 + b.Property<string>("ClaimValue")
81 + .HasColumnType("text");
82 +
83 + b.Property<Guid>("UserId")
84 + .HasColumnType("uuid");
85 +
86 + b.HasKey("Id");
87 +
88 + b.HasIndex("UserId");
89 +
90 + b.ToTable("AspNetUserClaims", "users");
91 + });
92 +
93 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
94 + {
95 + b.Property<string>("LoginProvider")
96 + .HasColumnType("text");
97 +
98 + b.Property<string>("ProviderKey")
99 + .HasColumnType("text");
100 +
101 + b.Property<string>("ProviderDisplayName")
102 + .HasColumnType("text");
103 +
104 + b.Property<Guid>("UserId")
105 + .HasColumnType("uuid");
106 +
107 + b.HasKey("LoginProvider", "ProviderKey");
108 +
109 + b.HasIndex("UserId");
110 +
111 + b.ToTable("AspNetUserLogins", "users");
112 + });
113 +
114 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
115 + {
116 + b.Property<Guid>("UserId")
117 + .HasColumnType("uuid");
118 +
119 + b.Property<Guid>("RoleId")
120 + .HasColumnType("uuid");
121 +
122 + b.HasKey("UserId", "RoleId");
123 +
124 + b.HasIndex("RoleId");
125 +
126 + b.ToTable("AspNetUserRoles", "users");
127 + });
128 +
129 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
130 + {
131 + b.Property<Guid>("UserId")
132 + .HasColumnType("uuid");
133 +
134 + b.Property<string>("LoginProvider")
135 + .HasColumnType("text");
136 +
137 + b.Property<string>("Name")
138 + .HasColumnType("text");
139 +
140 + b.Property<string>("Value")
141 + .HasColumnType("text");
142 +
143 + b.HasKey("UserId", "LoginProvider", "Name");
144 +
145 + b.ToTable("AspNetUserTokens", "users");
146 + });
147 +
148 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRefreshToken", b =>
149 + {
150 + b.Property<Guid>("Id")
151 + .ValueGeneratedOnAdd()
152 + .HasColumnType("uuid");
153 +
154 + b.Property<Guid>("AppUserId")
155 + .HasColumnType("uuid");
156 +
157 + b.Property<DateTime>("CreatedAt")
158 + .HasColumnType("timestamp with time zone");
159 +
160 + b.Property<DateTime>("ExpirationDT")
161 + .HasColumnType("timestamp with time zone");
162 +
163 + b.Property<DateTime>("PreviousExpirationDT")
164 + .HasColumnType("timestamp with time zone");
165 +
166 + b.Property<string>("PreviousRefreshToken")
167 + .HasMaxLength(64)
168 + .HasColumnType("character varying(64)");
169 +
170 + b.Property<string>("RefreshToken")
171 + .IsRequired()
172 + .HasMaxLength(64)
173 + .HasColumnType("character varying(64)");
174 +
175 + b.Property<DateTime>("UpdatedAt")
176 + .HasColumnType("timestamp with time zone");
177 +
178 + b.HasKey("Id");
179 +
180 + b.HasIndex("AppUserId");
181 +
182 + b.ToTable("RefreshTokens", "users");
183 + });
184 +
185 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRole", b =>
186 + {
187 + b.Property<Guid>("Id")
188 + .ValueGeneratedOnAdd()
189 + .HasColumnType("uuid");
190 +
191 + b.Property<string>("ConcurrencyStamp")
192 + .IsConcurrencyToken()
193 + .HasColumnType("text");
194 +
195 + b.Property<string>("Name")
196 + .HasMaxLength(256)
197 + .HasColumnType("character varying(256)");
198 +
199 + b.Property<string>("NormalizedName")
200 + .HasMaxLength(256)
201 + .HasColumnType("character varying(256)");
202 +
203 + b.HasKey("Id");
204 +
205 + b.HasIndex("NormalizedName")
206 + .IsUnique()
207 + .HasDatabaseName("RoleNameIndex");
208 +
209 + b.ToTable("AspNetRoles", "users");
210 + });
211 +
212 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppUser", b =>
213 + {
214 + b.Property<Guid>("Id")
215 + .ValueGeneratedOnAdd()
216 + .HasColumnType("uuid");
217 +
218 + b.Property<int>("AccessFailedCount")
219 + .HasColumnType("integer");
220 +
221 + b.Property<string>("ConcurrencyStamp")
222 + .IsConcurrencyToken()
223 + .HasColumnType("text");
224 +
225 + b.Property<string>("Email")
226 + .HasMaxLength(256)
227 + .HasColumnType("character varying(256)");
228 +
229 + b.Property<bool>("EmailConfirmed")
230 + .HasColumnType("boolean");
231 +
232 + b.Property<string>("FirstName")
233 + .IsRequired()
234 + .HasMaxLength(128)
235 + .HasColumnType("character varying(128)");
236 +
237 + b.Property<string>("LastName")
238 + .IsRequired()
239 + .HasMaxLength(128)
240 + .HasColumnType("character varying(128)");
241 +
242 + b.Property<bool>("LockoutEnabled")
243 + .HasColumnType("boolean");
244 +
245 + b.Property<DateTimeOffset?>("LockoutEnd")
246 + .HasColumnType("timestamp with time zone");
247 +
248 + b.Property<string>("NormalizedEmail")
249 + .HasMaxLength(256)
250 + .HasColumnType("character varying(256)");
251 +
252 + b.Property<string>("NormalizedUserName")
253 + .HasMaxLength(256)
254 + .HasColumnType("character varying(256)");
255 +
256 + b.Property<string>("PasswordHash")
257 + .HasColumnType("text");
258 +
259 + b.Property<string>("PhoneNumber")
260 + .HasColumnType("text");
261 +
262 + b.Property<bool>("PhoneNumberConfirmed")
263 + .HasColumnType("boolean");
264 +
265 + b.Property<string>("SecurityStamp")
266 + .HasColumnType("text");
267 +
268 + b.Property<bool>("TwoFactorEnabled")
269 + .HasColumnType("boolean");
270 +
271 + b.Property<string>("UserName")
272 + .HasMaxLength(256)
273 + .HasColumnType("character varying(256)");
274 +
275 + b.HasKey("Id");
276 +
277 + b.HasIndex("NormalizedEmail")
278 + .HasDatabaseName("EmailIndex");
279 +
280 + b.HasIndex("NormalizedUserName")
281 + .IsUnique()
282 + .HasDatabaseName("UserNameIndex");
283 +
284 + b.ToTable("AspNetUsers", "users");
285 + });
286 +
287 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
288 + {
289 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppRole", null)
290 + .WithMany()
291 + .HasForeignKey("RoleId")
292 + .OnDelete(DeleteBehavior.Restrict)
293 + .IsRequired();
294 + });
295 +
296 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
297 + {
298 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
299 + .WithMany()
300 + .HasForeignKey("UserId")
301 + .OnDelete(DeleteBehavior.Restrict)
302 + .IsRequired();
303 + });
304 +
305 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
306 + {
307 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
308 + .WithMany()
309 + .HasForeignKey("UserId")
310 + .OnDelete(DeleteBehavior.Restrict)
311 + .IsRequired();
312 + });
313 +
314 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
315 + {
316 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppRole", null)
317 + .WithMany()
318 + .HasForeignKey("RoleId")
319 + .OnDelete(DeleteBehavior.Restrict)
320 + .IsRequired();
321 +
322 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
323 + .WithMany()
324 + .HasForeignKey("UserId")
325 + .OnDelete(DeleteBehavior.Restrict)
326 + .IsRequired();
327 + });
328 +
329 + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
330 + {
331 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", null)
332 + .WithMany()
333 + .HasForeignKey("UserId")
334 + .OnDelete(DeleteBehavior.Restrict)
335 + .IsRequired();
336 + });
337 +
338 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppRefreshToken", b =>
339 + {
340 + b.HasOne("SplitApp.Modules.Users.Domain.Entities.AppUser", "AppUser")
341 + .WithMany("RefreshTokens")
342 + .HasForeignKey("AppUserId")
343 + .OnDelete(DeleteBehavior.Restrict)
344 + .IsRequired();
345 +
346 + b.Navigation("AppUser");
347 + });
348 +
349 + modelBuilder.Entity("SplitApp.Modules.Users.Domain.Entities.AppUser", b =>
350 + {
351 + b.Navigation("RefreshTokens");
352 + });
353 +#pragma warning restore 612, 618
354 + }
355 + }
356 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/BaseRepository.cs +29 −0
@@ -0,0 +1,29 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Shared.Kernel.Domain;
3 +using SplitApp.Shared.Kernel.Persistence;
4 +
5 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Repositories;
6 +
7 +public class BaseRepository<TEntity> : IBaseRepository<TEntity> where TEntity : class, IBaseEntity
8 +{
9 + protected readonly UsersDbContext DbContext;
10 + protected readonly DbSet<TEntity> DbSet;
11 +
12 + public BaseRepository(UsersDbContext dbContext)
13 + {
14 + DbContext = dbContext;
15 + DbSet = dbContext.Set<TEntity>();
16 + }
17 +
18 + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await DbSet.ToListAsync();
19 + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await DbSet.FirstOrDefaultAsync(e => e.Id == id);
20 + public virtual TEntity Add(TEntity entity) => DbSet.Add(entity).Entity;
21 + public virtual TEntity Update(TEntity entity) => DbSet.Update(entity).Entity;
22 + public virtual async Task<TEntity?> RemoveAsync(Guid id)
23 + {
24 + var entity = await GetByIdAsync(id);
25 + if (entity == null) return null;
26 + return DbSet.Remove(entity).Entity;
27 + }
28 + public virtual async Task<bool> ExistsAsync(Guid id) => await DbSet.AnyAsync(e => e.Id == id);
29 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs +42 −0
@@ -0,0 +1,42 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Users.Application.Contracts;
3 +using SplitApp.Modules.Users.Domain.Entities;
4 +
5 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Repositories;
6 +
7 +public class RefreshTokenRepository : BaseRepository<AppRefreshToken>, IRefreshTokenRepository
8 +{
9 + public RefreshTokenRepository(UsersDbContext dbContext) : base(dbContext)
10 + {
11 + }
12 +
13 + public async Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue)
14 + {
15 + var now = DateTime.UtcNow;
16 + return await DbContext.RefreshTokens
17 + .Where(t => t.AppUserId == userId &&
18 + ((t.RefreshToken == refreshTokenValue && t.ExpirationDT > now) ||
19 + (t.PreviousRefreshToken == refreshTokenValue && t.PreviousExpirationDT > now)))
20 + .ToListAsync();
21 + }
22 +
23 + public async Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue)
24 + {
25 + return await DbContext.RefreshTokens
26 + .Where(t => t.AppUserId == userId &&
27 + (t.RefreshToken == refreshTokenValue || t.PreviousRefreshToken == refreshTokenValue))
28 + .ToListAsync();
29 + }
30 +
31 + public async Task<int> RemoveExpiredForUserAsync(Guid userId)
32 + {
33 + return await DbContext.RefreshTokens
34 + .Where(t => t.AppUserId == userId && t.ExpirationDT < DateTime.UtcNow)
35 + .ExecuteDeleteAsync();
36 + }
37 +
38 + public void Remove(AppRefreshToken token)
39 + {
40 + DbContext.RefreshTokens.Remove(token);
41 + }
42 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/UserRepository.cs +40 −0
@@ -0,0 +1,40 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Users.Application.Contracts;
3 +using SplitApp.Modules.Users.Domain.Entities;
4 +
5 +namespace SplitApp.Modules.Users.Infrastructure.Persistence.Repositories;
6 +
7 +public class UserRepository : BaseRepository<AppUser>, IUserRepository
8 +{
9 + public UserRepository(UsersDbContext dbContext) : base(dbContext)
10 + {
11 + }
12 +
13 + public async Task<int> CountAsync()
14 + {
15 + return await DbContext.Users.CountAsync();
16 + }
17 +
18 + public async Task<IEnumerable<AppUser>> GetRecentAsync(int take)
19 + {
20 + return await DbContext.Users
21 + .OrderByDescending(u => u.Id)
22 + .Take(take)
23 + .ToListAsync();
24 + }
25 +
26 + public async Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId)
27 + {
28 + return await DbContext.Users
29 + .Include(u => u.RefreshTokens)
30 + .FirstOrDefaultAsync(u => u.Id == userId);
31 + }
32 +
33 + public async Task<IReadOnlyList<AppUser>> GetByIdsAsync(IReadOnlyCollection<Guid> ids)
34 + {
35 + if (ids.Count == 0) return Array.Empty<AppUser>();
36 + return await DbContext.Users
37 + .Where(u => ids.Contains(u.Id))
38 + .ToListAsync();
39 + }
40 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersDbContext.cs +66 −0
@@ -0,0 +1,66 @@
1 +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
2 +using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
3 +using Microsoft.EntityFrameworkCore;
4 +using SplitApp.Modules.Users.Domain.Entities;
5 +using SplitApp.Shared.Kernel.Domain;
6 +
7 +namespace SplitApp.Modules.Users.Infrastructure.Persistence;
8 +
9 +public class UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>, IDataProtectionKeyContext
10 +{
11 + public DbSet<AppRefreshToken> RefreshTokens { get; set; } = default!;
12 + public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = default!;
13 +
14 + public UsersDbContext(DbContextOptions<UsersDbContext> options) : base(options)
15 + {
16 + }
17 +
18 + public override int SaveChanges()
19 + {
20 + UpdateTimestamps();
21 + return base.SaveChanges();
22 + }
23 +
24 + public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
25 + {
26 + UpdateTimestamps();
27 + return base.SaveChangesAsync(cancellationToken);
28 + }
29 +
30 + private void UpdateTimestamps()
31 + {
32 + var entries = ChangeTracker.Entries<BaseEntity>();
33 + foreach (var entry in entries)
34 + {
35 + if (entry.State == EntityState.Modified)
36 + {
37 + entry.Entity.UpdatedAt = DateTime.UtcNow;
38 + }
39 + }
40 + }
41 +
42 + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
43 + {
44 + base.ConfigureConventions(configurationBuilder);
45 + configurationBuilder.Properties<DateTime>().HaveConversion<UtcDateTimeConverter>();
46 + }
47 +
48 + protected override void OnModelCreating(ModelBuilder builder)
49 + {
50 + base.OnModelCreating(builder);
51 +
52 + builder.HasDefaultSchema("users");
53 +
54 + foreach (var relationship in builder.Model
55 + .GetEntityTypes()
56 + .SelectMany(e => e.GetForeignKeys()))
57 + {
58 + relationship.DeleteBehavior = DeleteBehavior.Restrict;
59 + }
60 +
61 + builder.Entity<AppRefreshToken>()
62 + .HasOne(rt => rt.AppUser)
63 + .WithMany(u => u.RefreshTokens)
64 + .HasForeignKey(rt => rt.AppUserId);
65 + }
66 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersUnitOfWork.cs +23 −0
@@ -0,0 +1,23 @@
1 +using SplitApp.Modules.Users.Application.Contracts;
2 +
3 +namespace SplitApp.Modules.Users.Infrastructure.Persistence;
4 +
5 +public class UsersUnitOfWork : IUsersUnitOfWork
6 +{
7 + private readonly UsersDbContext _db;
8 +
9 + public UsersUnitOfWork(
10 + UsersDbContext db,
11 + IUserRepository users,
12 + IRefreshTokenRepository refreshTokens)
13 + {
14 + _db = db;
15 + Users = users;
16 + RefreshTokens = refreshTokens;
17 + }
18 +
19 + public IUserRepository Users { get; }
20 + public IRefreshTokenRepository RefreshTokens { get; }
21 +
22 + public Task<int> SaveChangesAsync() => _db.SaveChangesAsync();
23 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
@@ -0,0 +1,14 @@
1 +using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
2 +
3 +namespace SplitApp.Modules.Users.Infrastructure.Persistence;
4 +
5 +public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
6 +{
7 + public UtcDateTimeConverter() : base(
8 + v => v.Kind == DateTimeKind.Unspecified
9 + ? DateTime.SpecifyKind(v, DateTimeKind.Utc)
10 + : v.ToUniversalTime(),
11 + v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
12 + {
13 + }
14 +}
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj +34 −0
@@ -0,0 +1,34 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj" />
5 + <ProjectReference Include="..\SplitApp.Modules.Users.Application\SplitApp.Modules.Users.Application.csproj" />
6 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
7 + </ItemGroup>
8 +
9 + <ItemGroup>
10 + <FrameworkReference Include="Microsoft.AspNetCore.App" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="MediatR" Version="12.4.1" />
15 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
16 + <PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
18 + <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.5" />
19 + <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
20 + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
21 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
22 + <PrivateAssets>all</PrivateAssets>
23 + </PackageReference>
24 + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.5" />
25 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
26 + </ItemGroup>
27 +
28 + <PropertyGroup>
29 + <TargetFramework>net10.0</TargetFramework>
30 + <ImplicitUsings>enable</ImplicitUsings>
31 + <Nullable>enable</Nullable>
32 + </PropertyGroup>
33 +
34 +</Project>
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/UsersModuleExtensions.cs +141 −0
@@ -0,0 +1,141 @@
1 +using System.Text;
2 +using Microsoft.AspNetCore.Authentication.JwtBearer;
3 +using Microsoft.AspNetCore.Builder;
4 +using Microsoft.AspNetCore.DataProtection;
5 +using Microsoft.AspNetCore.Identity;
6 +using Microsoft.EntityFrameworkCore;
7 +using Microsoft.Extensions.Configuration;
8 +using Microsoft.Extensions.DependencyInjection;
9 +using Microsoft.IdentityModel.Tokens;
10 +using SplitApp.Modules.Users.Application;
11 +using SplitApp.Modules.Users.Application.Contracts;
12 +using SplitApp.Modules.Users.Application.Services;
13 +using SplitApp.Modules.Users.Domain.Entities;
14 +using SplitApp.Modules.Users.Infrastructure.Persistence;
15 +using SplitApp.Modules.Users.Infrastructure.Persistence.Repositories;
16 +
17 +namespace SplitApp.Modules.Users.Infrastructure;
18 +
19 +public static class UsersModuleExtensions
20 +{
21 + public static IServiceCollection AddUsersModule(
22 + this IServiceCollection services,
23 + IConfiguration configuration)
24 + {
25 + services.AddDbContext<UsersDbContext>(opt =>
26 + {
27 + opt.UseNpgsql(configuration.GetConnectionString("DefaultConnection"));
28 + });
29 +
30 + services.AddIdentity<AppUser, AppRole>(options =>
31 + {
32 + options.Password.RequireDigit = false;
33 + options.Password.RequireLowercase = false;
34 + options.Password.RequireNonAlphanumeric = false;
35 + options.Password.RequireUppercase = false;
36 + options.Password.RequiredLength = 6;
37 + options.User.RequireUniqueEmail = true;
38 + })
39 + .AddDefaultUI()
40 + .AddEntityFrameworkStores<UsersDbContext>()
41 + .AddDefaultTokenProviders();
42 +
43 + services.AddDataProtection().PersistKeysToDbContext<UsersDbContext>();
44 +
45 + services.AddAuthentication()
46 + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
47 + {
48 + options.RequireHttpsMetadata = false;
49 + options.SaveToken = false;
50 + options.TokenValidationParameters = new TokenValidationParameters
51 + {
52 + ValidateIssuer = true,
53 + ValidateAudience = true,
54 + ValidateLifetime = true,
55 + ValidateIssuerSigningKey = true,
56 + ValidIssuer = configuration["JWT:Issuer"],
57 + ValidAudience = configuration["JWT:Audience"],
58 + IssuerSigningKey = new SymmetricSecurityKey(
59 + Encoding.UTF8.GetBytes(configuration["JWT:Key"]!)),
60 + ClockSkew = TimeSpan.Zero
61 + };
62 + });
63 +
64 + services.AddScoped<IUserRepository, UserRepository>();
65 + services.AddScoped<IRefreshTokenRepository, RefreshTokenRepository>();
66 + services.AddScoped<IUsersUnitOfWork, UsersUnitOfWork>();
67 + services.AddScoped<IIdentityService, IdentityService>();
68 +
69 + services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining<UsersModuleMarker>());
70 +
71 + return services;
72 + }
73 +
74 + public static IApplicationBuilder UseUsersModule(this IApplicationBuilder app)
75 + {
76 + using var scope = app.ApplicationServices.CreateScope();
77 + var db = scope.ServiceProvider.GetRequiredService<UsersDbContext>();
78 + db.Database.Migrate();
79 +
80 + var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<AppRole>>();
81 + foreach (var roleName in new[] { "admin", "user" })
82 + {
83 + if (!roleManager.RoleExistsAsync(roleName).GetAwaiter().GetResult())
84 + {
85 + roleManager.CreateAsync(new AppRole { Name = roleName }).GetAwaiter().GetResult();
86 + }
87 + }
88 +
89 + var userManager = scope.ServiceProvider.GetRequiredService<UserManager<AppUser>>();
90 +
91 + // The demo password for the ordinary accounts is in the source on
92 + // purpose: the running instance is a demo and anyone reading the code is
93 + // meant to be able to sign in and look around.
94 + //
95 + // The administrator is not. That account can change other people's data,
96 + // and this source is shared read-only with people outside the project, so
97 + // its password comes from SEED_ADMIN_PASSWORD and there is no default.
98 + // No variable, no administrator: seeding skips the account rather than
99 + // falling back to something guessable.
100 + const string demoPassword = "Kala.12345";
101 + var adminPassword = Environment.GetEnvironmentVariable("SEED_ADMIN_PASSWORD");
102 +
103 + var seedUsers = new[]
104 + {
105 + (Email: "user@example.com", Password: demoPassword, FirstName: "Test", LastName: "User", Roles: new[] { "user" }),
106 + (Email: "alice@example.com", Password: demoPassword, FirstName: "Alice", LastName: "Johnson", Roles: new[] { "user" }),
107 + (Email: "bob@example.com", Password: demoPassword, FirstName: "Bob", LastName: "Smith", Roles: new[] { "user" }),
108 + (Email: "charlie@example.com", Password: demoPassword, FirstName: "Charlie", LastName: "Brown", Roles: new[] { "user" }),
109 + (Email: "diana@example.com", Password: demoPassword, FirstName: "Diana", LastName: "Miller", Roles: new[] { "user" }),
110 + };
111 +
112 + if (!string.IsNullOrWhiteSpace(adminPassword))
113 + {
114 + seedUsers =
115 + [
116 + (Email: "admin@example.com", Password: adminPassword, FirstName: "Admin", LastName: "User", Roles: new[] { "admin" }),
117 + .. seedUsers,
118 + ];
119 + }
120 +
121 + foreach (var seed in seedUsers)
122 + {
123 + var existing = userManager.FindByEmailAsync(seed.Email).GetAwaiter().GetResult();
124 + if (existing != null) continue;
125 +
126 + var user = new AppUser
127 + {
128 + UserName = seed.Email,
129 + Email = seed.Email,
130 + EmailConfirmed = true,
131 + FirstName = seed.FirstName,
132 + LastName = seed.LastName,
133 + };
134 + var result = userManager.CreateAsync(user, seed.Password).GetAwaiter().GetResult();
135 + if (!result.Succeeded) continue;
136 + userManager.AddToRolesAsync(user, seed.Roles).GetAwaiter().GetResult();
137 + }
138 +
139 + return app;
140 + }
141 +}
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Controllers/HealthController.cs +19 −0
@@ -0,0 +1,19 @@
1 +using Microsoft.AspNetCore.Mvc;
2 +using SplitApp.UsersService.Hosting;
3 +
4 +namespace SplitApp.UsersService.Controllers;
5 +
6 +/// <summary>Non-versioned health endpoint consumed by Docker compose's healthcheck.</summary>
7 +[ApiController]
8 +[Route("health")]
9 +public class HealthController : ControllerBase
10 +{
11 + private readonly SeededHealthState _state;
12 +
13 + public HealthController(SeededHealthState state) => _state = state;
14 +
15 + [HttpGet]
16 + public IActionResult Get() => _state.IsSeeded
17 + ? Ok(new { status = "healthy" })
18 + : StatusCode(503, new { status = "starting" });
19 +}
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Hosting/ConfigureSwaggerOptions.cs +58 −0
@@ -0,0 +1,58 @@
1 +using Asp.Versioning.ApiExplorer;
2 +using Microsoft.Extensions.Options;
3 +using Microsoft.OpenApi;
4 +using Swashbuckle.AspNetCore.SwaggerGen;
5 +
6 +namespace SplitApp.UsersService.Hosting;
7 +
8 +public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
9 +{
10 + private readonly IApiVersionDescriptionProvider _descriptionProvider;
11 +
12 + public ConfigureSwaggerOptions(IApiVersionDescriptionProvider descriptionProvider)
13 + {
14 + _descriptionProvider = descriptionProvider;
15 + }
16 +
17 + public void Configure(SwaggerGenOptions options)
18 + {
19 + foreach (var description in _descriptionProvider.ApiVersionDescriptions)
20 + {
21 + options.SwaggerDoc(
22 + description.GroupName,
23 + new OpenApiInfo
24 + {
25 + Title = $"SplitApp Users Service {description.ApiVersion}",
26 + Version = description.ApiVersion.ToString(),
27 + });
28 + }
29 +
30 + options.CustomSchemaIds(t => t.FullName);
31 +
32 + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
33 + {
34 + Description = "JWT Authorization header using the Bearer scheme.",
35 + Name = "Authorization",
36 + In = ParameterLocation.Header,
37 + Type = SecuritySchemeType.Http,
38 + Scheme = "Bearer",
39 + BearerFormat = "JWT",
40 + });
41 +
42 + options.DocumentFilter<BearerSecurityRequirementDocumentFilter>();
43 + }
44 +}
45 +
46 +public class BearerSecurityRequirementDocumentFilter : IDocumentFilter
47 +{
48 + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
49 + {
50 + swaggerDoc.Security = new List<OpenApiSecurityRequirement>
51 + {
52 + new()
53 + {
54 + [new OpenApiSecuritySchemeReference("Bearer", swaggerDoc)] = new List<string>(),
55 + },
56 + };
57 + }
58 +}
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Hosting/SeededHealthState.cs +13 −0
@@ -0,0 +1,13 @@
1 +namespace SplitApp.UsersService.Hosting;
2 +
3 +/// <summary>
4 +/// Tiny singleton flag flipped by Program.cs after `UseUsersModule()` (migrate + seed) finishes.
5 +/// Read by <see cref="Controllers.HealthController"/> so the compose healthcheck reports
6 +/// "starting" until seeding is done — webapp's depends_on: service_healthy then unblocks cleanly.
7 +/// </summary>
8 +public class SeededHealthState
9 +{
10 + private volatile bool _seeded;
11 + public bool IsSeeded => _seeded;
12 + public void MarkSeeded() => _seeded = true;
13 +}
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Messaging/GetUserByIdRequestHandler.cs +19 −0
@@ -0,0 +1,19 @@
1 +using SplitApp.Modules.Users.Application.Contracts;
2 +using SplitApp.Shared.Contracts.Users;
3 +using SplitApp.Shared.Messaging;
4 +using SplitApp.Shared.Messaging.Integration.Users;
5 +
6 +namespace SplitApp.UsersService.Messaging;
7 +
8 +public class GetUserByIdRequestHandler : IRequestHandler<GetUserByIdRequest, UserDto?>
9 +{
10 + private readonly IUserRepository _users;
11 +
12 + public GetUserByIdRequestHandler(IUserRepository users) => _users = users;
13 +
14 + public async Task<UserDto?> HandleAsync(GetUserByIdRequest request, CancellationToken cancellationToken)
15 + {
16 + var user = await _users.GetByIdAsync(request.UserId);
17 + return user is null ? null : UserDtoMapper.ToDto(user);
18 + }
19 +}
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Messaging/GetUsersByIdsRequestHandler.cs +21 −0
@@ -0,0 +1,21 @@
1 +using SplitApp.Modules.Users.Application.Contracts;
2 +using SplitApp.Shared.Contracts.Users;
3 +using SplitApp.Shared.Messaging;
4 +using SplitApp.Shared.Messaging.Integration.Users;
5 +
6 +namespace SplitApp.UsersService.Messaging;
7 +
8 +public class GetUsersByIdsRequestHandler : IRequestHandler<GetUsersByIdsRequest, UserDto[]>
9 +{
10 + private readonly IUserRepository _users;
11 +
12 + public GetUsersByIdsRequestHandler(IUserRepository users) => _users = users;
13 +
14 + public async Task<UserDto[]> HandleAsync(GetUsersByIdsRequest request, CancellationToken cancellationToken)
15 + {
16 + if (request.UserIds.Length == 0) return Array.Empty<UserDto>();
17 +
18 + var users = await _users.GetByIdsAsync(request.UserIds);
19 + return users.Select(UserDtoMapper.ToDto).ToArray();
20 + }
21 +}
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Messaging/UserDtoMapper.cs +12 −0
@@ -0,0 +1,12 @@
1 +using SplitApp.Modules.Users.Domain.Entities;
2 +using SplitApp.Shared.Contracts.Users;
3 +
4 +namespace SplitApp.UsersService.Messaging;
5 +
6 +internal static class UserDtoMapper
7 +{
8 + public static UserDto ToDto(AppUser user) => new(
9 + user.Id,
10 + $"{user.FirstName} {user.LastName}".Trim(),
11 + user.Email ?? "");
12 +}
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Program.cs +94 −0
@@ -0,0 +1,94 @@
1 +using Asp.Versioning;
2 +using Asp.Versioning.ApiExplorer;
3 +using Microsoft.Extensions.Options;
4 +using SplitApp.Modules.Users.Api.Controllers;
5 +using SplitApp.Modules.Users.Infrastructure;
6 +using SplitApp.Shared.Contracts.Users;
7 +using SplitApp.Shared.Messaging;
8 +using SplitApp.Shared.Messaging.Integration.Users;
9 +using SplitApp.UsersService.Hosting;
10 +using SplitApp.UsersService.Messaging;
11 +using Swashbuckle.AspNetCore.SwaggerGen;
12 +
13 +var builder = WebApplication.CreateBuilder(args);
14 +
15 +builder.Services
16 + .AddControllers()
17 + .AddApplicationPart(typeof(AccountController).Assembly);
18 +
19 +builder.Services.AddApiVersioning(options =>
20 +{
21 + options.ReportApiVersions = true;
22 + options.DefaultApiVersion = new ApiVersion(1, 0);
23 + options.AssumeDefaultVersionWhenUnspecified = true;
24 + options.ApiVersionReader = new UrlSegmentApiVersionReader();
25 +}).AddApiExplorer(options =>
26 +{
27 + options.GroupNameFormat = "'v'VVV";
28 + options.SubstituteApiVersionInUrl = true;
29 +});
30 +
31 +builder.Services.AddEndpointsApiExplorer();
32 +builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
33 +builder.Services.AddSwaggerGen();
34 +
35 +// Users module (Identity + JWT + EF + repos) — reused verbatim from the modular monolith.
36 +builder.Services.AddUsersModule(builder.Configuration);
37 +
38 +builder.Services.AddAuthorization();
39 +
40 +// CORS — same wide-open policy as the WebApp, so external SPA frontends (Vite/Vue)
41 +// can call /api/v1/identity/* directly without proxy gymnastics. The login response
42 +// returns the JWT as JSON; SPAs store it in localStorage and send it on subsequent
43 +// requests via `Authorization: Bearer ...`, so cross-subdomain cookie issues don't apply.
44 +builder.Services.AddCors(options =>
45 +{
46 + options.AddPolicy("CorsAllowAll", policy =>
47 + {
48 + policy
49 + .AllowAnyOrigin()
50 + .AllowAnyHeader()
51 + .AllowAnyMethod()
52 + .WithExposedHeaders("X-Version", "X-Version-Created-At");
53 + });
54 +});
55 +
56 +// Messaging: this process advertises serviceName "users-service" in queue names.
57 +builder.Services.AddMessaging(builder.Configuration, serviceName: "users-service");
58 +builder.Services.AddIntegrationRequestHandler<GetUserByIdRequest, UserDto?, GetUserByIdRequestHandler>();
59 +builder.Services.AddIntegrationRequestHandler<GetUsersByIdsRequest, UserDto[], GetUsersByIdsRequestHandler>();
60 +
61 +builder.Services.AddSingleton<SeededHealthState>();
62 +
63 +var app = builder.Build();
64 +
65 +// Run migrations + seed roles/users. The compose healthcheck polls /health until this flips.
66 +app.UseUsersModule();
67 +app.Services.GetRequiredService<SeededHealthState>().MarkSeeded();
68 +
69 +if (app.Environment.IsDevelopment())
70 +{
71 + app.UseDeveloperExceptionPage();
72 +}
73 +
74 +app.UseSwagger();
75 +app.UseSwaggerUI(options =>
76 +{
77 + var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
78 + foreach (var description in provider.ApiVersionDescriptions)
79 + {
80 + options.SwaggerEndpoint(
81 + $"/swagger/{description.GroupName}/swagger.json",
82 + description.GroupName.ToUpperInvariant());
83 + }
84 +});
85 +
86 +app.UseRouting();
87 +app.UseCors("CorsAllowAll");
88 +app.UseAuthentication();
89 +app.UseAuthorization();
90 +app.MapControllers();
91 +
92 +app.Run();
93 +
94 +public partial class Program;
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/Properties/launchSettings.json +13 −0
@@ -0,0 +1,13 @@
1 +{
2 + "profiles": {
3 + "SplitApp.UsersService": {
4 + "commandName": "Project",
5 + "launchBrowser": true,
6 + "launchUrl": "swagger",
7 + "applicationUrl": "http://localhost:5198",
8 + "environmentVariables": {
9 + "ASPNETCORE_ENVIRONMENT": "Development"
10 + }
11 + }
12 + }
13 +}
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/SplitApp.UsersService.csproj +27 −0
@@ -0,0 +1,27 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Web">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <Nullable>enable</Nullable>
6 + <ImplicitUsings>enable</ImplicitUsings>
7 + <UserSecretsId>splitapp-users-service</UserSecretsId>
8 + </PropertyGroup>
9 +
10 + <ItemGroup>
11 + <ProjectReference Include="..\..\..\Modules\Users\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.csproj" />
12 + <ProjectReference Include="..\..\..\Modules\Users\SplitApp.Modules.Users.Application\SplitApp.Modules.Users.Application.csproj" />
13 + <ProjectReference Include="..\..\..\Modules\Users\SplitApp.Modules.Users.Infrastructure\SplitApp.Modules.Users.Infrastructure.csproj" />
14 + <ProjectReference Include="..\..\..\Modules\Users\SplitApp.Modules.Users.Api\SplitApp.Modules.Users.Api.csproj" />
15 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
16 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
17 + <ProjectReference Include="..\..\..\Shared\SplitApp.Shared.Messaging\SplitApp.Shared.Messaging.csproj" />
18 + </ItemGroup>
19 +
20 + <ItemGroup>
21 + <PackageReference Include="Asp.Versioning.Mvc" Version="8.1.1" />
22 + <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
23 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
24 + <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
25 + </ItemGroup>
26 +
27 +</Project>
added SplitApp.Modular/src/Services/Users/SplitApp.UsersService/appsettings.json +29 −0
@@ -0,0 +1,29 @@
1 +{
2 + "Logging": {
3 + "LogLevel": {
4 + "Default": "Information",
5 + "Microsoft.AspNetCore": "Warning",
6 + "Microsoft.EntityFrameworkCore.Database.Command": "Warning"
7 + }
8 + },
9 + "AllowedHosts": "*",
10 + "ConnectionStrings": {
11 + "DefaultConnection": "Host=localhost;Port=5432;Database=splitapp_users;Username=postgres;Password=postgres"
12 + },
13 + "JWT": {
14 + "Key": "dev-only-signing-key-override-in-production-0123456789",
15 + "Issuer": "splitapp",
16 + "Audience": "splitapp",
17 + "ExpiresInSeconds": 1800
18 + },
19 + "Messaging": {
20 + "RabbitMq": {
21 + "HostName": "localhost",
22 + "Port": 5672,
23 + "UserName": "guest",
24 + "Password": "guest",
25 + "VirtualHost": "/",
26 + "RpcReplyTimeoutSeconds": 10
27 + }
28 + }
29 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/CalculateSettlementCommand.cs +11 −0
@@ -0,0 +1,11 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Commands;
4 +
5 +/// <summary>Computes per-user balances for a trip, persists a SettlementPlan with
6 +/// minimum-payment SettlementPayments, and returns the new plan id (or null if
7 +/// nobody owes anybody anything). Same logic as POST /Settlements/trip/{id}/calculate
8 +/// — exposed as a MediatR command so the Trips module can chain it from
9 +/// Finalize without a direct project reference to Expenses.</summary>
10 +public record CalculateSettlementCommand(Guid TripId, Guid CreatedByUserId)
11 + : IRequest<Guid?>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/RemoveSettlementPlanCommand.cs +8 −0
@@ -0,0 +1,8 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Commands;
4 +
5 +/// <summary>Deletes the latest settlement plan + payments for a trip.
6 +/// Returns false if any payment in the plan is already Confirmed (caller should
7 +/// surface a "payments-confirmed" error). Used by Trip Reopen flow.</summary>
8 +public record RemoveSettlementPlanCommand(Guid TripId) : IRequest<bool>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/CurrencyDto.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Shared.Contracts.Expenses;
2 +
3 +public record CurrencyDto(
4 + Guid Id,
5 + string Code,
6 + string Symbol
7 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/ExpenseSettledEvent.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Events;
4 +
5 +public record ExpenseSettledEvent(Guid ExpenseId, Guid TripId) : INotification;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/SettlementPlanCompletedEvent.cs +10 −0
@@ -0,0 +1,10 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Events;
4 +
5 +/// <summary>
6 +/// Published by the Expenses module when every payment in a settlement plan has been
7 +/// confirmed. The Trips module subscribes to advance trips out of the "Finalizing"
8 +/// state without the Expenses module needing to know about Trip lifecycle.
9 +/// </summary>
10 +public record SettlementPlanCompletedEvent(Guid TripId, Guid SettlementPlanId) : INotification;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetBudgetCategorySpentQuery.cs +6 −0
@@ -0,0 +1,6 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Queries;
4 +
5 +public record GetBudgetCategorySpentQuery(Guid TripId)
6 + : IRequest<IReadOnlyDictionary<Guid, decimal>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetCurrenciesByIdsQuery.cs +6 −0
@@ -0,0 +1,6 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Queries;
4 +
5 +public record GetCurrenciesByIdsQuery(IReadOnlyCollection<Guid> CurrencyIds)
6 + : IRequest<IReadOnlyList<CurrencyDto>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetTripExpenseTotalsQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Expenses.Queries;
4 +
5 +public record GetTripExpenseTotalsQuery(Guid TripId) : IRequest<TripExpenseTotalsDto>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/TripExpenseTotalsDto.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Shared.Contracts.Expenses;
2 +
3 +public record TripExpenseTotalsDto(
4 + Guid TripId,
5 + IReadOnlyDictionary<string, decimal> TotalsByCurrency,
6 + int ExpenseCount
7 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/SplitApp.Shared.Contracts.csproj +17 −0
@@ -0,0 +1,17 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + </PropertyGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="MediatR.Contracts" Version="2.0.1" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <ProjectReference Include="..\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
15 + </ItemGroup>
16 +
17 +</Project>
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/BudgetCategoryNameDto.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Contracts.Trips;
2 +
3 +public record BudgetCategoryNameDto(
4 + Guid Id,
5 + string Name
6 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Events/TripDeletedEvent.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Events;
4 +
5 +public record TripDeletedEvent(Guid TripId) : INotification;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetBudgetCategoryNamesByIdsQuery.cs +6 −0
@@ -0,0 +1,6 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Queries;
4 +
5 +public record GetBudgetCategoryNamesByIdsQuery(IReadOnlyCollection<Guid> CategoryIds)
6 + : IRequest<IReadOnlyList<BudgetCategoryNameDto>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripByIdQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Queries;
4 +
5 +public record GetTripByIdQuery(Guid TripId) : IRequest<TripSummaryDto?>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripParticipantsQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Queries;
4 +
5 +public record GetTripParticipantsQuery(Guid TripId) : IRequest<IReadOnlyList<TripParticipantDto>>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/IsTripParticipantQuery.cs +5 −0
@@ -0,0 +1,5 @@
1 +using MediatR;
2 +
3 +namespace SplitApp.Shared.Contracts.Trips.Queries;
4 +
5 +public record IsTripParticipantQuery(Guid TripId, Guid UserId) : IRequest<bool>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripParticipantDto.cs +11 −0
@@ -0,0 +1,11 @@
1 +namespace SplitApp.Shared.Contracts.Trips;
2 +
3 +public record TripParticipantDto(
4 + Guid Id,
5 + Guid TripId,
6 + Guid UserId,
7 + string Role,
8 + string? Nickname,
9 + DateTime JoinedAt,
10 + bool IsActive
11 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripSummaryDto.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Shared.Contracts.Trips;
2 +
3 +public record TripSummaryDto(
4 + Guid Id,
5 + Guid OwnerId,
6 + string Name,
7 + Guid DefaultCurrencyId
8 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/UserDto.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Shared.Contracts.Users;
2 +
3 +public record UserDto(
4 + Guid Id,
5 + string DisplayName,
6 + string Email
7 +);
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Auth/IdentityHelpers.cs +59 −0
@@ -0,0 +1,59 @@
1 +using System.IdentityModel.Tokens.Jwt;
2 +using System.Security.Claims;
3 +using System.Text;
4 +using Microsoft.IdentityModel.Tokens;
5 +
6 +namespace SplitApp.Shared.Kernel.Auth;
7 +
8 +public static class IdentityHelpers
9 +{
10 + public static string GenerateJwt(
11 + IEnumerable<Claim> claims,
12 + string key,
13 + string issuer,
14 + string audience,
15 + int expiresInSeconds)
16 + {
17 + var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key));
18 + var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);
19 + var expires = DateTime.UtcNow.AddSeconds(expiresInSeconds);
20 + var token = new JwtSecurityToken(
21 + issuer: issuer,
22 + audience: audience,
23 + claims: claims,
24 + expires: expires,
25 + signingCredentials: signingCredentials
26 + );
27 + return new JwtSecurityTokenHandler().WriteToken(token);
28 + }
29 +
30 + /// <summary>
31 + /// Validate JWT token signature and issuer/audience.
32 + /// Ignores expiration - used during token refresh where the JWT is allowed to be expired.
33 + /// </summary>
34 + public static bool ValidateJWT(
35 + string jwt,
36 + string key,
37 + string issuer,
38 + string audience)
39 + {
40 + var tokenHandler = new JwtSecurityTokenHandler();
41 + var validationParameters = new TokenValidationParameters
42 + {
43 + ValidIssuer = issuer,
44 + ValidAudience = audience,
45 + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
46 + ValidateLifetime = false
47 + };
48 +
49 + try
50 + {
51 + tokenHandler.ValidateToken(jwt, validationParameters, out _);
52 + return true;
53 + }
54 + catch (Exception)
55 + {
56 + return false;
57 + }
58 + }
59 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/BaseEntity.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.Shared.Kernel.Domain;
2 +
3 +public abstract class BaseEntity : IBaseEntity
4 +{
5 + public Guid Id { get; set; } = Guid.NewGuid();
6 + public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
7 + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
8 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/IBaseEntity.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Kernel.Domain;
2 +
3 +public interface IBaseEntity
4 +{
5 + public Guid Id { get; set; }
6 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Localization/LangStr.cs +73 −0
@@ -0,0 +1,73 @@
1 +namespace SplitApp.Shared.Kernel.Localization;
2 +
3 +public class LangStr : Dictionary<string, string>
4 +{
5 + public static string DefaultCulture { get; set; } = "en";
6 +
7 + public new string this[string key]
8 + {
9 + get => base[key];
10 + set => base[key] = value;
11 + }
12 +
13 + public LangStr()
14 + {
15 + }
16 +
17 + public LangStr(string value) : this(value, Thread.CurrentThread.CurrentUICulture.Name)
18 + {
19 + }
20 +
21 + public LangStr(string value, string culture)
22 + {
23 + if (culture.Length < 1) throw new ApplicationException("Culture is required!");
24 +
25 + var neutralCulture = culture.Split('-')[0];
26 + this[neutralCulture] = value;
27 +
28 + if (!ContainsKey(DefaultCulture))
29 + {
30 + this[DefaultCulture] = value;
31 + }
32 + }
33 +
34 + public string? Translate(string? culture = null)
35 + {
36 + if (Count == 0) return null;
37 + culture = culture?.Trim() ?? Thread.CurrentThread.CurrentUICulture.Name;
38 +
39 + if (ContainsKey(culture))
40 + {
41 + return this[culture];
42 + }
43 +
44 + var neutralCulture = culture.Split('-')[0];
45 + if (ContainsKey(neutralCulture))
46 + {
47 + return this[neutralCulture];
48 + }
49 +
50 + if (ContainsKey(DefaultCulture))
51 + {
52 + return this[DefaultCulture];
53 + }
54 +
55 + return null;
56 + }
57 +
58 + public void SetTranslation(string value, string? culture = null)
59 + {
60 + culture = culture?.Trim() ?? Thread.CurrentThread.CurrentUICulture.Name;
61 + var neutralCulture = culture.Split('-')[0];
62 + this[neutralCulture] = value;
63 + }
64 +
65 + public override string ToString()
66 + {
67 + return Translate() ?? "????";
68 + }
69 +
70 + public static implicit operator string(LangStr? langStr) => langStr?.ToString() ?? "null";
71 +
72 + public static implicit operator LangStr(string value) => new LangStr(value);
73 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IBaseRepository.cs +13 −0
@@ -0,0 +1,13 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +
3 +namespace SplitApp.Shared.Kernel.Persistence;
4 +
5 +public interface IBaseRepository<TEntity> where TEntity : class, IBaseEntity
6 +{
7 + Task<IEnumerable<TEntity>> GetAllAsync();
8 + Task<TEntity?> GetByIdAsync(Guid id);
9 + TEntity Add(TEntity entity);
10 + TEntity Update(TEntity entity);
11 + Task<TEntity?> RemoveAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IUnitOfWork.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Kernel.Persistence;
2 +
3 +public interface IUnitOfWork
4 +{
5 + Task<int> SaveChangesAsync();
6 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/SplitApp.Shared.Kernel.csproj +14 −0
@@ -0,0 +1,14 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + </PropertyGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.3.1" />
11 + <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.3.1" />
12 + </ItemGroup>
13 +
14 +</Project>
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IEventHandler.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Messaging;
2 +
3 +public interface IEventHandler<in TEvent> where TEvent : IIntegrationEvent
4 +{
5 + Task HandleAsync(TEvent @event, CancellationToken cancellationToken);
6 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IIntegrationEvent.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Messaging;
2 +
3 +/// <summary>Marker for fire-and-forget integration events published on the topic exchange.</summary>
4 +public interface IIntegrationEvent
5 +{
6 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IIntegrationRequest.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Messaging;
2 +
3 +/// <summary>Marker for RPC requests over RabbitMQ. <typeparamref name="TResponse"/> is the expected reply payload.</summary>
4 +public interface IIntegrationRequest<TResponse>
5 +{
6 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IMessageBus.cs +13 −0
@@ -0,0 +1,13 @@
1 +namespace SplitApp.Shared.Messaging;
2 +
3 +public interface IMessageBus
4 +{
5 + Task PublishEventAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
6 + where TEvent : IIntegrationEvent;
7 +
8 + Task<TResponse> RequestAsync<TRequest, TResponse>(
9 + TRequest request,
10 + TimeSpan timeout,
11 + CancellationToken cancellationToken = default)
12 + where TRequest : IIntegrationRequest<TResponse>;
13 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/IRequestHandler.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.Shared.Messaging;
2 +
3 +public interface IRequestHandler<in TRequest, TResponse> where TRequest : IIntegrationRequest<TResponse>
4 +{
5 + Task<TResponse> HandleAsync(TRequest request, CancellationToken cancellationToken);
6 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/GetUserByIdRequest.cs +6 −0
@@ -0,0 +1,6 @@
1 +using SplitApp.Shared.Contracts.Users;
2 +
3 +namespace SplitApp.Shared.Messaging.Integration.Users;
4 +
5 +/// <summary>RPC request: fetch a single user projection by id. Null reply means not found.</summary>
6 +public record GetUserByIdRequest(Guid UserId) : IIntegrationRequest<UserDto?>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/GetUsersByIdsRequest.cs +6 −0
@@ -0,0 +1,6 @@
1 +using SplitApp.Shared.Contracts.Users;
2 +
3 +namespace SplitApp.Shared.Messaging.Integration.Users;
4 +
5 +/// <summary>RPC request: batch user lookup. Reply contains the subset of ids that resolved.</summary>
6 +public record GetUsersByIdsRequest(Guid[] UserIds) : IIntegrationRequest<UserDto[]>;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/IUserLookup.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.Shared.Contracts.Users;
2 +
3 +namespace SplitApp.Shared.Messaging.Integration.Users;
4 +
5 +/// <summary>
6 +/// Higher-level abstraction over <see cref="IMessageBus"/> so consumer code in Trips.Api,
7 +/// Expenses.Api, and the monolith WebApp doesn't take a direct dep on RabbitMQ plumbing.
8 +/// </summary>
9 +public interface IUserLookup
10 +{
11 + Task<UserDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
12 +
13 + Task<IReadOnlyDictionary<Guid, UserDto>> GetByIdsAsync(
14 + IEnumerable<Guid> ids,
15 + CancellationToken cancellationToken = default);
16 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/UserDeletedEvent.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace SplitApp.Shared.Messaging.Integration.Users;
2 +
3 +/// <summary>
4 +/// Published by the Users service when a user is deleted. Subscribed by the monolith
5 +/// to cascade-clean Trips/Expenses rows that referenced the gone user.
6 +/// </summary>
7 +public record UserDeletedEvent(Guid UserId) : IIntegrationEvent;
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Integration/Users/UserLookup.cs +64 −0
@@ -0,0 +1,64 @@
1 +using Microsoft.Extensions.Logging;
2 +using Microsoft.Extensions.Options;
3 +using SplitApp.Shared.Contracts.Users;
4 +
5 +namespace SplitApp.Shared.Messaging.Integration.Users;
6 +
7 +/// <summary>
8 +/// Default <see cref="IUserLookup"/> backed by <see cref="IMessageBus"/> RPC.
9 +/// Empty input collections short-circuit without a network round-trip.
10 +/// On bus failure (timeout / unavailable) returns an empty result and logs — the
11 +/// UX shows "(unknown)" rather than crashing the page.
12 +/// </summary>
13 +public class UserLookup : IUserLookup
14 +{
15 + private readonly IMessageBus _bus;
16 + private readonly MessagingOptions _options;
17 + private readonly ILogger<UserLookup> _logger;
18 +
19 + public UserLookup(IMessageBus bus, IOptions<MessagingOptions> options, ILogger<UserLookup> logger)
20 + {
21 + _bus = bus;
22 + _options = options.Value;
23 + _logger = logger;
24 + }
25 +
26 + public async Task<UserDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
27 + {
28 + if (id == Guid.Empty) return null;
29 + try
30 + {
31 + return await _bus.RequestAsync<GetUserByIdRequest, UserDto?>(
32 + new GetUserByIdRequest(id),
33 + TimeSpan.FromSeconds(_options.RpcReplyTimeoutSeconds),
34 + cancellationToken);
35 + }
36 + catch (Exception ex) when (ex is MessageBusTimeoutException or MessageBusUnavailableException)
37 + {
38 + _logger.LogWarning(ex, "User lookup degraded for {Id}", id);
39 + return null;
40 + }
41 + }
42 +
43 + public async Task<IReadOnlyDictionary<Guid, UserDto>> GetByIdsAsync(
44 + IEnumerable<Guid> ids,
45 + CancellationToken cancellationToken = default)
46 + {
47 + var idArr = ids.Where(i => i != Guid.Empty).Distinct().ToArray();
48 + if (idArr.Length == 0) return new Dictionary<Guid, UserDto>();
49 +
50 + try
51 + {
52 + var result = await _bus.RequestAsync<GetUsersByIdsRequest, UserDto[]>(
53 + new GetUsersByIdsRequest(idArr),
54 + TimeSpan.FromSeconds(_options.RpcReplyTimeoutSeconds),
55 + cancellationToken);
56 + return result.ToDictionary(u => u.Id);
57 + }
58 + catch (Exception ex) when (ex is MessageBusTimeoutException or MessageBusUnavailableException)
59 + {
60 + _logger.LogWarning(ex, "User batch lookup degraded for {Count} ids", idArr.Length);
61 + return new Dictionary<Guid, UserDto>();
62 + }
63 + }
64 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/HandlerRegistry.cs +15 −0
@@ -0,0 +1,15 @@
1 +namespace SplitApp.Shared.Messaging.Internal;
2 +
3 +/// <summary>
4 +/// DI-singleton list of all event/request handlers registered for this process.
5 +/// Populated at startup via <see cref="ServiceCollectionExtensions.AddIntegrationEventHandler"/>
6 +/// and read by <see cref="RabbitMqConsumerHostedService"/> to build consumer subscriptions.
7 +/// </summary>
8 +public class HandlerRegistry
9 +{
10 + private readonly List<MessageHandlerRegistration> _registrations = new();
11 +
12 + public void Register(MessageHandlerRegistration registration) => _registrations.Add(registration);
13 +
14 + public IReadOnlyList<MessageHandlerRegistration> All => _registrations;
15 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/MessageDispatcherFactory.cs +39 −0
@@ -0,0 +1,39 @@
1 +using System.Text.Json;
2 +using Microsoft.Extensions.DependencyInjection;
3 +
4 +namespace SplitApp.Shared.Messaging.Internal;
5 +
6 +/// <summary>
7 +/// Builds strongly-typed delegate adapters so the consumer loop can dispatch by `Type` lookup
8 +/// without per-message reflection. One factory call per registered handler at startup.
9 +/// </summary>
10 +public static class MessageDispatcherFactory
11 +{
12 + public static MessageDispatcher CreateEventDispatcher<TEvent, THandler>()
13 + where TEvent : IIntegrationEvent
14 + where THandler : class, IEventHandler<TEvent>
15 + {
16 + return async (body, scopedProvider, ct) =>
17 + {
18 + var evt = JsonSerializer.Deserialize<TEvent>(body.Span)
19 + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(TEvent).Name}");
20 + var handler = scopedProvider.GetRequiredService<THandler>();
21 + await handler.HandleAsync(evt, ct);
22 + return null;
23 + };
24 + }
25 +
26 + public static MessageDispatcher CreateRequestDispatcher<TRequest, TResponse, THandler>()
27 + where TRequest : IIntegrationRequest<TResponse>
28 + where THandler : class, IRequestHandler<TRequest, TResponse>
29 + {
30 + return async (body, scopedProvider, ct) =>
31 + {
32 + var req = JsonSerializer.Deserialize<TRequest>(body.Span)
33 + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(TRequest).Name}");
34 + var handler = scopedProvider.GetRequiredService<THandler>();
35 + var resp = await handler.HandleAsync(req, ct);
36 + return JsonSerializer.SerializeToUtf8Bytes(resp);
37 + };
38 + }
39 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/MessageHandlerRegistration.cs +27 −0
@@ -0,0 +1,27 @@
1 +namespace SplitApp.Shared.Messaging.Internal;
2 +
3 +/// <summary>
4 +/// Dispatcher signature: deserialize body, resolve handler from the supplied scope,
5 +/// invoke it. Returns the serialized response bytes for RPC handlers, or null for events.
6 +/// </summary>
7 +public delegate Task<byte[]?> MessageDispatcher(
8 + ReadOnlyMemory<byte> body,
9 + IServiceProvider scopedProvider,
10 + CancellationToken cancellationToken);
11 +
12 +public enum MessageKind
13 +{
14 + Event,
15 + Request,
16 +}
17 +
18 +public class MessageHandlerRegistration
19 +{
20 + public required Type MessageType { get; init; }
21 + public required Type HandlerType { get; init; }
22 + public Type? ResponseType { get; init; }
23 + public required string Exchange { get; init; }
24 + public required string RoutingKey { get; init; }
25 + public required MessageKind Kind { get; init; }
26 + public required MessageDispatcher Dispatcher { get; init; }
27 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/RabbitMqBus.cs +228 −0
@@ -0,0 +1,228 @@
1 +using System.Collections.Concurrent;
2 +using System.Text.Json;
3 +using Microsoft.Extensions.Logging;
4 +using Microsoft.Extensions.Options;
5 +using RabbitMQ.Client;
6 +using RabbitMQ.Client.Events;
7 +
8 +namespace SplitApp.Shared.Messaging.Internal;
9 +
10 +/// <summary>
11 +/// Singleton IMessageBus implementation. Owns:
12 +/// - one publish channel (events + RPC outbound),
13 +/// - one reply consumer channel + exclusive server-named auto-delete reply queue,
14 +/// - a TCS map keyed by correlation ID for in-flight RPC requests.
15 +///
16 +/// On connection/channel shutdown, all pending RPC TCSs are faulted with
17 +/// <see cref="MessageBusUnavailableException"/> so callers fail fast.
18 +/// </summary>
19 +public class RabbitMqBus : IMessageBus, IAsyncDisposable
20 +{
21 + private readonly RabbitMqConnectionProvider _connections;
22 + private readonly MessagingOptions _options;
23 + private readonly ILogger<RabbitMqBus> _logger;
24 + private readonly SemaphoreSlim _initGate = new(1, 1);
25 + private readonly ConcurrentDictionary<string, TaskCompletionSource<ReadOnlyMemory<byte>>> _pending = new();
26 +
27 + private IChannel? _publishChannel;
28 + private IChannel? _replyChannel;
29 + private string? _replyQueueName;
30 + private bool _topologyDeclared;
31 +
32 + public RabbitMqBus(
33 + RabbitMqConnectionProvider connections,
34 + IOptions<MessagingOptions> options,
35 + ILogger<RabbitMqBus> logger)
36 + {
37 + _connections = connections;
38 + _options = options.Value;
39 + _logger = logger;
40 + }
41 +
42 + public async Task PublishEventAsync<TEvent>(TEvent @event, CancellationToken cancellationToken = default)
43 + where TEvent : IIntegrationEvent
44 + {
45 + await EnsureInitializedAsync(cancellationToken);
46 +
47 + var typeName = typeof(TEvent).Name;
48 + var body = JsonSerializer.SerializeToUtf8Bytes(@event);
49 + var props = new BasicProperties
50 + {
51 + Type = typeName,
52 + MessageId = Guid.NewGuid().ToString(),
53 + ContentType = "application/json",
54 + DeliveryMode = DeliveryModes.Persistent,
55 + };
56 +
57 + await _publishChannel!.BasicPublishAsync(
58 + exchange: RabbitMqTopology.EventsExchange,
59 + routingKey: typeName,
60 + mandatory: false,
61 + basicProperties: props,
62 + body: body,
63 + cancellationToken: cancellationToken);
64 +
65 + _logger.LogDebug("Published event {Type} ({MessageId})", typeName, props.MessageId);
66 + }
67 +
68 + public async Task<TResponse> RequestAsync<TRequest, TResponse>(
69 + TRequest request,
70 + TimeSpan timeout,
71 + CancellationToken cancellationToken = default)
72 + where TRequest : IIntegrationRequest<TResponse>
73 + {
74 + await EnsureInitializedAsync(cancellationToken);
75 +
76 + var typeName = typeof(TRequest).Name;
77 + var correlationId = Guid.NewGuid().ToString();
78 + var tcs = new TaskCompletionSource<ReadOnlyMemory<byte>>(TaskCreationOptions.RunContinuationsAsynchronously);
79 + _pending[correlationId] = tcs;
80 +
81 + try
82 + {
83 + var body = JsonSerializer.SerializeToUtf8Bytes(request);
84 + var props = new BasicProperties
85 + {
86 + Type = typeName,
87 + CorrelationId = correlationId,
88 + ReplyTo = _replyQueueName,
89 + ContentType = "application/json",
90 + };
91 +
92 + await _publishChannel!.BasicPublishAsync(
93 + exchange: RabbitMqTopology.RequestsExchange,
94 + routingKey: typeName,
95 + mandatory: false,
96 + basicProperties: props,
97 + body: body,
98 + cancellationToken: cancellationToken);
99 +
100 + _logger.LogDebug("Sent request {Type} ({CorrelationId})", typeName, correlationId);
101 +
102 + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
103 + timeoutCts.CancelAfter(timeout);
104 +
105 + ReadOnlyMemory<byte> replyBody;
106 + try
107 + {
108 + replyBody = await tcs.Task.WaitAsync(timeoutCts.Token);
109 + }
110 + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
111 + {
112 + throw new MessageBusTimeoutException(
113 + $"RPC request {typeName} ({correlationId}) timed out after {timeout.TotalSeconds:0.#}s");
114 + }
115 +
116 + var response = JsonSerializer.Deserialize<TResponse>(replyBody.Span);
117 + return response!;
118 + }
119 + finally
120 + {
121 + _pending.TryRemove(correlationId, out _);
122 + }
123 + }
124 +
125 + private async Task EnsureInitializedAsync(CancellationToken ct)
126 + {
127 + if (_publishChannel is { IsOpen: true } && _replyChannel is { IsOpen: true }) return;
128 +
129 + await _initGate.WaitAsync(ct);
130 + try
131 + {
132 + if (_publishChannel is { IsOpen: true } && _replyChannel is { IsOpen: true }) return;
133 +
134 + var conn = await _connections.GetConnectionAsync(ct);
135 + _publishChannel ??= await conn.CreateChannelAsync(cancellationToken: ct);
136 + _replyChannel ??= await conn.CreateChannelAsync(cancellationToken: ct);
137 +
138 + if (!_topologyDeclared)
139 + {
140 + await _publishChannel.ExchangeDeclareAsync(
141 + RabbitMqTopology.EventsExchange,
142 + RabbitMqTopology.EventsExchangeType,
143 + durable: true,
144 + autoDelete: false,
145 + cancellationToken: ct);
146 +
147 + await _publishChannel.ExchangeDeclareAsync(
148 + RabbitMqTopology.RequestsExchange,
149 + RabbitMqTopology.RequestsExchangeType,
150 + durable: true,
151 + autoDelete: false,
152 + cancellationToken: ct);
153 +
154 + _topologyDeclared = true;
155 + }
156 +
157 + if (_replyQueueName is null)
158 + {
159 + var declareOk = await _replyChannel.QueueDeclareAsync(
160 + queue: "",
161 + durable: false,
162 + exclusive: true,
163 + autoDelete: true,
164 + cancellationToken: ct);
165 + _replyQueueName = declareOk.QueueName;
166 +
167 + var consumer = new AsyncEventingBasicConsumer(_replyChannel);
168 + consumer.ReceivedAsync += OnReplyReceivedAsync;
169 + await _replyChannel.BasicConsumeAsync(
170 + queue: _replyQueueName,
171 + autoAck: false,
172 + consumer: consumer,
173 + cancellationToken: ct);
174 +
175 + _logger.LogInformation("RPC reply queue ready: {Queue}", _replyQueueName);
176 + }
177 +
178 + conn.ConnectionShutdownAsync += OnConnectionShutdownAsync;
179 + }
180 + finally
181 + {
182 + _initGate.Release();
183 + }
184 + }
185 +
186 + private Task OnReplyReceivedAsync(object sender, BasicDeliverEventArgs ea)
187 + {
188 + var corr = ea.BasicProperties.CorrelationId;
189 + if (!string.IsNullOrEmpty(corr) && _pending.TryRemove(corr, out var tcs))
190 + {
191 + tcs.TrySetResult(ea.Body);
192 + }
193 + else
194 + {
195 + _logger.LogWarning("Reply with unknown correlation id {CorrelationId} dropped", corr);
196 + }
197 +
198 + return _replyChannel!.BasicAckAsync(ea.DeliveryTag, multiple: false).AsTask();
199 + }
200 +
201 + private Task OnConnectionShutdownAsync(object? sender, ShutdownEventArgs ea)
202 + {
203 + _logger.LogWarning("RabbitMQ connection shutdown: {Reason}", ea.ReplyText);
204 + var ex = new MessageBusUnavailableException($"RabbitMQ connection lost: {ea.ReplyText}");
205 + foreach (var kvp in _pending)
206 + {
207 + if (_pending.TryRemove(kvp.Key, out var tcs))
208 + {
209 + tcs.TrySetException(ex);
210 + }
211 + }
212 + return Task.CompletedTask;
213 + }
214 +
215 + public async ValueTask DisposeAsync()
216 + {
217 + try
218 + {
219 + if (_publishChannel is not null) { await _publishChannel.CloseAsync(); await _publishChannel.DisposeAsync(); }
220 + if (_replyChannel is not null) { await _replyChannel.CloseAsync(); await _replyChannel.DisposeAsync(); }
221 + }
222 + catch (Exception ex)
223 + {
224 + _logger.LogWarning(ex, "Error closing RabbitMQ bus channels");
225 + }
226 + _initGate.Dispose();
227 + }
228 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/RabbitMqConnectionProvider.cs +94 −0
@@ -0,0 +1,94 @@
1 +using Microsoft.Extensions.Logging;
2 +using Microsoft.Extensions.Options;
3 +using Polly;
4 +using Polly.Retry;
5 +using RabbitMQ.Client;
6 +
7 +namespace SplitApp.Shared.Messaging.Internal;
8 +
9 +/// <summary>
10 +/// Singleton owner of the single AMQP <see cref="IConnection"/> for this process.
11 +/// First call to <see cref="GetConnectionAsync"/> performs Polly-backed retries so
12 +/// service startup order with RabbitMQ is forgiving.
13 +/// </summary>
14 +public class RabbitMqConnectionProvider : IAsyncDisposable
15 +{
16 + private readonly MessagingOptions _options;
17 + private readonly ILogger<RabbitMqConnectionProvider> _logger;
18 + private readonly SemaphoreSlim _gate = new(1, 1);
19 + private IConnection? _connection;
20 +
21 + public RabbitMqConnectionProvider(IOptions<MessagingOptions> options, ILogger<RabbitMqConnectionProvider> logger)
22 + {
23 + _options = options.Value;
24 + _logger = logger;
25 + }
26 +
27 + public async Task<IConnection> GetConnectionAsync(CancellationToken cancellationToken = default)
28 + {
29 + if (_connection is { IsOpen: true }) return _connection;
30 +
31 + await _gate.WaitAsync(cancellationToken);
32 + try
33 + {
34 + if (_connection is { IsOpen: true }) return _connection;
35 +
36 + var factory = new ConnectionFactory
37 + {
38 + HostName = _options.HostName,
39 + Port = _options.Port,
40 + UserName = _options.UserName,
41 + Password = _options.Password,
42 + VirtualHost = _options.VirtualHost,
43 + AutomaticRecoveryEnabled = true,
44 + TopologyRecoveryEnabled = true,
45 + NetworkRecoveryInterval = TimeSpan.FromSeconds(5),
46 + };
47 +
48 + var pipeline = new ResiliencePipelineBuilder()
49 + .AddRetry(new RetryStrategyOptions
50 + {
51 + MaxRetryAttempts = _options.InitialConnectRetryAttempts,
52 + BackoffType = DelayBackoffType.Exponential,
53 + Delay = TimeSpan.FromSeconds(1),
54 + MaxDelay = TimeSpan.FromSeconds(30),
55 + OnRetry = args =>
56 + {
57 + _logger.LogWarning(args.Outcome.Exception,
58 + "RabbitMQ connect attempt {Attempt} failed; retrying in {Delay}",
59 + args.AttemptNumber + 1, args.RetryDelay);
60 + return ValueTask.CompletedTask;
61 + },
62 + })
63 + .Build();
64 +
65 + _connection = await pipeline.ExecuteAsync(
66 + async ct => await factory.CreateConnectionAsync(ct),
67 + cancellationToken);
68 +
69 + _logger.LogInformation("RabbitMQ connection opened to {Host}:{Port}", _options.HostName, _options.Port);
70 + return _connection;
71 + }
72 + finally
73 + {
74 + _gate.Release();
75 + }
76 + }
77 +
78 + public async ValueTask DisposeAsync()
79 + {
80 + if (_connection is not null)
81 + {
82 + try
83 + {
84 + await _connection.CloseAsync();
85 + await _connection.DisposeAsync();
86 + }
87 + catch (Exception ex)
88 + {
89 + _logger.LogWarning(ex, "Error closing RabbitMQ connection");
90 + }
91 + }
92 + _gate.Dispose();
93 + }
94 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/Internal/RabbitMqConsumerHostedService.cs +181 −0
@@ -0,0 +1,181 @@
1 +using Microsoft.Extensions.DependencyInjection;
2 +using Microsoft.Extensions.Hosting;
3 +using Microsoft.Extensions.Logging;
4 +using Microsoft.Extensions.Options;
5 +using RabbitMQ.Client;
6 +using RabbitMQ.Client.Events;
7 +
8 +namespace SplitApp.Shared.Messaging.Internal;
9 +
10 +/// <summary>
11 +/// Server-side of the bus. On start, declares one durable queue per registered handler,
12 +/// binds it to the right exchange + routing key, and starts an async consumer.
13 +/// For RPC handlers, publishes the response back to the caller's ReplyTo queue with
14 +/// the original CorrelationId. Acks on success; nacks (no requeue) on handler exception.
15 +/// </summary>
16 +public class RabbitMqConsumerHostedService : BackgroundService
17 +{
18 + private readonly RabbitMqConnectionProvider _connections;
19 + private readonly HandlerRegistry _registry;
20 + private readonly IServiceScopeFactory _scopeFactory;
21 + private readonly MessagingOptions _options;
22 + private readonly ILogger<RabbitMqConsumerHostedService> _logger;
23 +
24 + private readonly List<IChannel> _consumerChannels = new();
25 +
26 + public RabbitMqConsumerHostedService(
27 + RabbitMqConnectionProvider connections,
28 + HandlerRegistry registry,
29 + IServiceScopeFactory scopeFactory,
30 + IOptions<MessagingOptions> options,
31 + ILogger<RabbitMqConsumerHostedService> logger)
32 + {
33 + _connections = connections;
34 + _registry = registry;
35 + _scopeFactory = scopeFactory;
36 + _options = options.Value;
37 + _logger = logger;
38 + }
39 +
40 + protected override async Task ExecuteAsync(CancellationToken stoppingToken)
41 + {
42 + if (_registry.All.Count == 0)
43 + {
44 + _logger.LogInformation("No integration handlers registered; consumer service idle");
45 + return;
46 + }
47 +
48 + var conn = await _connections.GetConnectionAsync(stoppingToken);
49 +
50 + foreach (var registration in _registry.All)
51 + {
52 + await SubscribeAsync(conn, registration, stoppingToken);
53 + }
54 +
55 + try
56 + {
57 + await Task.Delay(Timeout.Infinite, stoppingToken);
58 + }
59 + catch (OperationCanceledException)
60 + {
61 + // Expected on shutdown
62 + }
63 + }
64 +
65 + private async Task SubscribeAsync(IConnection conn, MessageHandlerRegistration reg, CancellationToken ct)
66 + {
67 + var channel = await conn.CreateChannelAsync(cancellationToken: ct);
68 + _consumerChannels.Add(channel);
69 +
70 + await channel.ExchangeDeclareAsync(
71 + exchange: reg.Exchange,
72 + type: reg.Exchange == RabbitMqTopology.EventsExchange
73 + ? RabbitMqTopology.EventsExchangeType
74 + : RabbitMqTopology.RequestsExchangeType,
75 + durable: true,
76 + autoDelete: false,
77 + cancellationToken: ct);
78 +
79 + var queueName = RabbitMqTopology.HandlerQueueName(_options.ServiceName, reg.MessageType.Name);
80 + await channel.QueueDeclareAsync(
81 + queue: queueName,
82 + durable: true,
83 + exclusive: false,
84 + autoDelete: false,
85 + cancellationToken: ct);
86 +
87 + await channel.QueueBindAsync(
88 + queue: queueName,
89 + exchange: reg.Exchange,
90 + routingKey: reg.RoutingKey,
91 + cancellationToken: ct);
92 +
93 + // Process one message at a time per consumer (predictable handler concurrency).
94 + await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false, cancellationToken: ct);
95 +
96 + var consumer = new AsyncEventingBasicConsumer(channel);
97 + consumer.ReceivedAsync += (sender, ea) => OnReceivedAsync(channel, reg, ea, ct);
98 +
99 + await channel.BasicConsumeAsync(
100 + queue: queueName,
101 + autoAck: false,
102 + consumer: consumer,
103 + cancellationToken: ct);
104 +
105 + _logger.LogInformation(
106 + "Subscribed: queue={Queue} exchange={Exchange} routingKey={RoutingKey} handler={Handler}",
107 + queueName, reg.Exchange, reg.RoutingKey, reg.HandlerType.Name);
108 + }
109 +
110 + private async Task OnReceivedAsync(
111 + IChannel channel,
112 + MessageHandlerRegistration reg,
113 + BasicDeliverEventArgs ea,
114 + CancellationToken stoppingToken)
115 + {
116 + try
117 + {
118 + using var scope = _scopeFactory.CreateScope();
119 + var responseBytes = await reg.Dispatcher(ea.Body, scope.ServiceProvider, stoppingToken);
120 +
121 + if (reg.Kind == MessageKind.Request && responseBytes is not null)
122 + {
123 + var replyTo = ea.BasicProperties.ReplyTo;
124 + var correlationId = ea.BasicProperties.CorrelationId;
125 + if (string.IsNullOrEmpty(replyTo) || string.IsNullOrEmpty(correlationId))
126 + {
127 + _logger.LogWarning(
128 + "Request {Type} missing ReplyTo/CorrelationId; dropping response", reg.MessageType.Name);
129 + }
130 + else
131 + {
132 + var replyProps = new BasicProperties
133 + {
134 + CorrelationId = correlationId,
135 + ContentType = "application/json",
136 + };
137 + await channel.BasicPublishAsync(
138 + exchange: "",
139 + routingKey: replyTo,
140 + mandatory: false,
141 + basicProperties: replyProps,
142 + body: responseBytes,
143 + cancellationToken: stoppingToken);
144 + }
145 + }
146 +
147 + await channel.BasicAckAsync(ea.DeliveryTag, multiple: false);
148 + }
149 + catch (Exception ex)
150 + {
151 + _logger.LogError(ex, "Handler {Handler} failed for {Type}", reg.HandlerType.Name, reg.MessageType.Name);
152 + try
153 + {
154 + await channel.BasicNackAsync(ea.DeliveryTag, multiple: false, requeue: false);
155 + }
156 + catch (Exception nackEx)
157 + {
158 + _logger.LogError(nackEx, "Nack failed for {Type}", reg.MessageType.Name);
159 + }
160 + }
161 + }
162 +
163 + public override async Task StopAsync(CancellationToken cancellationToken)
164 + {
165 + await base.StopAsync(cancellationToken);
166 +
167 + foreach (var ch in _consumerChannels)
168 + {
169 + try
170 + {
171 + await ch.CloseAsync(cancellationToken);
172 + await ch.DisposeAsync();
173 + }
174 + catch (Exception ex)
175 + {
176 + _logger.LogWarning(ex, "Error closing consumer channel");
177 + }
178 + }
179 + _consumerChannels.Clear();
180 + }
181 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/MessageBusUnavailableException.cs +12 −0
@@ -0,0 +1,12 @@
1 +namespace SplitApp.Shared.Messaging;
2 +
3 +public class MessageBusUnavailableException : Exception
4 +{
5 + public MessageBusUnavailableException(string message) : base(message) { }
6 + public MessageBusUnavailableException(string message, Exception inner) : base(message, inner) { }
7 +}
8 +
9 +public class MessageBusTimeoutException : Exception
10 +{
11 + public MessageBusTimeoutException(string message) : base(message) { }
12 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/MessagingOptions.cs +20 −0
@@ -0,0 +1,20 @@
1 +namespace SplitApp.Shared.Messaging;
2 +
3 +public class MessagingOptions
4 +{
5 + public const string SectionName = "Messaging:RabbitMq";
6 +
7 + public string HostName { get; set; } = "localhost";
8 + public int Port { get; set; } = 5672;
9 + public string UserName { get; set; } = "guest";
10 + public string Password { get; set; } = "guest";
11 + public string VirtualHost { get; set; } = "/";
12 +
13 + /// <summary>Identifier used in queue naming (e.g. q.&lt;ServiceName&gt;.&lt;MessageType&gt;). Set per process.</summary>
14 + public string ServiceName { get; set; } = "service";
15 +
16 + public int RpcReplyTimeoutSeconds { get; set; } = 10;
17 +
18 + /// <summary>Polly initial-connect retry: max attempts before giving up.</summary>
19 + public int InitialConnectRetryAttempts { get; set; } = 8;
20 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/RabbitMqTopology.cs +14 −0
@@ -0,0 +1,14 @@
1 +namespace SplitApp.Shared.Messaging;
2 +
3 +public static class RabbitMqTopology
4 +{
5 + public const string EventsExchange = "splitapp.events";
6 + public const string RequestsExchange = "splitapp.requests";
7 +
8 + public const string EventsExchangeType = "topic";
9 + public const string RequestsExchangeType = "direct";
10 +
11 + /// <summary>Stable per-service durable queue name for handler subscriptions.</summary>
12 + public static string HandlerQueueName(string serviceName, string messageTypeName) =>
13 + $"q.{serviceName}.{messageTypeName}";
14 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/ServiceCollectionExtensions.cs +100 −0
@@ -0,0 +1,100 @@
1 +using Microsoft.Extensions.Configuration;
2 +using Microsoft.Extensions.DependencyInjection;
3 +using Microsoft.Extensions.DependencyInjection.Extensions;
4 +using SplitApp.Shared.Messaging.Integration.Users;
5 +using SplitApp.Shared.Messaging.Internal;
6 +
7 +namespace SplitApp.Shared.Messaging;
8 +
9 +public static class ServiceCollectionExtensions
10 +{
11 + /// <summary>
12 + /// Registers the RabbitMQ-backed message bus, connection provider, consumer hosted service,
13 + /// and handler registry. Bind <see cref="MessagingOptions"/> from configuration section
14 + /// <c>Messaging:RabbitMq</c>. Call AddIntegrationEventHandler / AddIntegrationRequestHandler
15 + /// for each handler the service should consume.
16 + /// </summary>
17 + public static IServiceCollection AddMessaging(this IServiceCollection services, IConfiguration configuration)
18 + {
19 + services.AddOptions<MessagingOptions>()
20 + .Bind(configuration.GetSection(MessagingOptions.SectionName))
21 + .PostConfigure(o =>
22 + {
23 + if (string.IsNullOrWhiteSpace(o.ServiceName))
24 + throw new InvalidOperationException("MessagingOptions.ServiceName must be set per host");
25 + });
26 +
27 + services.TryAddSingleton<RabbitMqConnectionProvider>();
28 +
29 + // HandlerRegistry pulls in every MessageHandlerRegistration singleton registered
30 + // via AddIntegrationEventHandler / AddIntegrationRequestHandler. Resolved as singleton
31 + // so the consumer service sees all registrations.
32 + services.TryAddSingleton<HandlerRegistry>(sp =>
33 + {
34 + var registry = new HandlerRegistry();
35 + foreach (var r in sp.GetServices<MessageHandlerRegistration>())
36 + {
37 + registry.Register(r);
38 + }
39 + return registry;
40 + });
41 +
42 + services.TryAddSingleton<IMessageBus, RabbitMqBus>();
43 + services.TryAddSingleton<IUserLookup, UserLookup>();
44 + services.AddHostedService<RabbitMqConsumerHostedService>();
45 +
46 + return services;
47 + }
48 +
49 + /// <summary>Convenience overload that sets <see cref="MessagingOptions.ServiceName"/> in-line.</summary>
50 + public static IServiceCollection AddMessaging(
51 + this IServiceCollection services,
52 + IConfiguration configuration,
53 + string serviceName)
54 + {
55 + services.AddMessaging(configuration);
56 + services.PostConfigure<MessagingOptions>(o => o.ServiceName = serviceName);
57 + return services;
58 + }
59 +
60 + public static IServiceCollection AddIntegrationEventHandler<TEvent, THandler>(this IServiceCollection services)
61 + where TEvent : IIntegrationEvent
62 + where THandler : class, IEventHandler<TEvent>
63 + {
64 + services.AddScoped<THandler>();
65 +
66 + services.AddSingleton(_ => new MessageHandlerRegistration
67 + {
68 + MessageType = typeof(TEvent),
69 + HandlerType = typeof(THandler),
70 + ResponseType = null,
71 + Exchange = RabbitMqTopology.EventsExchange,
72 + RoutingKey = typeof(TEvent).Name,
73 + Kind = MessageKind.Event,
74 + Dispatcher = MessageDispatcherFactory.CreateEventDispatcher<TEvent, THandler>(),
75 + });
76 +
77 + return services;
78 + }
79 +
80 + public static IServiceCollection AddIntegrationRequestHandler<TRequest, TResponse, THandler>(
81 + this IServiceCollection services)
82 + where TRequest : IIntegrationRequest<TResponse>
83 + where THandler : class, IRequestHandler<TRequest, TResponse>
84 + {
85 + services.AddScoped<THandler>();
86 +
87 + services.AddSingleton(_ => new MessageHandlerRegistration
88 + {
89 + MessageType = typeof(TRequest),
90 + HandlerType = typeof(THandler),
91 + ResponseType = typeof(TResponse),
92 + Exchange = RabbitMqTopology.RequestsExchange,
93 + RoutingKey = typeof(TRequest).Name,
94 + Kind = MessageKind.Request,
95 + Dispatcher = MessageDispatcherFactory.CreateRequestDispatcher<TRequest, TResponse, THandler>(),
96 + });
97 +
98 + return services;
99 + }
100 +}
added SplitApp.Modular/src/Shared/SplitApp.Shared.Messaging/SplitApp.Shared.Messaging.csproj +22 −0
@@ -0,0 +1,22 @@
1 +<Project Sdk="Microsoft.NET.Sdk">
2 +
3 + <PropertyGroup>
4 + <TargetFramework>net10.0</TargetFramework>
5 + <ImplicitUsings>enable</ImplicitUsings>
6 + <Nullable>enable</Nullable>
7 + </PropertyGroup>
8 +
9 + <ItemGroup>
10 + <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
11 + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
12 + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
13 + <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
14 + <PackageReference Include="Polly" Version="8.5.0" />
15 + <PackageReference Include="RabbitMQ.Client" Version="7.0.0" />
16 + </ItemGroup>
17 +
18 + <ItemGroup>
19 + <ProjectReference Include="..\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
20 + </ItemGroup>
21 +
22 +</Project>
added SplitApp.Modular/src/SplitApp.WebApp/Application/Contracts/IAppUnitOfWork.cs +82 −0
@@ -0,0 +1,82 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Shared.Kernel.Domain;
4 +using SplitApp.Shared.Kernel.Persistence;
5 +
6 +namespace SplitApp.WebApp.Application.Contracts;
7 +
8 +/// <summary>
9 +/// Composition-root unit-of-work facade. Aggregates the Trips and Expenses module
10 +/// DbContexts so phase-2 BLL services keep working. Users are no longer in-process
11 +/// (moved to SplitApp.UsersService) — user data is fetched via IUserLookup (RabbitMQ RPC).
12 +/// </summary>
13 +public interface IAppUnitOfWork : IUnitOfWork
14 +{
15 + ITripRepository Trips { get; }
16 + IExpenseRepository Expenses { get; }
17 + ITripParticipantRepository TripParticipants { get; }
18 + ITripInvitationRepository TripInvitations { get; }
19 + ISettlementPlanRepository SettlementPlans { get; }
20 + ISettlementPaymentRepository SettlementPayments { get; }
21 + ITripPollRepository TripPolls { get; }
22 + ITripWishlistItemRepository TripWishlistItems { get; }
23 + ISplitPresetRepository SplitPresets { get; }
24 + IBudgetCategoryRepository BudgetCategories { get; }
25 + IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity;
26 +}
27 +
28 +public interface ITripRepository : IBaseRepository<Trip>
29 +{
30 + Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId);
31 + Task<Trip?> GetByIdWithDetailsAsync(Guid id);
32 +}
33 +
34 +public interface IExpenseRepository : IBaseRepository<Expense>
35 +{
36 + Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId);
37 + Task<Expense?> GetByIdWithDetailsAsync(Guid id);
38 +}
39 +
40 +public interface ITripParticipantRepository : IBaseRepository<TripParticipant>
41 +{
42 + Task<bool> IsParticipantAsync(Guid tripId, Guid userId);
43 + Task<bool> IsOrganizerAsync(Guid tripId, Guid userId);
44 + Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId);
45 +}
46 +
47 +public interface ITripInvitationRepository : IBaseRepository<TripInvitation>
48 +{
49 + Task<TripInvitation?> GetByTokenAsync(string token);
50 + Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId);
51 +}
52 +
53 +public interface ISettlementPlanRepository : IBaseRepository<SettlementPlan>
54 +{
55 + Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId);
56 + Task DeletePlanWithPaymentsAsync(Guid planId);
57 +}
58 +
59 +public interface ISettlementPaymentRepository : IBaseRepository<SettlementPayment>
60 +{
61 +}
62 +
63 +public interface ITripPollRepository : IBaseRepository<TripPoll>
64 +{
65 + Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId);
66 + Task<TripPoll?> GetByIdWithDetailsAsync(Guid id);
67 +}
68 +
69 +public interface ITripWishlistItemRepository : IBaseRepository<TripWishlistItem>
70 +{
71 + Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId);
72 +}
73 +
74 +public interface ISplitPresetRepository : IBaseRepository<SplitPreset>
75 +{
76 + Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId);
77 +}
78 +
79 +public interface IBudgetCategoryRepository : IBaseRepository<BudgetCategory>
80 +{
81 + Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId);
82 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/AppUserBllDto.cs +11 −0
@@ -0,0 +1,11 @@
1 +namespace SplitApp.WebApp.Application.DTO;
2 +
3 +public class AppUserBllDto
4 +{
5 + public Guid Id { get; set; }
6 + public string FirstName { get; set; } = "";
7 + public string LastName { get; set; } = "";
8 + public string? Email { get; set; }
9 +
10 + public string FullName => $"{FirstName} {LastName}".Trim();
11 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BalanceBllDto.cs +10 −0
@@ -0,0 +1,10 @@
1 +namespace SplitApp.WebApp.Application.DTO;
2 +
3 +public class BalanceBllDto
4 +{
5 + public Guid UserId { get; set; }
6 + public string UserFullName { get; set; } = default!;
7 + public decimal Balance { get; set; }
8 + public decimal TotalPaid { get; set; }
9 + public decimal TotalOwed { get; set; }
10 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BudgetCategoryBllDto.cs +22 −0
@@ -0,0 +1,22 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class BudgetCategoryBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public Guid TripId { get; set; }
13 + public TripBllDto? Trip { get; set; }
14 + public string? TripName => Trip?.Name;
15 +
16 + public LangStr Name { get; set; } = new();
17 + public string? IconName { get; set; }
18 + public decimal? PlannedAmount { get; set; }
19 + public int DisplayOrder { get; set; }
20 + public int ExpenseCount { get; set; }
21 + public decimal SpentAmount { get; set; }
22 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/CurrencyBllDto.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class CurrencyBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public string Code { get; set; } = default!;
13 + public LangStr Name { get; set; } = new();
14 + public string Symbol { get; set; } = default!;
15 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseBllDto.cs +37 −0
@@ -0,0 +1,37 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.WebApp.Application.DTO;
9 +
10 +public class ExpenseBllDto
11 +{
12 + public Guid Id { get; set; }
13 + public DateTime CreatedAt { get; set; }
14 + public DateTime UpdatedAt { get; set; }
15 +
16 + public Guid TripId { get; set; }
17 + public TripBllDto? Trip { get; set; }
18 + public string? TripName => Trip?.Name;
19 +
20 + public Guid PaidByUserId { get; set; }
21 + public AppUserBllDto? PaidByUser { get; set; }
22 + public string? PaidByUserFullName => PaidByUser?.FullName;
23 + public string? PaidByUserEmail => PaidByUser?.Email;
24 +
25 + public Guid? BudgetCategoryId { get; set; }
26 + public BudgetCategoryBllDto? BudgetCategory { get; set; }
27 +
28 + public Guid? CurrencyId { get; set; }
29 + public CurrencyBllDto? Currency { get; set; }
30 +
31 + public decimal Amount { get; set; }
32 + public string? Description { get; set; }
33 + public DateTime ExpenseDate { get; set; }
34 + public ESplitMethod SplitMethod { get; set; }
35 +
36 + public ICollection<ExpenseSplitBllDto>? Splits { get; set; }
37 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseSplitBllDto.cs +19 −0
@@ -0,0 +1,19 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class ExpenseSplitBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public Guid ExpenseId { get; set; }
13 + public Guid UserId { get; set; }
14 + public string? UserFullName { get; set; }
15 + public string? UserEmail { get; set; }
16 +
17 + public decimal Amount { get; set; }
18 + public decimal? Percentage { get; set; }
19 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPaymentBllDto.cs +30 −0
@@ -0,0 +1,30 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.WebApp.Application.DTO;
9 +
10 +public class SettlementPaymentBllDto
11 +{
12 + public Guid Id { get; set; }
13 + public DateTime CreatedAt { get; set; }
14 + public DateTime UpdatedAt { get; set; }
15 +
16 + public Guid SettlementPlanId { get; set; }
17 +
18 + public Guid FromUserId { get; set; }
19 + public AppUserBllDto? FromUser { get; set; }
20 + public string? FromUserFullName => FromUser?.FullName;
21 +
22 + public Guid ToUserId { get; set; }
23 + public AppUserBllDto? ToUser { get; set; }
24 + public string? ToUserFullName => ToUser?.FullName;
25 +
26 + public decimal Amount { get; set; }
27 + public EPaymentStatus Status { get; set; } = EPaymentStatus.Pending;
28 + public DateTime? MarkedPaidAt { get; set; }
29 + public DateTime? ConfirmedAt { get; set; }
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPlanBllDto.cs +29 −0
@@ -0,0 +1,29 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.WebApp.Application.DTO;
9 +
10 +public class SettlementPlanBllDto
11 +{
12 + public Guid Id { get; set; }
13 + public DateTime CreatedAt { get; set; }
14 + public DateTime UpdatedAt { get; set; }
15 +
16 + public Guid TripId { get; set; }
17 + public TripBllDto? Trip { get; set; }
18 + public string? TripName => Trip?.Name;
19 +
20 + public Guid CreatedByUserId { get; set; }
21 + public AppUserBllDto? CreatedByUser { get; set; }
22 + public string? CreatedByUserFullName => CreatedByUser?.FullName;
23 +
24 + public decimal TotalAmount { get; set; }
25 + public ESettlementStatus Status { get; set; } = ESettlementStatus.Pending;
26 + public DateTime? CompletedAt { get; set; }
27 +
28 + public ICollection<SettlementPaymentBllDto>? Payments { get; set; }
29 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SplitPresetBllDto.cs +41 −0
@@ -0,0 +1,41 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.WebApp.Application.DTO;
9 +
10 +public class SplitPresetBllDto
11 +{
12 + public Guid Id { get; set; }
13 + public DateTime CreatedAt { get; set; }
14 + public DateTime UpdatedAt { get; set; }
15 +
16 + public Guid TripId { get; set; }
17 + public TripBllDto? Trip { get; set; }
18 + public string? TripName => Trip?.Name;
19 +
20 + public string Name { get; set; } = default!;
21 + public ESplitMethod SplitMethod { get; set; }
22 +
23 + public Guid CreatedById { get; set; }
24 + public AppUserBllDto? CreatedBy { get; set; }
25 + public string? CreatedByFullName => CreatedBy?.FullName;
26 +
27 + public ICollection<SplitPresetMemberBllDto>? Members { get; set; }
28 +}
29 +
30 +public class SplitPresetMemberBllDto
31 +{
32 + public Guid Id { get; set; }
33 + public DateTime CreatedAt { get; set; }
34 + public DateTime UpdatedAt { get; set; }
35 +
36 + public Guid SplitPresetId { get; set; }
37 + public Guid UserId { get; set; }
38 + public string? UserFullName { get; set; }
39 + public decimal? ShareWeight { get; set; }
40 + public decimal? Percentage { get; set; }
41 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripBllDto.cs +38 −0
@@ -0,0 +1,38 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.WebApp.Application.DTO;
9 +
10 +public class TripBllDto
11 +{
12 + public Guid Id { get; set; }
13 + public DateTime CreatedAt { get; set; }
14 + public DateTime UpdatedAt { get; set; }
15 +
16 + public string Name { get; set; } = default!;
17 + public string? Description { get; set; }
18 + public string? Destination { get; set; }
19 + public DateTime? StartDate { get; set; }
20 + public DateTime? EndDate { get; set; }
21 + public ETripStatus Status { get; set; } = ETripStatus.Active;
22 +
23 + public Guid DefaultCurrencyId { get; set; }
24 + public CurrencyBllDto? DefaultCurrency { get; set; }
25 +
26 + public Guid CreatedById { get; set; }
27 + public AppUserBllDto? CreatedBy { get; set; }
28 + public string? CreatedByFullName => CreatedBy?.FullName;
29 + public string? CreatedByEmail => CreatedBy?.Email;
30 +
31 + public ICollection<TripParticipantBllDto>? Participants { get; set; }
32 + public ICollection<ExpenseBllDto>? Expenses { get; set; }
33 + public ICollection<BudgetCategoryBllDto>? BudgetCategories { get; set; }
34 + public ICollection<TripWishlistItemBllDto>? WishlistItems { get; set; }
35 + public ICollection<TripPollBllDto>? Polls { get; set; }
36 + public ICollection<TripInvitationBllDto>? Invitations { get; set; }
37 + public ICollection<SettlementPlanBllDto>? SettlementPlans { get; set; }
38 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripInvitationBllDto.cs +29 −0
@@ -0,0 +1,29 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.WebApp.Application.DTO;
9 +
10 +public class TripInvitationBllDto
11 +{
12 + public Guid Id { get; set; }
13 + public DateTime CreatedAt { get; set; }
14 + public DateTime UpdatedAt { get; set; }
15 +
16 + public Guid TripId { get; set; }
17 + public TripBllDto? Trip { get; set; }
18 + public string? TripName => Trip?.Name;
19 +
20 + public Guid InvitedByUserId { get; set; }
21 + public AppUserBllDto? InvitedByUser { get; set; }
22 + public string? InvitedByUserFullName => InvitedByUser?.FullName;
23 + public string? InvitedByUserEmail => InvitedByUser?.Email;
24 +
25 + public string Token { get; set; } = default!;
26 + public EInvitationStatus Status { get; set; } = EInvitationStatus.Pending;
27 + public DateTime ExpiresAt { get; set; }
28 + public DateTime? RespondedAt { get; set; }
29 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripParticipantBllDto.cs +29 −0
@@ -0,0 +1,29 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.WebApp.Application.DTO;
9 +
10 +public class TripParticipantBllDto
11 +{
12 + public Guid Id { get; set; }
13 + public DateTime CreatedAt { get; set; }
14 + public DateTime UpdatedAt { get; set; }
15 +
16 + public Guid TripId { get; set; }
17 + public TripBllDto? Trip { get; set; }
18 + public Guid UserId { get; set; }
19 + public AppUserBllDto? User { get; set; }
20 + public string? UserFirstName => User?.FirstName;
21 + public string? UserLastName => User?.LastName;
22 + public string? UserEmail => User?.Email;
23 +
24 + public EParticipantRole Role { get; set; } = EParticipantRole.Participant;
25 + public string? Nickname { get; set; }
26 + public DateTime JoinedAt { get; set; }
27 + public DateTime? LeftAt { get; set; }
28 + public bool IsActive { get; set; } = true;
29 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollBllDto.cs +26 −0
@@ -0,0 +1,26 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class TripPollBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public Guid TripId { get; set; }
13 + public TripBllDto? Trip { get; set; }
14 + public string? TripName => Trip?.Name;
15 +
16 + public Guid CreatedByUserId { get; set; }
17 + public AppUserBllDto? CreatedByUser { get; set; }
18 + public string? CreatedByUserFullName => CreatedByUser?.FullName;
19 +
20 + public string Question { get; set; } = default!;
21 + public bool AllowMultipleVotes { get; set; }
22 + public bool IsAnonymous { get; set; }
23 + public DateTime? ClosedAt { get; set; }
24 +
25 + public ICollection<TripPollOptionBllDto>? Options { get; set; }
26 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollOptionBllDto.cs +18 −0
@@ -0,0 +1,18 @@
1 +using SplitApp.Shared.Kernel.Domain;
2 +using SplitApp.Shared.Kernel.Localization;
3 +
4 +namespace SplitApp.WebApp.Application.DTO;
5 +
6 +public class TripPollOptionBllDto
7 +{
8 + public Guid Id { get; set; }
9 + public DateTime CreatedAt { get; set; }
10 + public DateTime UpdatedAt { get; set; }
11 +
12 + public Guid PollId { get; set; }
13 + public string Text { get; set; } = default!;
14 + public int DisplayOrder { get; set; }
15 + public int VoteCount { get; set; }
16 + public List<Guid> VoterUserIds { get; set; } = new();
17 + public List<AppUserBllDto> Voters { get; set; } = new();
18 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripWishlistItemBllDto.cs +37 −0
@@ -0,0 +1,37 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Domain.Entities;
4 +using SplitApp.Modules.Expenses.Domain.Enums;
5 +using SplitApp.Shared.Kernel.Domain;
6 +using SplitApp.Shared.Kernel.Localization;
7 +
8 +namespace SplitApp.WebApp.Application.DTO;
9 +
10 +public class TripWishlistItemBllDto
11 +{
12 + public Guid Id { get; set; }
13 + public DateTime CreatedAt { get; set; }
14 + public DateTime UpdatedAt { get; set; }
15 +
16 + public Guid TripId { get; set; }
17 + public TripBllDto? Trip { get; set; }
18 + public string? TripName => Trip?.Name;
19 +
20 + public Guid AddedByUserId { get; set; }
21 + public AppUserBllDto? AddedByUser { get; set; }
22 + public string? AddedByUserFullName => AddedByUser?.FullName;
23 +
24 + public string Title { get; set; } = default!;
25 + public string? Description { get; set; }
26 + public EWishlistCategory Category { get; set; }
27 + public EWishlistPriority Priority { get; set; }
28 + public decimal? EstimatedCost { get; set; }
29 + public string? Url { get; set; }
30 + public string? Location { get; set; }
31 + public bool IsCompleted { get; set; }
32 + public DateTime? CompletedAt { get; set; }
33 + public int DisplayOrder { get; set; }
34 +
35 + public int VoteCount { get; set; }
36 + public List<Guid> VoterUserIds { get; set; } = new();
37 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Helpers/CurrencyConverter.cs +30 −0
@@ -0,0 +1,30 @@
1 +namespace SplitApp.WebApp.Application.Helpers;
2 +
3 +public static class CurrencyConverter
4 +{
5 + // Rates: 1 unit of currency = X EUR (approximate, hardcoded)
6 + private static readonly Dictionary<string, decimal> RatesToEur = new()
7 + {
8 + { "EUR", 1.0m },
9 + { "USD", 0.92m },
10 + { "GBP", 1.16m },
11 + { "SEK", 0.087m },
12 + { "NOK", 0.086m },
13 + };
14 +
15 + /// <summary>
16 + /// Convert amount from one currency to another.
17 + /// Falls back to 1:1 if either currency code is unknown.
18 + /// </summary>
19 + public static decimal Convert(decimal amount, string fromCurrencyCode, string toCurrencyCode)
20 + {
21 + if (fromCurrencyCode == toCurrencyCode) return amount;
22 +
23 + if (!RatesToEur.TryGetValue(fromCurrencyCode, out var fromRate)) return amount;
24 + if (!RatesToEur.TryGetValue(toCurrencyCode, out var toRate)) return amount;
25 +
26 + // Convert: amount in "from" -> EUR -> "to"
27 + var amountInEur = amount * fromRate;
28 + return Math.Round(amountInEur / toRate, 2);
29 + }
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/AppUserBllDtoFactory.cs +26 −0
@@ -0,0 +1,26 @@
1 +using SplitApp.Shared.Contracts.Users;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class AppUserBllDtoFactory
7 +{
8 + /// <summary>
9 + /// Project the cross-service UserDto into the BLL shape used by views (which still
10 + /// read FirstName/LastName separately for initials, etc.). DisplayName is split on
11 + /// the first space — fidelity loss with 3-part names is acceptable for display.
12 + /// </summary>
13 + public static AppUserBllDto? Create(UserDto? dto)
14 + {
15 + if (dto is null) return null;
16 + var display = dto.DisplayName ?? "";
17 + var parts = display.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
18 + return new AppUserBllDto
19 + {
20 + Id = dto.Id,
21 + FirstName = parts.Length > 0 ? parts[0] : "",
22 + LastName = parts.Length > 1 ? parts[1] : "",
23 + Email = dto.Email,
24 + };
25 + }
26 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/BudgetCategoryBllDtoFactory.cs +42 −0
@@ -0,0 +1,42 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class BudgetCategoryBllDtoFactory
7 +{
8 + public static BudgetCategoryBllDto Create(BudgetCategory entity, decimal spentAmount = 0) => new()
9 + {
10 + Id = entity.Id,
11 + CreatedAt = entity.CreatedAt,
12 + UpdatedAt = entity.UpdatedAt,
13 + TripId = entity.TripId,
14 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
15 + Name = entity.Name,
16 + IconName = entity.IconName,
17 + PlannedAmount = entity.PlannedAmount,
18 + DisplayOrder = entity.DisplayOrder,
19 + ExpenseCount = 0,
20 + SpentAmount = spentAmount
21 + };
22 +
23 + public static List<BudgetCategoryBllDto> CreateList(IEnumerable<BudgetCategory> entities)
24 + => entities.Select(e => Create(e)).ToList();
25 +
26 + public static List<BudgetCategoryBllDto> CreateList(
27 + IEnumerable<BudgetCategory> entities,
28 + IReadOnlyDictionary<Guid, decimal> spentByCategoryId)
29 + => entities
30 + .Select(e => Create(e, spentByCategoryId.TryGetValue(e.Id, out var s) ? s : 0))
31 + .ToList();
32 +
33 + public static BudgetCategory ToEntity(BudgetCategoryBllDto dto) => new()
34 + {
35 + Id = dto.Id,
36 + TripId = dto.TripId,
37 + Name = dto.Name,
38 + IconName = dto.IconName,
39 + PlannedAmount = dto.PlannedAmount,
40 + DisplayOrder = dto.DisplayOrder
41 + };
42 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/CurrencyBllDtoFactory.cs +31 −0
@@ -0,0 +1,31 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +
7 +namespace SplitApp.WebApp.Application.Mappers;
8 +
9 +public static class CurrencyBllDtoFactory
10 +{
11 + public static CurrencyBllDto Create(Currency entity) => new()
12 + {
13 + Id = entity.Id,
14 + CreatedAt = entity.CreatedAt,
15 + UpdatedAt = entity.UpdatedAt,
16 + Code = entity.Code,
17 + Name = entity.Name,
18 + Symbol = entity.Symbol
19 + };
20 +
21 + public static List<CurrencyBllDto> CreateList(IEnumerable<Currency> entities)
22 + => entities.Select(Create).ToList();
23 +
24 + public static Currency ToEntity(CurrencyBllDto dto) => new()
25 + {
26 + Id = dto.Id,
27 + Code = dto.Code,
28 + Name = dto.Name,
29 + Symbol = dto.Symbol
30 + };
31 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/ExpenseBllDtoFactory.cs +71 −0
@@ -0,0 +1,71 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.WebApp.Application.DTO;
4 +
5 +namespace SplitApp.WebApp.Application.Mappers;
6 +
7 +public static class ExpenseBllDtoFactory
8 +{
9 + public static ExpenseBllDto Create(Expense entity, bool includeSplits = false) => new()
10 + {
11 + Id = entity.Id,
12 + CreatedAt = entity.CreatedAt,
13 + UpdatedAt = entity.UpdatedAt,
14 + TripId = entity.TripId,
15 + // entity.Trip is `object?` (Expenses module avoids direct ref to Trip type) — cast back here.
16 + Trip = entity.Trip is Trip trip ? TripBllDtoFactory.Create(trip) : null,
17 + PaidByUserId = entity.PaidByUserId,
18 + PaidByUser = AppUserBllDtoFactory.Create(entity.PaidByUser),
19 + BudgetCategoryId = entity.BudgetCategoryId,
20 + CurrencyId = entity.CurrencyId,
21 + Currency = entity.Currency != null ? CurrencyBllDtoFactory.Create(entity.Currency) : null,
22 + Amount = entity.Amount,
23 + Description = entity.Description,
24 + ExpenseDate = entity.ExpenseDate,
25 + SplitMethod = entity.SplitMethod,
26 + Splits = includeSplits && entity.Splits != null
27 + ? entity.Splits.Select(ExpenseSplitBllDtoFactory.Create).ToList()
28 + : null
29 + };
30 +
31 + public static List<ExpenseBllDto> CreateList(IEnumerable<Expense> entities, bool includeSplits = false)
32 + => entities.Select(e => Create(e, includeSplits)).ToList();
33 +
34 + public static Expense ToEntity(ExpenseBllDto dto) => new()
35 + {
36 + Id = dto.Id,
37 + TripId = dto.TripId,
38 + PaidByUserId = dto.PaidByUserId,
39 + BudgetCategoryId = dto.BudgetCategoryId,
40 + CurrencyId = dto.CurrencyId,
41 + Amount = dto.Amount,
42 + Description = dto.Description,
43 + ExpenseDate = dto.ExpenseDate,
44 + SplitMethod = dto.SplitMethod
45 + };
46 +}
47 +
48 +public static class ExpenseSplitBllDtoFactory
49 +{
50 + public static ExpenseSplitBllDto Create(ExpenseSplit entity) => new()
51 + {
52 + Id = entity.Id,
53 + CreatedAt = entity.CreatedAt,
54 + UpdatedAt = entity.UpdatedAt,
55 + ExpenseId = entity.ExpenseId,
56 + UserId = entity.UserId,
57 + UserFullName = entity.User?.DisplayName,
58 + UserEmail = entity.User?.Email,
59 + Amount = entity.Amount,
60 + Percentage = entity.Percentage
61 + };
62 +
63 + public static ExpenseSplit ToEntity(ExpenseSplitBllDto dto) => new()
64 + {
65 + Id = dto.Id,
66 + ExpenseId = dto.ExpenseId,
67 + UserId = dto.UserId,
68 + Amount = dto.Amount,
69 + Percentage = dto.Percentage
70 + };
71 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/InvitationBllDtoFactory.cs +36 −0
@@ -0,0 +1,36 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class InvitationBllDtoFactory
7 +{
8 + public static TripInvitationBllDto Create(TripInvitation entity) => new()
9 + {
10 + Id = entity.Id,
11 + CreatedAt = entity.CreatedAt,
12 + UpdatedAt = entity.UpdatedAt,
13 + TripId = entity.TripId,
14 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
15 + InvitedByUserId = entity.InvitedByUserId,
16 + InvitedByUser = AppUserBllDtoFactory.Create(entity.InvitedByUser),
17 + Token = entity.Token,
18 + Status = entity.Status,
19 + ExpiresAt = entity.ExpiresAt,
20 + RespondedAt = entity.RespondedAt
21 + };
22 +
23 + public static List<TripInvitationBllDto> CreateList(IEnumerable<TripInvitation> entities)
24 + => entities.Select(Create).ToList();
25 +
26 + public static TripInvitation ToEntity(TripInvitationBllDto dto) => new()
27 + {
28 + Id = dto.Id,
29 + TripId = dto.TripId,
30 + InvitedByUserId = dto.InvitedByUserId,
31 + Token = dto.Token,
32 + Status = dto.Status,
33 + ExpiresAt = dto.ExpiresAt,
34 + RespondedAt = dto.RespondedAt
35 + };
36 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/PollBllDtoFactory.cs +63 −0
@@ -0,0 +1,63 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class PollBllDtoFactory
7 +{
8 + public static TripPollBllDto Create(TripPoll entity, bool includeOptions = false) => new()
9 + {
10 + Id = entity.Id,
11 + CreatedAt = entity.CreatedAt,
12 + UpdatedAt = entity.UpdatedAt,
13 + TripId = entity.TripId,
14 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
15 + CreatedByUserId = entity.CreatedByUserId,
16 + CreatedByUser = AppUserBllDtoFactory.Create(entity.CreatedByUser),
17 + Question = entity.Question,
18 + AllowMultipleVotes = entity.AllowMultipleVotes,
19 + IsAnonymous = entity.IsAnonymous,
20 + ClosedAt = entity.ClosedAt,
21 + Options = includeOptions && entity.Options != null
22 + ? entity.Options.OrderBy(o => o.DisplayOrder).Select(PollOptionBllDtoFactory.Create).ToList()
23 + : null
24 + };
25 +
26 + public static List<TripPollBllDto> CreateList(IEnumerable<TripPoll> entities, bool includeOptions = false)
27 + => entities.Select(p => Create(p, includeOptions)).ToList();
28 +
29 + public static TripPoll ToEntity(TripPollBllDto dto) => new()
30 + {
31 + Id = dto.Id,
32 + TripId = dto.TripId,
33 + CreatedByUserId = dto.CreatedByUserId,
34 + Question = dto.Question,
35 + AllowMultipleVotes = dto.AllowMultipleVotes,
36 + IsAnonymous = dto.IsAnonymous,
37 + ClosedAt = dto.ClosedAt
38 + };
39 +}
40 +
41 +public static class PollOptionBllDtoFactory
42 +{
43 + public static TripPollOptionBllDto Create(TripPollOption entity) => new()
44 + {
45 + Id = entity.Id,
46 + CreatedAt = entity.CreatedAt,
47 + UpdatedAt = entity.UpdatedAt,
48 + PollId = entity.PollId,
49 + Text = entity.Text,
50 + DisplayOrder = entity.DisplayOrder,
51 + VoteCount = entity.Votes?.Count ?? 0,
52 + VoterUserIds = entity.Votes?.Select(v => v.UserId).ToList() ?? new List<Guid>(),
53 + Voters = new List<AppUserBllDto>()
54 + };
55 +
56 + public static TripPollOption ToEntity(TripPollOptionBllDto dto) => new()
57 + {
58 + Id = dto.Id,
59 + PollId = dto.PollId,
60 + Text = dto.Text,
61 + DisplayOrder = dto.DisplayOrder
62 + };
63 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SettlementBllDtoFactory.cs +73 −0
@@ -0,0 +1,73 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.WebApp.Application.DTO;
4 +
5 +namespace SplitApp.WebApp.Application.Mappers;
6 +
7 +public static class SettlementBllDtoFactory
8 +{
9 + public static SettlementPlanBllDto Create(SettlementPlan entity, bool includePayments = false) => new()
10 + {
11 + Id = entity.Id,
12 + CreatedAt = entity.CreatedAt,
13 + UpdatedAt = entity.UpdatedAt,
14 + TripId = entity.TripId,
15 + // entity.Trip is `object?` (Expenses module avoids direct ref to Trip type) — cast back here.
16 + Trip = entity.Trip is Trip trip ? TripBllDtoFactory.Create(trip) : null,
17 + CreatedByUserId = entity.CreatedByUserId,
18 + CreatedByUser = AppUserBllDtoFactory.Create(entity.CreatedByUser),
19 + TotalAmount = entity.TotalAmount,
20 + Status = entity.Status,
21 + CompletedAt = entity.CompletedAt,
22 + Payments = includePayments && entity.Payments != null
23 + ? entity.Payments.Select(SettlementPaymentBllDtoFactory.Create).ToList()
24 + : null
25 + };
26 +
27 + public static List<SettlementPlanBllDto> CreateList(IEnumerable<SettlementPlan> entities, bool includePayments = false)
28 + => entities.Select(p => Create(p, includePayments)).ToList();
29 +
30 + public static SettlementPlan ToEntity(SettlementPlanBllDto dto) => new()
31 + {
32 + Id = dto.Id,
33 + TripId = dto.TripId,
34 + CreatedByUserId = dto.CreatedByUserId,
35 + TotalAmount = dto.TotalAmount,
36 + Status = dto.Status,
37 + CompletedAt = dto.CompletedAt
38 + };
39 +}
40 +
41 +public static class SettlementPaymentBllDtoFactory
42 +{
43 + public static SettlementPaymentBllDto Create(SettlementPayment entity) => new()
44 + {
45 + Id = entity.Id,
46 + CreatedAt = entity.CreatedAt,
47 + UpdatedAt = entity.UpdatedAt,
48 + SettlementPlanId = entity.SettlementPlanId,
49 + FromUserId = entity.FromUserId,
50 + FromUser = AppUserBllDtoFactory.Create(entity.FromUser),
51 + ToUserId = entity.ToUserId,
52 + ToUser = AppUserBllDtoFactory.Create(entity.ToUser),
53 + Amount = entity.Amount,
54 + Status = entity.Status,
55 + MarkedPaidAt = entity.MarkedPaidAt,
56 + ConfirmedAt = entity.ConfirmedAt
57 + };
58 +
59 + public static List<SettlementPaymentBllDto> CreateList(IEnumerable<SettlementPayment> entities)
60 + => entities.Select(Create).ToList();
61 +
62 + public static SettlementPayment ToEntity(SettlementPaymentBllDto dto) => new()
63 + {
64 + Id = dto.Id,
65 + SettlementPlanId = dto.SettlementPlanId,
66 + FromUserId = dto.FromUserId,
67 + ToUserId = dto.ToUserId,
68 + Amount = dto.Amount,
69 + Status = dto.Status,
70 + MarkedPaidAt = dto.MarkedPaidAt,
71 + ConfirmedAt = dto.ConfirmedAt
72 + };
73 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SplitPresetBllDtoFactory.cs +61 −0
@@ -0,0 +1,61 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.WebApp.Application.DTO;
4 +
5 +namespace SplitApp.WebApp.Application.Mappers;
6 +
7 +public static class SplitPresetBllDtoFactory
8 +{
9 + public static SplitPresetBllDto Create(SplitPreset entity, bool includeMembers = false) => new()
10 + {
11 + Id = entity.Id,
12 + CreatedAt = entity.CreatedAt,
13 + UpdatedAt = entity.UpdatedAt,
14 + TripId = entity.TripId,
15 + // entity.Trip is `object?` (Expenses module avoids direct ref to Trip type) — cast back here.
16 + Trip = entity.Trip is Trip trip ? TripBllDtoFactory.Create(trip) : null,
17 + Name = entity.Name,
18 + SplitMethod = entity.SplitMethod,
19 + CreatedById = entity.CreatedById,
20 + CreatedBy = AppUserBllDtoFactory.Create(entity.CreatedBy),
21 + Members = includeMembers && entity.Members != null
22 + ? entity.Members.Select(SplitPresetMemberBllDtoFactory.Create).ToList()
23 + : null
24 + };
25 +
26 + public static List<SplitPresetBllDto> CreateList(IEnumerable<SplitPreset> entities, bool includeMembers = false)
27 + => entities.Select(p => Create(p, includeMembers)).ToList();
28 +
29 + public static SplitPreset ToEntity(SplitPresetBllDto dto) => new()
30 + {
31 + Id = dto.Id,
32 + TripId = dto.TripId,
33 + Name = dto.Name,
34 + SplitMethod = dto.SplitMethod,
35 + CreatedById = dto.CreatedById
36 + };
37 +}
38 +
39 +public static class SplitPresetMemberBllDtoFactory
40 +{
41 + public static SplitPresetMemberBllDto Create(SplitPresetMember entity) => new()
42 + {
43 + Id = entity.Id,
44 + CreatedAt = entity.CreatedAt,
45 + UpdatedAt = entity.UpdatedAt,
46 + SplitPresetId = entity.SplitPresetId,
47 + UserId = entity.UserId,
48 + UserFullName = entity.User?.DisplayName,
49 + ShareWeight = entity.ShareWeight,
50 + Percentage = entity.Percentage
51 + };
52 +
53 + public static SplitPresetMember ToEntity(SplitPresetMemberBllDto dto) => new()
54 + {
55 + Id = dto.Id,
56 + SplitPresetId = dto.SplitPresetId,
57 + UserId = dto.UserId,
58 + ShareWeight = dto.ShareWeight,
59 + Percentage = dto.Percentage
60 + };
61 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/TripBllDtoFactory.cs +86 −0
@@ -0,0 +1,86 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class TripBllDtoFactory
7 +{
8 + public static TripBllDto Create(Trip entity, bool includeParticipants = false, bool includeExpenses = false)
9 + {
10 + var dto = new TripBllDto
11 + {
12 + Id = entity.Id,
13 + CreatedAt = entity.CreatedAt,
14 + UpdatedAt = entity.UpdatedAt,
15 + Name = entity.Name,
16 + Description = entity.Description,
17 + Destination = entity.Destination,
18 + StartDate = entity.StartDate,
19 + EndDate = entity.EndDate,
20 + Status = entity.Status,
21 + DefaultCurrencyId = entity.DefaultCurrencyId,
22 + DefaultCurrency = entity.DefaultCurrency != null ? CurrencyBllDtoFactory.Create(entity.DefaultCurrency) : null,
23 + CreatedById = entity.CreatedById,
24 + CreatedBy = AppUserBllDtoFactory.Create(entity.CreatedBy),
25 + };
26 +
27 + if (includeParticipants && entity.Participants != null)
28 + {
29 + dto.Participants = entity.Participants
30 + .Select(TripParticipantBllDtoFactory.Create)
31 + .ToList();
32 + }
33 +
34 + return dto;
35 + }
36 +
37 + public static List<TripBllDto> CreateList(IEnumerable<Trip> entities, bool includeParticipants = false)
38 + => entities.Select(t => Create(t, includeParticipants)).ToList();
39 +
40 + public static Trip ToEntity(TripBllDto dto) => new()
41 + {
42 + Id = dto.Id,
43 + Name = dto.Name,
44 + Description = dto.Description,
45 + Destination = dto.Destination,
46 + StartDate = dto.StartDate,
47 + EndDate = dto.EndDate,
48 + Status = dto.Status,
49 + DefaultCurrencyId = dto.DefaultCurrencyId,
50 + CreatedById = dto.CreatedById
51 + };
52 +}
53 +
54 +public static class TripParticipantBllDtoFactory
55 +{
56 + public static TripParticipantBllDto Create(TripParticipant entity) => new()
57 + {
58 + Id = entity.Id,
59 + CreatedAt = entity.CreatedAt,
60 + UpdatedAt = entity.UpdatedAt,
61 + TripId = entity.TripId,
62 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
63 + UserId = entity.UserId,
64 + User = AppUserBllDtoFactory.Create(entity.User),
65 + Role = entity.Role,
66 + Nickname = entity.Nickname,
67 + JoinedAt = entity.JoinedAt,
68 + LeftAt = entity.LeftAt,
69 + IsActive = entity.IsActive
70 + };
71 +
72 + public static List<TripParticipantBllDto> CreateList(IEnumerable<TripParticipant> entities)
73 + => entities.Select(Create).ToList();
74 +
75 + public static TripParticipant ToEntity(TripParticipantBllDto dto) => new()
76 + {
77 + Id = dto.Id,
78 + TripId = dto.TripId,
79 + UserId = dto.UserId,
80 + Role = dto.Role,
81 + Nickname = dto.Nickname,
82 + JoinedAt = dto.JoinedAt,
83 + LeftAt = dto.LeftAt,
84 + IsActive = dto.IsActive
85 + };
86 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/WishlistBllDtoFactory.cs +50 −0
@@ -0,0 +1,50 @@
1 +using SplitApp.Modules.Trips.Domain.Entities;
2 +using SplitApp.WebApp.Application.DTO;
3 +
4 +namespace SplitApp.WebApp.Application.Mappers;
5 +
6 +public static class WishlistBllDtoFactory
7 +{
8 + public static TripWishlistItemBllDto Create(TripWishlistItem entity) => new()
9 + {
10 + Id = entity.Id,
11 + CreatedAt = entity.CreatedAt,
12 + UpdatedAt = entity.UpdatedAt,
13 + TripId = entity.TripId,
14 + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null,
15 + AddedByUserId = entity.AddedByUserId,
16 + AddedByUser = AppUserBllDtoFactory.Create(entity.AddedByUser),
17 + Title = entity.Title,
18 + Description = entity.Description,
19 + Category = entity.Category,
20 + Priority = entity.Priority,
21 + EstimatedCost = entity.EstimatedCost,
22 + Url = entity.Url,
23 + Location = entity.Location,
24 + IsCompleted = entity.IsCompleted,
25 + CompletedAt = entity.CompletedAt,
26 + DisplayOrder = entity.DisplayOrder,
27 + VoteCount = entity.Votes?.Count(v => v.IsInterested) ?? 0,
28 + VoterUserIds = entity.Votes?.Where(v => v.IsInterested).Select(v => v.UserId).ToList() ?? new List<Guid>()
29 + };
30 +
31 + public static List<TripWishlistItemBllDto> CreateList(IEnumerable<TripWishlistItem> entities)
32 + => entities.Select(Create).ToList();
33 +
34 + public static TripWishlistItem ToEntity(TripWishlistItemBllDto dto) => new()
35 + {
36 + Id = dto.Id,
37 + TripId = dto.TripId,
38 + AddedByUserId = dto.AddedByUserId,
39 + Title = dto.Title,
40 + Description = dto.Description,
41 + Category = dto.Category,
42 + Priority = dto.Priority,
43 + EstimatedCost = dto.EstimatedCost,
44 + Url = dto.Url,
45 + Location = dto.Location,
46 + IsCompleted = dto.IsCompleted,
47 + CompletedAt = dto.CompletedAt,
48 + DisplayOrder = dto.DisplayOrder
49 + };
50 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Messaging/UserDeletedEventHandler.cs +47 −0
@@ -0,0 +1,47 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
3 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
4 +using SplitApp.Shared.Messaging;
5 +using SplitApp.Shared.Messaging.Integration.Users;
6 +
7 +namespace SplitApp.WebApp.Application.Messaging;
8 +
9 +/// <summary>
10 +/// Cascades user deletion across the monolith: drops the user's TripParticipant rows
11 +/// and removes ExpenseSplit references. Pure data cleanup — no orphan rows are deleted
12 +/// (the surrounding Trip / Expense stays so other participants keep their history).
13 +/// </summary>
14 +public class UserDeletedEventHandler : IEventHandler<UserDeletedEvent>
15 +{
16 + private readonly TripsDbContext _trips;
17 + private readonly ExpensesDbContext _expenses;
18 + private readonly ILogger<UserDeletedEventHandler> _logger;
19 +
20 + public UserDeletedEventHandler(
21 + TripsDbContext trips,
22 + ExpensesDbContext expenses,
23 + ILogger<UserDeletedEventHandler> logger)
24 + {
25 + _trips = trips;
26 + _expenses = expenses;
27 + _logger = logger;
28 + }
29 +
30 + public async Task HandleAsync(UserDeletedEvent @event, CancellationToken cancellationToken)
31 + {
32 + _logger.LogInformation("Cascade-cleaning data for deleted user {UserId}", @event.UserId);
33 +
34 + var participants = await _trips.TripParticipants
35 + .Where(p => p.UserId == @event.UserId)
36 + .ToListAsync(cancellationToken);
37 + _trips.TripParticipants.RemoveRange(participants);
38 +
39 + var splits = await _expenses.ExpenseSplits
40 + .Where(s => s.UserId == @event.UserId)
41 + .ToListAsync(cancellationToken);
42 + _expenses.ExpenseSplits.RemoveRange(splits);
43 +
44 + await _trips.SaveChangesAsync(cancellationToken);
45 + await _expenses.SaveChangesAsync(cancellationToken);
46 + }
47 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs +583 −0
@@ -0,0 +1,583 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Expenses.Domain.Entities;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
6 +using SplitApp.Shared.Contracts.Users;
7 +using SplitApp.Shared.Kernel.Domain;
8 +using SplitApp.Shared.Kernel.Persistence;
9 +using SplitApp.Shared.Messaging.Integration.Users;
10 +using SplitApp.WebApp.Application.Contracts;
11 +
12 +namespace SplitApp.WebApp.Application.Persistence;
13 +
14 +/// <summary>
15 +/// Composition-root unit-of-work that aggregates the Trips + Expenses module DbContexts.
16 +/// The Users module now lives in a separate process (SplitApp.UsersService) — user data
17 +/// is fetched via <see cref="IUserLookup"/> (RabbitMQ RPC) and projected as UserDto into
18 +/// the [NotMapped] cross-module nav properties on Trip/Expense entities.
19 +/// </summary>
20 +public class AppUnitOfWork : IAppUnitOfWork
21 +{
22 + private readonly TripsDbContext _tripsDb;
23 + private readonly ExpensesDbContext _expensesDb;
24 + private readonly IUserLookup _users;
25 +
26 + public AppUnitOfWork(TripsDbContext tripsDb, ExpensesDbContext expensesDb, IUserLookup users)
27 + {
28 + _tripsDb = tripsDb;
29 + _expensesDb = expensesDb;
30 + _users = users;
31 +
32 + Trips = new TripRepo(tripsDb, _users, expensesDb);
33 + Expenses = new ExpenseRepo(expensesDb, _users, tripsDb);
34 + TripParticipants = new TripParticipantRepo(tripsDb, _users);
35 + TripInvitations = new TripInvitationRepo(tripsDb, _users);
36 + SettlementPlans = new SettlementPlanRepo(expensesDb, _users, tripsDb);
37 + SettlementPayments = new SettlementPaymentRepo(expensesDb, _users);
38 + TripPolls = new TripPollRepo(tripsDb, _users);
39 + TripWishlistItems = new TripWishlistItemRepo(tripsDb, _users);
40 + SplitPresets = new SplitPresetRepo(expensesDb, _users, tripsDb);
41 + BudgetCategories = new BudgetCategoryRepo(tripsDb);
42 + }
43 +
44 + public ITripRepository Trips { get; }
45 + public IExpenseRepository Expenses { get; }
46 + public ITripParticipantRepository TripParticipants { get; }
47 + public ITripInvitationRepository TripInvitations { get; }
48 + public ISettlementPlanRepository SettlementPlans { get; }
49 + public ISettlementPaymentRepository SettlementPayments { get; }
50 + public ITripPollRepository TripPolls { get; }
51 + public ITripWishlistItemRepository TripWishlistItems { get; }
52 + public ISplitPresetRepository SplitPresets { get; }
53 + public IBudgetCategoryRepository BudgetCategories { get; }
54 +
55 + public async Task<int> SaveChangesAsync()
56 + {
57 + var trips = await _tripsDb.SaveChangesAsync();
58 + var expenses = await _expensesDb.SaveChangesAsync();
59 + return trips + expenses;
60 + }
61 +
62 + public IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity
63 + {
64 + var t = typeof(TEntity);
65 + if (t == typeof(SettlementPayment)) return (IBaseRepository<TEntity>)SettlementPayments;
66 + if (t == typeof(SettlementPlan)) return (IBaseRepository<TEntity>)SettlementPlans;
67 + if (t == typeof(Trip)) return (IBaseRepository<TEntity>)Trips;
68 + if (t == typeof(TripParticipant)) return (IBaseRepository<TEntity>)TripParticipants;
69 + if (t == typeof(Expense)) return (IBaseRepository<TEntity>)Expenses;
70 + if (t == typeof(TripInvitation)) return (IBaseRepository<TEntity>)TripInvitations;
71 + if (t == typeof(BudgetCategory)) return (IBaseRepository<TEntity>)BudgetCategories;
72 + if (t == typeof(TripPoll)) return (IBaseRepository<TEntity>)TripPolls;
73 + if (t == typeof(TripWishlistItem)) return (IBaseRepository<TEntity>)TripWishlistItems;
74 + if (t == typeof(SplitPreset)) return (IBaseRepository<TEntity>)SplitPresets;
75 + if (t == typeof(Currency)) return new GenericExpensesRepo<Currency>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
76 + if (t == typeof(ExpenseSplit)) return new GenericExpensesRepo<ExpenseSplit>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
77 + if (t == typeof(SplitPresetMember)) return new GenericExpensesRepo<SplitPresetMember>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
78 + if (t == typeof(TripPollOption)) return new GenericTripsRepo<TripPollOption>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
79 + if (t == typeof(TripPollVote)) return new GenericTripsRepo<TripPollVote>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
80 + if (t == typeof(TripWishlistVote)) return new GenericTripsRepo<TripWishlistVote>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException();
81 + throw new InvalidOperationException($"No repository registered for {t.Name}");
82 + }
83 +}
84 +
85 +internal class GenericTripsRepo<TEntity> : GenericRepo<TEntity, TripsDbContext>
86 + where TEntity : class, IBaseEntity
87 +{
88 + public GenericTripsRepo(TripsDbContext db) : base(db) { }
89 +}
90 +
91 +internal class GenericExpensesRepo<TEntity> : GenericRepo<TEntity, ExpensesDbContext>
92 + where TEntity : class, IBaseEntity
93 +{
94 + public GenericExpensesRepo(ExpensesDbContext db) : base(db) { }
95 +}
96 +
97 +internal class GenericRepo<TEntity, TDbContext> : IBaseRepository<TEntity>
98 + where TEntity : class, IBaseEntity
99 + where TDbContext : DbContext
100 +{
101 + protected readonly TDbContext Db;
102 + protected readonly DbSet<TEntity> Set;
103 +
104 + protected GenericRepo(TDbContext db)
105 + {
106 + Db = db;
107 + Set = db.Set<TEntity>();
108 + }
109 +
110 + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await Set.ToListAsync();
111 + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await Set.FirstOrDefaultAsync(e => e.Id == id);
112 + public virtual TEntity Add(TEntity entity) => Set.Add(entity).Entity;
113 + public virtual TEntity Update(TEntity entity) => Set.Update(entity).Entity;
114 + public virtual async Task<TEntity?> RemoveAsync(Guid id)
115 + {
116 + var e = await GetByIdAsync(id);
117 + if (e == null) return null;
118 + return Set.Remove(e).Entity;
119 + }
120 + public virtual async Task<bool> ExistsAsync(Guid id) => await Set.AnyAsync(e => e.Id == id);
121 +}
122 +
123 +internal static class CrossModuleHydration
124 +{
125 + /// <summary>Batches a single user-lookup over RabbitMQ, populates each item's User nav with UserDto.</summary>
126 + public static async Task HydrateUsersAsync<T>(
127 + IUserLookup userLookup,
128 + IEnumerable<T> items,
129 + Func<T, Guid> getUserId,
130 + Action<T, UserDto?> setUser) where T : class
131 + {
132 + var list = items as IList<T> ?? items.ToList();
133 + var ids = list.Select(getUserId).Where(id => id != Guid.Empty).Distinct().ToList();
134 + if (ids.Count == 0) return;
135 + var users = await userLookup.GetByIdsAsync(ids);
136 + foreach (var item in list)
137 + {
138 + users.TryGetValue(getUserId(item), out var u);
139 + setUser(item, u);
140 + }
141 + }
142 +
143 + public static async Task HydrateUsersAsync<T>(
144 + IUserLookup userLookup,
145 + IEnumerable<T> items,
146 + Func<T, Guid?> getUserId,
147 + Action<T, UserDto?> setUser) where T : class
148 + {
149 + var list = items as IList<T> ?? items.ToList();
150 + var ids = list.Select(getUserId).Where(id => id is { } x && x != Guid.Empty).Select(id => id!.Value).Distinct().ToList();
151 + if (ids.Count == 0) return;
152 + var users = await userLookup.GetByIdsAsync(ids);
153 + foreach (var item in list)
154 + {
155 + var id = getUserId(item);
156 + if (id.HasValue && users.TryGetValue(id.Value, out var u)) setUser(item, u);
157 + else setUser(item, null);
158 + }
159 + }
160 +
161 + public static async Task HydrateTripsAsync<T>(
162 + TripsDbContext tripsDb,
163 + IEnumerable<T> items,
164 + Func<T, Guid> getTripId,
165 + Action<T, Trip?> setTrip) where T : class
166 + {
167 + var list = items as IList<T> ?? items.ToList();
168 + var ids = list.Select(getTripId).Where(id => id != Guid.Empty).Distinct().ToList();
169 + if (ids.Count == 0) return;
170 + var trips = await tripsDb.Trips
171 + .Where(t => ids.Contains(t.Id))
172 + .ToDictionaryAsync(t => t.Id);
173 + foreach (var item in list)
174 + {
175 + trips.TryGetValue(getTripId(item), out var t);
176 + setTrip(item, t);
177 + }
178 + }
179 +}
180 +
181 +internal class TripRepo : GenericRepo<Trip, TripsDbContext>, ITripRepository
182 +{
183 + private readonly IUserLookup _users;
184 + private readonly ExpensesDbContext _expenses;
185 +
186 + public TripRepo(TripsDbContext db, IUserLookup users, ExpensesDbContext expenses) : base(db)
187 + {
188 + _users = users;
189 + _expenses = expenses;
190 + }
191 +
192 + private async Task HydrateTripAsync(Trip trip)
193 + {
194 + var creator = await _users.GetByIdAsync(trip.CreatedById);
195 + trip.CreatedBy = creator;
196 + trip.DefaultCurrency = await _expenses.Currencies.FirstOrDefaultAsync(c => c.Id == trip.DefaultCurrencyId);
197 +
198 + if (trip.Participants is { Count: > 0 } parts)
199 + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
200 + if (trip.Invitations is { Count: > 0 } invs)
201 + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
202 + if (trip.Polls is { Count: > 0 } polls)
203 + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
204 + if (trip.WishlistItems is { Count: > 0 } wls)
205 + await CrossModuleHydration.HydrateUsersAsync(_users, wls, w => w.AddedByUserId, (w, u) => w.AddedByUser = u);
206 + }
207 +
208 + public override async Task<Trip?> GetByIdAsync(Guid id)
209 + {
210 + var trip = await base.GetByIdAsync(id);
211 + if (trip != null) await HydrateTripAsync(trip);
212 + return trip;
213 + }
214 +
215 + public override async Task<IEnumerable<Trip>> GetAllAsync()
216 + {
217 + var trips = (await Db.Trips.ToListAsync());
218 + await CrossModuleHydration.HydrateUsersAsync(_users, trips, t => t.CreatedById, (t, u) => t.CreatedBy = u);
219 + var currencyIds = trips.Select(t => t.DefaultCurrencyId).Where(id => id != Guid.Empty).Distinct().ToList();
220 + if (currencyIds.Count > 0)
221 + {
222 + var currencies = await _expenses.Currencies
223 + .Where(c => currencyIds.Contains(c.Id))
224 + .ToDictionaryAsync(c => c.Id);
225 + foreach (var t in trips)
226 + {
227 + if (currencies.TryGetValue(t.DefaultCurrencyId, out var c)) t.DefaultCurrency = c;
228 + }
229 + }
230 + return trips;
231 + }
232 +
233 + public async Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId)
234 + {
235 + var trips = await Db.Trips
236 + .Include(t => t.Participants)
237 + .Where(t => t.CreatedById == userId
238 + || (t.Participants != null
239 + && t.Participants.Any(p => p.UserId == userId && p.IsActive)))
240 + .ToListAsync();
241 + foreach (var t in trips) await HydrateTripAsync(t);
242 + return trips;
243 + }
244 +
245 + public async Task<Trip?> GetByIdWithDetailsAsync(Guid id)
246 + {
247 + var trip = await Db.Trips
248 + .Include(t => t.Participants)
249 + .Include(t => t.BudgetCategories)
250 + .Include(t => t.WishlistItems)
251 + .Include(t => t.Polls)!.ThenInclude(p => p.Options)
252 + .Include(t => t.Invitations)
253 + .FirstOrDefaultAsync(t => t.Id == id);
254 + if (trip != null) await HydrateTripAsync(trip);
255 + return trip;
256 + }
257 +}
258 +
259 +internal class ExpenseRepo : GenericRepo<Expense, ExpensesDbContext>, IExpenseRepository
260 +{
261 + private readonly IUserLookup _users;
262 + private readonly TripsDbContext _trips;
263 + public ExpenseRepo(ExpensesDbContext db, IUserLookup users, TripsDbContext trips) : base(db)
264 + {
265 + _users = users;
266 + _trips = trips;
267 + }
268 +
269 + public override async Task<IEnumerable<Expense>> GetAllAsync()
270 + {
271 + var expenses = await Db.Expenses.Include(e => e.Currency).ToListAsync();
272 + await CrossModuleHydration.HydrateUsersAsync(_users, expenses, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
273 + await CrossModuleHydration.HydrateTripsAsync(_trips, expenses, e => e.TripId, (e, t) => e.Trip = t);
274 + return expenses;
275 + }
276 +
277 + public override async Task<Expense?> GetByIdAsync(Guid id)
278 + {
279 + var e = await Db.Expenses.Include(x => x.Currency).FirstOrDefaultAsync(x => x.Id == id);
280 + if (e != null)
281 + {
282 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { e }, x => x.PaidByUserId, (x, u) => x.PaidByUser = u);
283 + await CrossModuleHydration.HydrateTripsAsync(_trips, new[] { e }, x => x.TripId, (x, t) => x.Trip = t);
284 + }
285 + return e;
286 + }
287 +
288 + public async Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId)
289 + {
290 + var expenses = await Db.Expenses
291 + .Include(e => e.Currency)
292 + .Where(e => e.TripId == tripId)
293 + .ToListAsync();
294 + await CrossModuleHydration.HydrateUsersAsync(_users, expenses, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
295 + return expenses;
296 + }
297 +
298 + public async Task<Expense?> GetByIdWithDetailsAsync(Guid id)
299 + {
300 + var expense = await Db.Expenses
301 + .Include(e => e.Currency)
302 + .Include(e => e.Splits)
303 + .FirstOrDefaultAsync(e => e.Id == id);
304 + if (expense == null) return null;
305 +
306 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { expense }, e => e.PaidByUserId, (e, u) => e.PaidByUser = u);
307 + if (expense.Splits is { Count: > 0 } splits)
308 + await CrossModuleHydration.HydrateUsersAsync(_users, splits, s => s.UserId, (s, u) => s.User = u);
309 + return expense;
310 + }
311 +}
312 +
313 +internal class TripParticipantRepo : GenericRepo<TripParticipant, TripsDbContext>, ITripParticipantRepository
314 +{
315 + private readonly IUserLookup _users;
316 + public TripParticipantRepo(TripsDbContext db, IUserLookup users) : base(db) { _users = users; }
317 +
318 + public async Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
319 + => await Db.TripParticipants.AnyAsync(p => p.TripId == tripId && p.UserId == userId && p.IsActive);
320 +
321 + public async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
322 + {
323 + var trip = await Db.Trips.FirstOrDefaultAsync(t => t.Id == tripId);
324 + if (trip == null) return false;
325 + if (trip.CreatedById == userId) return true;
326 + return await Db.TripParticipants.AnyAsync(p =>
327 + p.TripId == tripId && p.UserId == userId && p.IsActive
328 + && p.Role == SplitApp.Modules.Trips.Domain.Enums.EParticipantRole.Organizer);
329 + }
330 +
331 + public async Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId)
332 + {
333 + var parts = await Db.TripParticipants.Where(p => p.TripId == tripId).ToListAsync();
334 + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
335 + return parts;
336 + }
337 +
338 + public override async Task<TripParticipant?> GetByIdAsync(Guid id)
339 + {
340 + var p = await Db.TripParticipants.Include(x => x.Trip).FirstOrDefaultAsync(x => x.Id == id);
341 + if (p != null)
342 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { p }, x => x.UserId, (x, u) => x.User = u);
343 + return p;
344 + }
345 +
346 + public override async Task<IEnumerable<TripParticipant>> GetAllAsync()
347 + {
348 + var parts = await Db.TripParticipants.Include(p => p.Trip).ToListAsync();
349 + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u);
350 + return parts;
351 + }
352 +}
353 +
354 +internal class TripInvitationRepo : GenericRepo<TripInvitation, TripsDbContext>, ITripInvitationRepository
355 +{
356 + private readonly IUserLookup _users;
357 + public TripInvitationRepo(TripsDbContext db, IUserLookup users) : base(db) { _users = users; }
358 +
359 + public override async Task<IEnumerable<TripInvitation>> GetAllAsync()
360 + {
361 + var invs = await Db.TripInvitations.Include(i => i.Trip).ToListAsync();
362 + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
363 + return invs;
364 + }
365 +
366 + public async Task<TripInvitation?> GetByTokenAsync(string token)
367 + {
368 + var inv = await Db.TripInvitations.Include(i => i.Trip).FirstOrDefaultAsync(i => i.Token == token);
369 + if (inv != null)
370 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { inv }, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
371 + return inv;
372 + }
373 +
374 + public async Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId)
375 + {
376 + var invs = await Db.TripInvitations
377 + .Where(i => i.TripId == tripId
378 + && i.Status == SplitApp.Modules.Trips.Domain.Enums.EInvitationStatus.Pending)
379 + .ToListAsync();
380 + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u);
381 + return invs;
382 + }
383 +}
384 +
385 +internal class SettlementPlanRepo : GenericRepo<SettlementPlan, ExpensesDbContext>, ISettlementPlanRepository
386 +{
387 + private readonly IUserLookup _users;
388 + private readonly TripsDbContext _trips;
389 + public SettlementPlanRepo(ExpensesDbContext db, IUserLookup users, TripsDbContext trips) : base(db)
390 + {
391 + _users = users;
392 + _trips = trips;
393 + }
394 +
395 + public override async Task<IEnumerable<SettlementPlan>> GetAllAsync()
396 + {
397 + var plans = await Db.SettlementPlans.ToListAsync();
398 + await CrossModuleHydration.HydrateUsersAsync(_users, plans, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
399 + await CrossModuleHydration.HydrateTripsAsync(_trips, plans, p => p.TripId, (p, t) => p.Trip = t);
400 + return plans;
401 + }
402 +
403 + private async Task HydratePlanAsync(SettlementPlan plan)
404 + {
405 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { plan }, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
406 + await CrossModuleHydration.HydrateTripsAsync(_trips, new[] { plan }, p => p.TripId, (p, t) => p.Trip = t);
407 + if (plan.Payments is { Count: > 0 } payments)
408 + {
409 + var ids = payments.SelectMany(p => new[] { p.FromUserId, p.ToUserId })
410 + .Where(id => id != Guid.Empty).Distinct().ToList();
411 + if (ids.Count > 0)
412 + {
413 + var users = await _users.GetByIdsAsync(ids);
414 + foreach (var p in payments)
415 + {
416 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
417 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
418 + }
419 + }
420 + }
421 + }
422 +
423 + public override async Task<SettlementPlan?> GetByIdAsync(Guid id)
424 + {
425 + var plan = await Db.SettlementPlans.Include(p => p.Payments).FirstOrDefaultAsync(p => p.Id == id);
426 + if (plan != null) await HydratePlanAsync(plan);
427 + return plan;
428 + }
429 +
430 + public async Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId)
431 + {
432 + var plan = await Db.SettlementPlans
433 + .Include(p => p.Payments)
434 + .Where(p => p.TripId == tripId)
435 + .OrderByDescending(p => p.CreatedAt)
436 + .FirstOrDefaultAsync();
437 + if (plan != null) await HydratePlanAsync(plan);
438 + return plan;
439 + }
440 +
441 + public async Task DeletePlanWithPaymentsAsync(Guid planId)
442 + {
443 + var payments = await Db.SettlementPayments.Where(p => p.SettlementPlanId == planId).ToListAsync();
444 + Db.SettlementPayments.RemoveRange(payments);
445 + var plan = await Db.SettlementPlans.FirstOrDefaultAsync(p => p.Id == planId);
446 + if (plan != null) Db.SettlementPlans.Remove(plan);
447 + await Db.SaveChangesAsync();
448 + }
449 +}
450 +
451 +internal class SettlementPaymentRepo : GenericRepo<SettlementPayment, ExpensesDbContext>, ISettlementPaymentRepository
452 +{
453 + private readonly IUserLookup _users;
454 + public SettlementPaymentRepo(ExpensesDbContext db, IUserLookup users) : base(db) { _users = users; }
455 +
456 + public override async Task<IEnumerable<SettlementPayment>> GetAllAsync()
457 + {
458 + var payments = await Db.SettlementPayments.ToListAsync();
459 + var ids = payments.SelectMany(p => new[] { p.FromUserId, p.ToUserId })
460 + .Where(id => id != Guid.Empty).Distinct().ToList();
461 + if (ids.Count > 0)
462 + {
463 + var users = await _users.GetByIdsAsync(ids);
464 + foreach (var p in payments)
465 + {
466 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
467 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
468 + }
469 + }
470 + return payments;
471 + }
472 +
473 + public override async Task<SettlementPayment?> GetByIdAsync(Guid id)
474 + {
475 + var p = await base.GetByIdAsync(id);
476 + if (p != null)
477 + {
478 + var ids = new[] { p.FromUserId, p.ToUserId }.Where(x => x != Guid.Empty).Distinct().ToList();
479 + var users = await _users.GetByIdsAsync(ids);
480 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
481 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
482 + }
483 + return p;
484 + }
485 +}
486 +
487 +internal class TripPollRepo : GenericRepo<TripPoll, TripsDbContext>, ITripPollRepository
488 +{
489 + private readonly IUserLookup _users;
490 + public TripPollRepo(TripsDbContext db, IUserLookup users) : base(db) { _users = users; }
491 +
492 + public override async Task<IEnumerable<TripPoll>> GetAllAsync()
493 + {
494 + var polls = await Db.TripPolls.Include(p => p.Trip).ToListAsync();
495 + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
496 + return polls;
497 + }
498 +
499 + public async Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId)
500 + {
501 + var polls = await Db.TripPolls
502 + .Include(p => p.Options)!
503 + .ThenInclude(o => o.Votes)
504 + .Where(p => p.TripId == tripId)
505 + .ToListAsync();
506 + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
507 + return polls;
508 + }
509 +
510 + public async Task<TripPoll?> GetByIdWithDetailsAsync(Guid id)
511 + {
512 + var poll = await Db.TripPolls
513 + .Include(p => p.Options)!
514 + .ThenInclude(o => o.Votes)
515 + .FirstOrDefaultAsync(p => p.Id == id);
516 + if (poll != null)
517 + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { poll }, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u);
518 + return poll;
519 + }
520 +}
521 +
522 +internal class TripWishlistItemRepo : GenericRepo<TripWishlistItem, TripsDbContext>, ITripWishlistItemRepository
523 +{
524 + private readonly IUserLookup _users;
525 + public TripWishlistItemRepo(TripsDbContext db, IUserLookup users) : base(db) { _users = users; }
526 +
527 + public override async Task<IEnumerable<TripWishlistItem>> GetAllAsync()
528 + {
529 + var items = await Db.TripWishlistItems.Include(i => i.Trip).ToListAsync();
530 + await CrossModuleHydration.HydrateUsersAsync(_users, items, i => i.AddedByUserId, (i, u) => i.AddedByUser = u);
531 + return items;
532 + }
533 +
534 + public async Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId)
535 + {
536 + var items = await Db.TripWishlistItems
537 + .Include(i => i.Votes)
538 + .Where(i => i.TripId == tripId)
539 + .ToListAsync();
540 + await CrossModuleHydration.HydrateUsersAsync(_users, items, i => i.AddedByUserId, (i, u) => i.AddedByUser = u);
541 + return items;
542 + }
543 +}
544 +
545 +internal class SplitPresetRepo : GenericRepo<SplitPreset, ExpensesDbContext>, ISplitPresetRepository
546 +{
547 + private readonly IUserLookup _users;
548 + private readonly TripsDbContext _trips;
549 + public SplitPresetRepo(ExpensesDbContext db, IUserLookup users, TripsDbContext trips) : base(db)
550 + {
551 + _users = users;
552 + _trips = trips;
553 + }
554 +
555 + public override async Task<IEnumerable<SplitPreset>> GetAllAsync()
556 + {
557 + var presets = await Db.SplitPresets.ToListAsync();
558 + await CrossModuleHydration.HydrateUsersAsync(_users, presets, p => p.CreatedById, (p, u) => p.CreatedBy = u);
559 + await CrossModuleHydration.HydrateTripsAsync(_trips, presets, p => p.TripId, (p, t) => p.Trip = t);
560 + return presets;
561 + }
562 +
563 + public async Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId)
564 + {
565 + var presets = await Db.SplitPresets
566 + .Include(p => p.Members)
567 + .Where(p => p.TripId == tripId)
568 + .ToListAsync();
569 + await CrossModuleHydration.HydrateUsersAsync(_users, presets, p => p.CreatedById, (p, u) => p.CreatedBy = u);
570 + var members = presets.SelectMany(p => p.Members ?? Enumerable.Empty<SplitPresetMember>()).ToList();
571 + if (members.Count > 0)
572 + await CrossModuleHydration.HydrateUsersAsync(_users, members, m => m.UserId, (m, u) => m.User = u);
573 + return presets;
574 + }
575 +}
576 +
577 +internal class BudgetCategoryRepo : GenericRepo<BudgetCategory, TripsDbContext>, IBudgetCategoryRepository
578 +{
579 + public BudgetCategoryRepo(TripsDbContext db) : base(db) { }
580 +
581 + public async Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId)
582 + => await Db.BudgetCategories.Where(c => c.TripId == tripId).ToListAsync();
583 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs +177 −0
@@ -0,0 +1,177 @@
1 +using Microsoft.EntityFrameworkCore;
2 +using SplitApp.Modules.Expenses.Domain.Entities;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
6 +using SplitApp.Shared.Contracts.Users;
7 +using SplitApp.Shared.Messaging.Integration.Users;
8 +
9 +namespace SplitApp.WebApp.Application.Persistence;
10 +
11 +/// <summary>
12 +/// Composition-root helper that hydrates the [NotMapped] cross-module nav properties
13 +/// after entities have been loaded by their owning module's DbContext. EF can't follow
14 +/// these because the FK crosses schemas (and now process boundaries) — the WebApp does
15 +/// it manually via <see cref="IUserLookup"/> (RabbitMQ RPC) for users and the in-process
16 +/// DbContexts for trips/currencies.
17 +/// </summary>
18 +public class CrossModuleNavigationLoader
19 +{
20 + private readonly IUserLookup _users;
21 + private readonly TripsDbContext _trips;
22 + private readonly ExpensesDbContext _expenses;
23 +
24 + public CrossModuleNavigationLoader(IUserLookup users, TripsDbContext trips, ExpensesDbContext expenses)
25 + {
26 + _users = users;
27 + _trips = trips;
28 + _expenses = expenses;
29 + }
30 +
31 + private async Task<IReadOnlyDictionary<Guid, UserDto>> UsersByIdAsync(IEnumerable<Guid> ids)
32 + => await _users.GetByIdsAsync(ids);
33 +
34 + private async Task<Dictionary<Guid, Currency>> CurrenciesByIdAsync(IEnumerable<Guid> ids)
35 + {
36 + var idList = ids.Where(id => id != Guid.Empty).Distinct().ToList();
37 + if (idList.Count == 0) return new Dictionary<Guid, Currency>();
38 + return await _expenses.Currencies.Where(c => idList.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
39 + }
40 +
41 + private async Task<Dictionary<Guid, Trip>> TripsByIdAsync(IEnumerable<Guid> ids)
42 + {
43 + var idList = ids.Where(id => id != Guid.Empty).Distinct().ToList();
44 + if (idList.Count == 0) return new Dictionary<Guid, Trip>();
45 + return await _trips.Trips.Where(t => idList.Contains(t.Id)).ToDictionaryAsync(t => t.Id);
46 + }
47 +
48 + public async Task PopulateAsync(Trip? trip)
49 + {
50 + if (trip == null) return;
51 + var users = await UsersByIdAsync(new[] { trip.CreatedById });
52 + var currencies = await CurrenciesByIdAsync(new[] { trip.DefaultCurrencyId });
53 + if (users.TryGetValue(trip.CreatedById, out var creator)) trip.CreatedBy = creator;
54 + if (currencies.TryGetValue(trip.DefaultCurrencyId, out var currency)) trip.DefaultCurrency = currency;
55 + if (trip.Participants != null)
56 + {
57 + var pUsers = await UsersByIdAsync(trip.Participants.Select(p => p.UserId));
58 + foreach (var p in trip.Participants)
59 + if (pUsers.TryGetValue(p.UserId, out var u)) p.User = u;
60 + }
61 + if (trip.Invitations != null)
62 + {
63 + var iUsers = await UsersByIdAsync(trip.Invitations.Select(i => i.InvitedByUserId));
64 + foreach (var i in trip.Invitations)
65 + if (iUsers.TryGetValue(i.InvitedByUserId, out var u)) i.InvitedByUser = u;
66 + }
67 + }
68 +
69 + public async Task PopulateAsync(IEnumerable<Trip> trips)
70 + {
71 + foreach (var t in trips) await PopulateAsync(t);
72 + }
73 +
74 + public async Task PopulateAsync(TripParticipant? p)
75 + {
76 + if (p == null) return;
77 + var users = await UsersByIdAsync(new[] { p.UserId });
78 + if (users.TryGetValue(p.UserId, out var u)) p.User = u;
79 + }
80 +
81 + public async Task PopulateAsync(IEnumerable<TripParticipant> ps)
82 + {
83 + var ids = ps.Select(p => p.UserId).ToList();
84 + var users = await UsersByIdAsync(ids);
85 + foreach (var p in ps) if (users.TryGetValue(p.UserId, out var u)) p.User = u;
86 + }
87 +
88 + public async Task PopulateAsync(Expense? e)
89 + {
90 + if (e == null) return;
91 + var users = await UsersByIdAsync(new[] { e.PaidByUserId });
92 + if (users.TryGetValue(e.PaidByUserId, out var u)) e.PaidByUser = u;
93 + if (e.Splits != null)
94 + {
95 + var splitUsers = await UsersByIdAsync(e.Splits.Select(s => s.UserId));
96 + foreach (var s in e.Splits)
97 + if (splitUsers.TryGetValue(s.UserId, out var su)) s.User = su;
98 + }
99 + }
100 +
101 + public async Task PopulateAsync(IEnumerable<Expense> es)
102 + {
103 + foreach (var e in es) await PopulateAsync(e);
104 + }
105 +
106 + public async Task PopulateAsync(SettlementPlan? plan)
107 + {
108 + if (plan == null) return;
109 + var users = await UsersByIdAsync(
110 + new[] { plan.CreatedByUserId }
111 + .Concat(plan.Payments?.Select(p => p.FromUserId) ?? Enumerable.Empty<Guid>())
112 + .Concat(plan.Payments?.Select(p => p.ToUserId) ?? Enumerable.Empty<Guid>()));
113 + if (users.TryGetValue(plan.CreatedByUserId, out var creator)) plan.CreatedByUser = creator;
114 + if (plan.Payments != null)
115 + foreach (var p in plan.Payments)
116 + {
117 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
118 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
119 + }
120 + }
121 +
122 + public async Task PopulateAsync(SettlementPayment? p)
123 + {
124 + if (p == null) return;
125 + var users = await UsersByIdAsync(new[] { p.FromUserId, p.ToUserId });
126 + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu;
127 + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu;
128 + }
129 +
130 + public async Task PopulateAsync(SplitPreset? sp)
131 + {
132 + if (sp == null) return;
133 + var users = await UsersByIdAsync(
134 + new[] { sp.CreatedById }
135 + .Concat(sp.Members?.Select(m => m.UserId) ?? Enumerable.Empty<Guid>()));
136 + if (users.TryGetValue(sp.CreatedById, out var creator)) sp.CreatedBy = creator;
137 + if (sp.Members != null)
138 + foreach (var m in sp.Members)
139 + if (users.TryGetValue(m.UserId, out var u)) m.User = u;
140 + }
141 +
142 + public async Task PopulateAsync(IEnumerable<SplitPreset> sps)
143 + {
144 + foreach (var sp in sps) await PopulateAsync(sp);
145 + }
146 +
147 + public async Task PopulateAsync(TripPoll? poll)
148 + {
149 + if (poll == null) return;
150 + var users = await UsersByIdAsync(new[] { poll.CreatedByUserId });
151 + if (users.TryGetValue(poll.CreatedByUserId, out var u)) poll.CreatedByUser = u;
152 + }
153 +
154 + public async Task PopulateAsync(IEnumerable<TripPoll> polls)
155 + {
156 + foreach (var p in polls) await PopulateAsync(p);
157 + }
158 +
159 + public async Task PopulateAsync(TripWishlistItem? item)
160 + {
161 + if (item == null) return;
162 + var users = await UsersByIdAsync(new[] { item.AddedByUserId });
163 + if (users.TryGetValue(item.AddedByUserId, out var u)) item.AddedByUser = u;
164 + }
165 +
166 + public async Task PopulateAsync(IEnumerable<TripWishlistItem> items)
167 + {
168 + foreach (var i in items) await PopulateAsync(i);
169 + }
170 +
171 + public async Task PopulateAsync(TripInvitation? inv)
172 + {
173 + if (inv == null) return;
174 + var users = await UsersByIdAsync(new[] { inv.InvitedByUserId });
175 + if (users.TryGetValue(inv.InvitedByUserId, out var u)) inv.InvitedByUser = u;
176 + }
177 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminDashboardData.cs +66 −0
@@ -0,0 +1,66 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public class AdminDashboardData
6 +{
7 + public int TripCount { get; set; }
8 + public int UserCount { get; set; }
9 + public int ExpenseCount { get; set; }
10 + public int CategoryCount { get; set; }
11 + public int SettlementCount { get; set; }
12 + public int WishlistCount { get; set; }
13 + public int PollCount { get; set; }
14 + public int InvitationCount { get; set; }
15 + public int CurrencyCount { get; set; }
16 + public int ParticipantCount { get; set; }
17 +
18 + public int ActiveTrips { get; set; }
19 + public int SettledTrips { get; set; }
20 + public int ArchivedTrips { get; set; }
21 + public decimal TotalExpenseAmount { get; set; }
22 + public int PendingSettlements { get; set; }
23 + public int InProgressSettlements { get; set; }
24 + public int CompletedSettlements { get; set; }
25 + public int PendingInvitations { get; set; }
26 + public int PendingPayments { get; set; }
27 + public int MarkedPaidPayments { get; set; }
28 +
29 + public List<TripBllDto> RecentTrips { get; set; } = new();
30 + public List<ExpenseBllDto> RecentExpenses { get; set; } = new();
31 + public List<AppUserBllDto> RecentUsers { get; set; } = new();
32 +
33 + public List<AdminTopActiveTripItem> TopActiveTrips { get; set; } = new();
34 + public List<ExpenseBllDto> BiggestExpenses { get; set; } = new();
35 + public int NewUsersLast7Days { get; set; }
36 + public int NewUsersLast30Days { get; set; }
37 + public List<AdminTopActiveUserItem> TopActiveUsers { get; set; } = new();
38 + public List<AdminActivityFeedItem> ActivityFeed { get; set; } = new();
39 +}
40 +
41 +public class AdminTopActiveTripItem
42 +{
43 + public TripBllDto Trip { get; set; } = default!;
44 + public int ParticipantCount { get; set; }
45 + public decimal ExpenseSum { get; set; }
46 + public int ExpenseCount { get; set; }
47 +}
48 +
49 +public class AdminTopActiveUserItem
50 +{
51 + public Guid UserId { get; set; }
52 + public string Email { get; set; } = "";
53 + public string FullName { get; set; } = "";
54 + public int ExpenseCount { get; set; }
55 + public decimal TotalAmount { get; set; }
56 +}
57 +
58 +public class AdminActivityFeedItem
59 +{
60 + public string Type { get; set; } = "";
61 + public string MessageKey { get; set; } = "";
62 + public object[] MessageArgs { get; set; } = Array.Empty<object>();
63 + public DateTime Date { get; set; }
64 + public string IconCssClass { get; set; } = "bi-circle";
65 + public string BadgeCssClass { get; set; } = "bg-secondary";
66 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminStatsService.cs +202 −0
@@ -0,0 +1,202 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Expenses.Domain.Enums;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Shared.Contracts.Users;
5 +using SplitApp.WebApp.Application.Contracts;
6 +using SplitApp.WebApp.Application.DTO;
7 +using SplitApp.WebApp.Application.Mappers;
8 +using SplitApp.WebApp.Application.UsersService;
9 +
10 +namespace SplitApp.WebApp.Application.Services.Admin;
11 +
12 +public class AdminStatsService : IAdminStatsService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 + private readonly IUsersServiceClient _usersService;
16 +
17 + public AdminStatsService(IAppUnitOfWork uow, IUsersServiceClient usersService)
18 + {
19 + _uow = uow;
20 + _usersService = usersService;
21 + }
22 +
23 + public async Task<AdminDashboardData> GetDashboardStatsAsync()
24 + {
25 + var trips = (await _uow.Trips.GetAllAsync()).ToList();
26 + var expenses = (await _uow.Expenses.GetAllAsync()).ToList();
27 + var budgetCategories = (await _uow.BudgetCategories.GetAllAsync()).ToList();
28 + var settlementPlans = (await _uow.SettlementPlans.GetAllAsync()).ToList();
29 + var wishlistItems = (await _uow.TripWishlistItems.GetAllAsync()).ToList();
30 + var polls = (await _uow.TripPolls.GetAllAsync()).ToList();
31 + var invitations = (await _uow.TripInvitations.GetAllAsync()).ToList();
32 + var currencies = (await _uow.GetRepository<Currency>().GetAllAsync()).ToList();
33 + var participants = (await _uow.TripParticipants.GetAllAsync()).ToList();
34 + var settlementPayments = (await _uow.SettlementPayments.GetAllAsync()).ToList();
35 + var allUsers = (await _usersService.ListUsersAsync()).ToList();
36 +
37 + var tripCount = trips.Count;
38 + var userCount = allUsers.Count;
39 + var expenseCount = expenses.Count;
40 +
41 + var activeTrips = trips.Count(t => t.Status == ETripStatus.Active);
42 + var settledTrips = trips.Count(t => t.Status == ETripStatus.Settled);
43 + var archivedTrips = trips.Count(t => t.Status == ETripStatus.Archived);
44 +
45 + var totalExpenseAmount = expenses.Sum(e => e.Amount);
46 +
47 + var pendingSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.Pending);
48 + var inProgressSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.InProgress);
49 + var completedSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.Completed);
50 +
51 + var recentTrips = TripBllDtoFactory.CreateList(trips.OrderByDescending(t => t.CreatedAt).Take(5));
52 + var recentExpenses = ExpenseBllDtoFactory.CreateList(expenses.OrderByDescending(e => e.ExpenseDate).Take(8));
53 + var recentUsers = allUsers.OrderByDescending(u => u.Email).Take(5).Select(u => new AppUserBllDto
54 + {
55 + Id = u.Id,
56 + FirstName = u.FirstName,
57 + LastName = u.LastName,
58 + Email = u.Email,
59 + }).ToList();
60 +
61 + var pendingInvitations = invitations.Count(i => i.Status == EInvitationStatus.Pending);
62 + var pendingPayments = settlementPayments.Count(p => p.Status == EPaymentStatus.Pending);
63 + var markedPaidPayments = settlementPayments.Count(p => p.Status == EPaymentStatus.MarkedPaid);
64 +
65 + var topActiveTrips = trips
66 + .Where(t => t.Status == ETripStatus.Active)
67 + .Select(t =>
68 + {
69 + var tripExpenses = expenses.Where(e => e.TripId == t.Id).ToList();
70 + return new AdminTopActiveTripItem
71 + {
72 + Trip = TripBllDtoFactory.Create(t),
73 + ParticipantCount = participants.Count(p => p.TripId == t.Id),
74 + ExpenseSum = tripExpenses.Sum(e => e.Amount),
75 + ExpenseCount = tripExpenses.Count,
76 + };
77 + })
78 + .OrderByDescending(x => x.ExpenseSum)
79 + .ThenByDescending(x => x.ParticipantCount)
80 + .Take(5)
81 + .ToList();
82 +
83 + var biggestExpensesEntities = expenses
84 + .OrderByDescending(e => e.Amount)
85 + .Take(10)
86 + .ToList();
87 + var usersById = allUsers.ToDictionary(u => u.Id);
88 + foreach (var e in biggestExpensesEntities)
89 + {
90 + e.Trip ??= trips.FirstOrDefault(t => t.Id == e.TripId);
91 + if (e.PaidByUser is null && usersById.TryGetValue(e.PaidByUserId, out var u))
92 + {
93 + e.PaidByUser = new UserDto(u.Id, $"{u.FirstName} {u.LastName}".Trim(), u.Email);
94 + }
95 + }
96 + var biggestExpenses = ExpenseBllDtoFactory.CreateList(biggestExpensesEntities);
97 +
98 + var now = DateTime.UtcNow;
99 + var cutoff7 = now.AddDays(-7);
100 + var cutoff30 = now.AddDays(-30);
101 +
102 + var activeUserIds7 = expenses.Where(e => e.CreatedAt >= cutoff7)
103 + .Select(e => e.PaidByUserId).Distinct().Count();
104 + var activeUserIds30 = expenses.Where(e => e.CreatedAt >= cutoff30)
105 + .Select(e => e.PaidByUserId).Distinct().Count();
106 +
107 + var topActiveUsers = expenses
108 + .GroupBy(e => e.PaidByUserId)
109 + .Select(g =>
110 + {
111 + usersById.TryGetValue(g.Key, out var user);
112 + return new AdminTopActiveUserItem
113 + {
114 + UserId = g.Key,
115 + Email = user?.Email ?? "—",
116 + FullName = user != null ? $"{user.FirstName} {user.LastName}".Trim() : "—",
117 + ExpenseCount = g.Count(),
118 + TotalAmount = g.Sum(e => e.Amount),
119 + };
120 + })
121 + .OrderByDescending(x => x.ExpenseCount)
122 + .Take(5)
123 + .ToList();
124 +
125 + var feed = new List<AdminActivityFeedItem>();
126 + foreach (var t in trips)
127 + {
128 + feed.Add(new AdminActivityFeedItem
129 + {
130 + Type = "trip",
131 + Date = t.CreatedAt,
132 + MessageKey = "Trip \"{0}\" created",
133 + MessageArgs = new object[] { t.Name },
134 + IconCssClass = "bi-suitcase-lg",
135 + BadgeCssClass = "bg-primary",
136 + });
137 + }
138 + foreach (var e in expenses.OrderByDescending(x => x.CreatedAt).Take(30))
139 + {
140 + var tripName = trips.FirstOrDefault(t => t.Id == e.TripId)?.Name ?? "?";
141 + feed.Add(new AdminActivityFeedItem
142 + {
143 + Type = "expense",
144 + Date = e.CreatedAt,
145 + MessageKey = "Expense {0:0.00} added to \"{1}\"",
146 + MessageArgs = new object[] { e.Amount, tripName },
147 + IconCssClass = "bi-cash-coin",
148 + BadgeCssClass = "bg-success",
149 + });
150 + }
151 + foreach (var s in settlementPlans)
152 + {
153 + var tripName = trips.FirstOrDefault(t => t.Id == s.TripId)?.Name ?? "?";
154 + feed.Add(new AdminActivityFeedItem
155 + {
156 + Type = "settlement",
157 + Date = s.CreatedAt,
158 + MessageKey = "Settlement plan for \"{0}\" ({1})",
159 + MessageArgs = new object[] { tripName, s.Status },
160 + IconCssClass = "bi-diagram-3",
161 + BadgeCssClass = "bg-info",
162 + });
163 + }
164 + var activityFeed = feed.OrderByDescending(f => f.Date).Take(15).ToList();
165 +
166 + return new AdminDashboardData
167 + {
168 + TripCount = tripCount,
169 + UserCount = userCount,
170 + ExpenseCount = expenseCount,
171 + CategoryCount = budgetCategories.Count,
172 + SettlementCount = settlementPlans.Count,
173 + WishlistCount = wishlistItems.Count,
174 + PollCount = polls.Count,
175 + InvitationCount = invitations.Count,
176 + CurrencyCount = currencies.Count,
177 + ParticipantCount = participants.Count,
178 +
179 + ActiveTrips = activeTrips,
180 + SettledTrips = settledTrips,
181 + ArchivedTrips = archivedTrips,
182 + TotalExpenseAmount = totalExpenseAmount,
183 + PendingSettlements = pendingSettlements,
184 + InProgressSettlements = inProgressSettlements,
185 + CompletedSettlements = completedSettlements,
186 + PendingInvitations = pendingInvitations,
187 + PendingPayments = pendingPayments,
188 + MarkedPaidPayments = markedPaidPayments,
189 +
190 + RecentTrips = recentTrips,
191 + RecentExpenses = recentExpenses,
192 + RecentUsers = recentUsers,
193 +
194 + TopActiveTrips = topActiveTrips,
195 + BiggestExpenses = biggestExpenses,
196 + NewUsersLast7Days = activeUserIds7,
197 + NewUsersLast30Days = activeUserIds30,
198 + TopActiveUsers = topActiveUsers,
199 + ActivityFeed = activityFeed,
200 + };
201 + }
202 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/BudgetCategoryAdminService.cs +80 −0
@@ -0,0 +1,80 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.WebApp.Application.Contracts;
8 +using SplitApp.Shared.Kernel.Domain;
9 +using SplitApp.Shared.Kernel.Localization;
10 +
11 +namespace SplitApp.WebApp.Application.Services.Admin;
12 +
13 +public class BudgetCategoryAdminService : IBudgetCategoryAdminService
14 +{
15 + private readonly IAppUnitOfWork _uow;
16 +
17 + public BudgetCategoryAdminService(IAppUnitOfWork uow)
18 + {
19 + _uow = uow;
20 + }
21 +
22 + public async Task<List<BudgetCategoryBllDto>> GetAllAsync(Guid? tripId, string? search)
23 + {
24 + var items = (await _uow.BudgetCategories.GetAllAsync()).ToList();
25 + if (tripId.HasValue)
26 + items = items.Where(b => b.TripId == tripId.Value).ToList();
27 + if (!string.IsNullOrEmpty(search))
28 + items = items.Where(b => b.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
29 + return BudgetCategoryBllDtoFactory.CreateList(items.OrderBy(b => b.DisplayOrder));
30 + }
31 +
32 + public async Task<List<TripBllDto>> GetAllTripsAsync()
33 + {
34 + var trips = await _uow.Trips.GetAllAsync();
35 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
36 + }
37 +
38 + public async Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id)
39 + {
40 + var category = await _uow.BudgetCategories.GetByIdAsync(id);
41 + return category == null ? null : BudgetCategoryBllDtoFactory.Create(category);
42 + }
43 +
44 + public async Task CreateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt)
45 + {
46 + var domainEntity = BudgetCategoryBllDtoFactory.ToEntity(entity);
47 + ApplyLangStr(domainEntity, nameEn, nameEt);
48 + domainEntity.Id = Guid.NewGuid();
49 + _uow.BudgetCategories.Add(domainEntity);
50 + await _uow.SaveChangesAsync();
51 + }
52 +
53 + public async Task UpdateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt)
54 + {
55 + var existing = await _uow.BudgetCategories.GetByIdAsync(entity.Id);
56 + if (existing == null) return;
57 + existing.TripId = entity.TripId;
58 + existing.IconName = entity.IconName;
59 + existing.PlannedAmount = entity.PlannedAmount;
60 + existing.DisplayOrder = entity.DisplayOrder;
61 + ApplyLangStr(existing, nameEn, nameEt);
62 + _uow.BudgetCategories.Update(existing);
63 + await _uow.SaveChangesAsync();
64 + }
65 +
66 + public async Task DeleteAsync(Guid id)
67 + {
68 + await _uow.BudgetCategories.RemoveAsync(id);
69 + await _uow.SaveChangesAsync();
70 + }
71 +
72 + public Task<bool> ExistsAsync(Guid id) => _uow.BudgetCategories.ExistsAsync(id);
73 +
74 + private static void ApplyLangStr(BudgetCategory entity, string? nameEn, string? nameEt)
75 + {
76 + var name = new LangStr(nameEn ?? "", "en");
77 + name.SetTranslation(nameEt ?? nameEn ?? "", "et");
78 + entity.Name = name;
79 + }
80 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/CurrencyAdminService.cs +72 −0
@@ -0,0 +1,72 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.WebApp.Application.Contracts;
8 +using SplitApp.Shared.Kernel.Domain;
9 +using SplitApp.Shared.Kernel.Localization;
10 +
11 +namespace SplitApp.WebApp.Application.Services.Admin;
12 +
13 +public class CurrencyAdminService : ICurrencyAdminService
14 +{
15 + private readonly IAppUnitOfWork _uow;
16 +
17 + public CurrencyAdminService(IAppUnitOfWork uow)
18 + {
19 + _uow = uow;
20 + }
21 +
22 + public async Task<List<CurrencyBllDto>> GetAllAsync(string? search)
23 + {
24 + var items = (await _uow.GetRepository<Currency>().GetAllAsync()).OrderBy(c => c.Code).ToList();
25 + if (!string.IsNullOrEmpty(search))
26 + items = items.Where(c =>
27 + c.Code.Contains(search, StringComparison.OrdinalIgnoreCase) ||
28 + c.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
29 + return CurrencyBllDtoFactory.CreateList(items);
30 + }
31 +
32 + public async Task<CurrencyBllDto?> GetByIdAsync(Guid id)
33 + {
34 + var entity = await _uow.GetRepository<Currency>().GetByIdAsync(id);
35 + return entity == null ? null : CurrencyBllDtoFactory.Create(entity);
36 + }
37 +
38 + public async Task CreateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt)
39 + {
40 + var domainEntity = CurrencyBllDtoFactory.ToEntity(entity);
41 + ApplyLangStr(domainEntity, nameEn, nameEt);
42 + domainEntity.Id = Guid.NewGuid();
43 + _uow.GetRepository<Currency>().Add(domainEntity);
44 + await _uow.SaveChangesAsync();
45 + }
46 +
47 + public async Task UpdateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt)
48 + {
49 + var existing = await _uow.GetRepository<Currency>().GetByIdAsync(entity.Id);
50 + if (existing == null) return;
51 + existing.Code = entity.Code;
52 + existing.Symbol = entity.Symbol;
53 + ApplyLangStr(existing, nameEn, nameEt);
54 + _uow.GetRepository<Currency>().Update(existing);
55 + await _uow.SaveChangesAsync();
56 + }
57 +
58 + public async Task DeleteAsync(Guid id)
59 + {
60 + await _uow.GetRepository<Currency>().RemoveAsync(id);
61 + await _uow.SaveChangesAsync();
62 + }
63 +
64 + public Task<bool> ExistsAsync(Guid id) => _uow.GetRepository<Currency>().ExistsAsync(id);
65 +
66 + private static void ApplyLangStr(Currency entity, string? nameEn, string? nameEt)
67 + {
68 + var name = new LangStr(nameEn ?? "", "en");
69 + name.SetTranslation(nameEt ?? nameEn ?? "", "et");
70 + entity.Name = name;
71 + }
72 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ExpenseAdminService.cs +97 −0
@@ -0,0 +1,97 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.WebApp.Application.Contracts;
3 +using SplitApp.WebApp.Application.DTO;
4 +using SplitApp.WebApp.Application.Mappers;
5 +using SplitApp.WebApp.Application.UsersService;
6 +
7 +namespace SplitApp.WebApp.Application.Services.Admin;
8 +
9 +public class ExpenseAdminService : IExpenseAdminService
10 +{
11 + private readonly IAppUnitOfWork _uow;
12 + private readonly IUsersServiceClient _usersService;
13 +
14 + public ExpenseAdminService(IAppUnitOfWork uow, IUsersServiceClient usersService)
15 + {
16 + _uow = uow;
17 + _usersService = usersService;
18 + }
19 +
20 + public async Task<List<ExpenseBllDto>> GetAllAsync(Guid? tripId, string? search)
21 + {
22 + var items = (await _uow.Expenses.GetAllAsync()).ToList();
23 + if (tripId.HasValue)
24 + items = items.Where(e => e.TripId == tripId.Value).ToList();
25 + if (!string.IsNullOrEmpty(search))
26 + items = items.Where(e => e.Description != null && e.Description.Contains(search)).ToList();
27 + return ExpenseBllDtoFactory.CreateList(items.OrderByDescending(e => e.ExpenseDate));
28 + }
29 +
30 + public async Task<ExpenseBllDto?> GetByIdAsync(Guid id)
31 + {
32 + var entity = await _uow.Expenses.GetByIdAsync(id);
33 + return entity == null ? null : ExpenseBllDtoFactory.Create(entity);
34 + }
35 +
36 + public async Task CreateAsync(ExpenseBllDto entity)
37 + {
38 + var domainEntity = ExpenseBllDtoFactory.ToEntity(entity);
39 + domainEntity.Id = Guid.NewGuid();
40 + _uow.Expenses.Add(domainEntity);
41 + await _uow.SaveChangesAsync();
42 + }
43 +
44 + public async Task UpdateAsync(ExpenseBllDto entity)
45 + {
46 + var existing = await _uow.Expenses.GetByIdAsync(entity.Id);
47 + if (existing == null) return;
48 + existing.TripId = entity.TripId;
49 + existing.PaidByUserId = entity.PaidByUserId;
50 + existing.BudgetCategoryId = entity.BudgetCategoryId;
51 + existing.CurrencyId = entity.CurrencyId;
52 + existing.Amount = entity.Amount;
53 + existing.Description = entity.Description;
54 + existing.ExpenseDate = entity.ExpenseDate;
55 + existing.SplitMethod = entity.SplitMethod;
56 + _uow.Expenses.Update(existing);
57 + await _uow.SaveChangesAsync();
58 + }
59 +
60 + public async Task DeleteAsync(Guid id)
61 + {
62 + await _uow.Expenses.RemoveAsync(id);
63 + await _uow.SaveChangesAsync();
64 + }
65 +
66 + public Task<bool> ExistsAsync(Guid id) => _uow.Expenses.ExistsAsync(id);
67 +
68 + public async Task<List<TripBllDto>> GetTripsAsync()
69 + {
70 + var trips = await _uow.Trips.GetAllAsync();
71 + return TripBllDtoFactory.CreateList(trips);
72 + }
73 +
74 + public async Task<List<AppUserBllDto>> GetUsersAsync()
75 + {
76 + var users = await _usersService.ListUsersAsync();
77 + return users.Select(u => new AppUserBllDto
78 + {
79 + Id = u.Id,
80 + FirstName = u.FirstName,
81 + LastName = u.LastName,
82 + Email = u.Email,
83 + }).ToList();
84 + }
85 +
86 + public async Task<List<CurrencyBllDto>> GetCurrenciesAsync()
87 + {
88 + var currencies = await _uow.GetRepository<Currency>().GetAllAsync();
89 + return CurrencyBllDtoFactory.CreateList(currencies);
90 + }
91 +
92 + public async Task<List<BudgetCategoryBllDto>> GetBudgetCategoriesAsync()
93 + {
94 + var categories = await _uow.BudgetCategories.GetAllAsync();
95 + return BudgetCategoryBllDtoFactory.CreateList(categories);
96 + }
97 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IAdminStatsService.cs +6 −0
@@ -0,0 +1,6 @@
1 +namespace SplitApp.WebApp.Application.Services.Admin;
2 +
3 +public interface IAdminStatsService
4 +{
5 + Task<AdminDashboardData> GetDashboardStatsAsync();
6 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IBudgetCategoryAdminService.cs +14 −0
@@ -0,0 +1,14 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IBudgetCategoryAdminService
6 +{
7 + Task<List<BudgetCategoryBllDto>> GetAllAsync(Guid? tripId, string? search);
8 + Task<List<TripBllDto>> GetAllTripsAsync();
9 + Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id);
10 + Task CreateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt);
11 + Task UpdateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt);
12 + Task DeleteAsync(Guid id);
13 + Task<bool> ExistsAsync(Guid id);
14 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ICurrencyAdminService.cs +13 −0
@@ -0,0 +1,13 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ICurrencyAdminService
6 +{
7 + Task<List<CurrencyBllDto>> GetAllAsync(string? search);
8 + Task<CurrencyBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt);
10 + Task UpdateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IExpenseAdminService.cs +18 −0
@@ -0,0 +1,18 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IExpenseAdminService
6 +{
7 + Task<List<ExpenseBllDto>> GetAllAsync(Guid? tripId, string? search);
8 + Task<ExpenseBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(ExpenseBllDto entity);
10 + Task UpdateAsync(ExpenseBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 + Task<List<CurrencyBllDto>> GetCurrenciesAsync();
17 + Task<List<BudgetCategoryBllDto>> GetBudgetCategoriesAsync();
18 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IInvitationAdminService.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IInvitationAdminService
6 +{
7 + Task<List<TripInvitationBllDto>> GetAllAsync(string? search);
8 + Task<TripInvitationBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(TripInvitationBllDto entity);
10 + Task UpdateAsync(TripInvitationBllDto entity);
11 + Task DeleteAsync(Guid id);
12 +
13 + Task<List<TripBllDto>> GetTripsAsync();
14 + Task<List<AppUserBllDto>> GetUsersAsync();
15 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IPollAdminService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IPollAdminService
6 +{
7 + Task<List<TripPollBllDto>> GetAllAsync(string? search);
8 + Task<TripPollBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(TripPollBllDto entity);
10 + Task UpdateAsync(TripPollBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPaymentAdminService.cs +15 −0
@@ -0,0 +1,15 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ISettlementPaymentAdminService
6 +{
7 + Task<List<SettlementPaymentBllDto>> GetAllAsync(string? search);
8 + Task<SettlementPaymentBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(SettlementPaymentBllDto entity);
10 + Task UpdateAsync(SettlementPaymentBllDto entity);
11 + Task DeleteAsync(Guid id);
12 +
13 + Task<List<SettlementPlanBllDto>> GetSettlementPlansAsync();
14 + Task<List<AppUserBllDto>> GetUsersAsync();
15 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPlanAdminService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ISettlementPlanAdminService
6 +{
7 + Task<List<SettlementPlanBllDto>> GetAllAsync(Guid? tripId);
8 + Task<SettlementPlanBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(SettlementPlanBllDto entity);
10 + Task UpdateAsync(SettlementPlanBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISplitPresetAdminService.cs +14 −0
@@ -0,0 +1,14 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ISplitPresetAdminService
6 +{
7 + Task<List<SplitPresetBllDto>> GetAllAsync(string? search);
8 + Task<SplitPresetBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(SplitPresetBllDto entity);
10 + Task DeleteAsync(Guid id);
11 +
12 + Task<List<TripBllDto>> GetTripsAsync();
13 + Task<List<AppUserBllDto>> GetUsersAsync();
14 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripAdminService.cs +14 −0
@@ -0,0 +1,14 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ITripAdminService
6 +{
7 + Task<List<TripBllDto>> GetAllAsync(string? search);
8 + Task<List<CurrencyBllDto>> GetAllCurrenciesAsync();
9 + Task<TripBllDto?> GetByIdAsync(Guid id);
10 + Task CreateAsync(TripBllDto entity);
11 + Task UpdateAsync(TripBllDto entity);
12 + Task DeleteAsync(Guid id);
13 + Task<bool> ExistsAsync(Guid id);
14 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripParticipantAdminService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface ITripParticipantAdminService
6 +{
7 + Task<List<TripParticipantBllDto>> GetAllAsync(Guid? tripId, string? search);
8 + Task<TripParticipantBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(TripParticipantBllDto entity);
10 + Task UpdateAsync(TripParticipantBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IWishlistAdminService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services.Admin;
4 +
5 +public interface IWishlistAdminService
6 +{
7 + Task<List<TripWishlistItemBllDto>> GetAllAsync(string? search);
8 + Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id);
9 + Task CreateAsync(TripWishlistItemBllDto entity);
10 + Task UpdateAsync(TripWishlistItemBllDto entity);
11 + Task DeleteAsync(Guid id);
12 + Task<bool> ExistsAsync(Guid id);
13 +
14 + Task<List<TripBllDto>> GetTripsAsync();
15 + Task<List<AppUserBllDto>> GetUsersAsync();
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/InvitationAdminService.cs +80 −0
@@ -0,0 +1,80 @@
1 +using SplitApp.WebApp.Application.Contracts;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.WebApp.Application.UsersService;
5 +
6 +namespace SplitApp.WebApp.Application.Services.Admin;
7 +
8 +public class InvitationAdminService : IInvitationAdminService
9 +{
10 + private readonly IAppUnitOfWork _uow;
11 + private readonly IUsersServiceClient _usersService;
12 +
13 + public InvitationAdminService(IAppUnitOfWork uow, IUsersServiceClient usersService)
14 + {
15 + _uow = uow;
16 + _usersService = usersService;
17 + }
18 +
19 + public async Task<List<TripInvitationBllDto>> GetAllAsync(string? search)
20 + {
21 + var invitations = (await _uow.TripInvitations.GetAllAsync()).ToList();
22 + if (!string.IsNullOrEmpty(search))
23 + invitations = invitations.Where(i => i.Token.Contains(search)).ToList();
24 + return InvitationBllDtoFactory.CreateList(invitations.OrderByDescending(i => i.Id));
25 + }
26 +
27 + public async Task<TripInvitationBllDto?> GetByIdAsync(Guid id)
28 + {
29 + var entity = await _uow.TripInvitations.GetByIdAsync(id);
30 + return entity == null ? null : InvitationBllDtoFactory.Create(entity);
31 + }
32 +
33 + public async Task CreateAsync(TripInvitationBllDto entity)
34 + {
35 + var domainEntity = InvitationBllDtoFactory.ToEntity(entity);
36 + domainEntity.Id = Guid.NewGuid();
37 + if (string.IsNullOrEmpty(domainEntity.Token))
38 + domainEntity.Token = Guid.NewGuid().ToString("N");
39 + _uow.TripInvitations.Add(domainEntity);
40 + await _uow.SaveChangesAsync();
41 + }
42 +
43 + public async Task UpdateAsync(TripInvitationBllDto entity)
44 + {
45 + var existing = await _uow.TripInvitations.GetByIdAsync(entity.Id);
46 + if (existing == null) return;
47 + existing.TripId = entity.TripId;
48 + existing.InvitedByUserId = entity.InvitedByUserId;
49 + existing.Token = entity.Token;
50 + existing.Status = entity.Status;
51 + existing.ExpiresAt = entity.ExpiresAt;
52 + existing.RespondedAt = entity.RespondedAt;
53 + _uow.TripInvitations.Update(existing);
54 + await _uow.SaveChangesAsync();
55 + }
56 +
57 + public async Task DeleteAsync(Guid id)
58 + {
59 + await _uow.TripInvitations.RemoveAsync(id);
60 + await _uow.SaveChangesAsync();
61 + }
62 +
63 + public async Task<List<TripBllDto>> GetTripsAsync()
64 + {
65 + var trips = await _uow.Trips.GetAllAsync();
66 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
67 + }
68 +
69 + public async Task<List<AppUserBllDto>> GetUsersAsync()
70 + {
71 + var users = await _usersService.ListUsersAsync();
72 + return users.Select(u => new AppUserBllDto
73 + {
74 + Id = u.Id,
75 + FirstName = u.FirstName,
76 + LastName = u.LastName,
77 + Email = u.Email,
78 + }).ToList();
79 + }
80 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/PollAdminService.cs +80 −0
@@ -0,0 +1,80 @@
1 +using SplitApp.WebApp.Application.Contracts;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.WebApp.Application.UsersService;
5 +
6 +namespace SplitApp.WebApp.Application.Services.Admin;
7 +
8 +public class PollAdminService : IPollAdminService
9 +{
10 + private readonly IAppUnitOfWork _uow;
11 + private readonly IUsersServiceClient _usersService;
12 +
13 + public PollAdminService(IAppUnitOfWork uow, IUsersServiceClient usersService)
14 + {
15 + _uow = uow;
16 + _usersService = usersService;
17 + }
18 +
19 + public async Task<List<TripPollBllDto>> GetAllAsync(string? search)
20 + {
21 + var items = (await _uow.TripPolls.GetAllAsync()).ToList();
22 + if (!string.IsNullOrEmpty(search))
23 + items = items.Where(p => p.Question.Contains(search)).ToList();
24 + return PollBllDtoFactory.CreateList(items.OrderByDescending(p => p.Id), includeOptions: true);
25 + }
26 +
27 + public async Task<TripPollBllDto?> GetByIdAsync(Guid id)
28 + {
29 + var entity = await _uow.TripPolls.GetByIdAsync(id);
30 + return entity == null ? null : PollBllDtoFactory.Create(entity);
31 + }
32 +
33 + public async Task CreateAsync(TripPollBllDto entity)
34 + {
35 + var domainEntity = PollBllDtoFactory.ToEntity(entity);
36 + domainEntity.Id = Guid.NewGuid();
37 + _uow.TripPolls.Add(domainEntity);
38 + await _uow.SaveChangesAsync();
39 + }
40 +
41 + public async Task UpdateAsync(TripPollBllDto entity)
42 + {
43 + var existing = await _uow.TripPolls.GetByIdAsync(entity.Id);
44 + if (existing == null) return;
45 + existing.TripId = entity.TripId;
46 + existing.CreatedByUserId = entity.CreatedByUserId;
47 + existing.Question = entity.Question;
48 + existing.AllowMultipleVotes = entity.AllowMultipleVotes;
49 + existing.IsAnonymous = entity.IsAnonymous;
50 + existing.ClosedAt = entity.ClosedAt;
51 + _uow.TripPolls.Update(existing);
52 + await _uow.SaveChangesAsync();
53 + }
54 +
55 + public async Task DeleteAsync(Guid id)
56 + {
57 + await _uow.TripPolls.RemoveAsync(id);
58 + await _uow.SaveChangesAsync();
59 + }
60 +
61 + public Task<bool> ExistsAsync(Guid id) => _uow.TripPolls.ExistsAsync(id);
62 +
63 + public async Task<List<TripBllDto>> GetTripsAsync()
64 + {
65 + var trips = await _uow.Trips.GetAllAsync();
66 + return TripBllDtoFactory.CreateList(trips);
67 + }
68 +
69 + public async Task<List<AppUserBllDto>> GetUsersAsync()
70 + {
71 + var users = await _usersService.ListUsersAsync();
72 + return users.Select(u => new AppUserBllDto
73 + {
74 + Id = u.Id,
75 + FirstName = u.FirstName,
76 + LastName = u.LastName,
77 + Email = u.Email,
78 + }).ToList();
79 + }
80 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPaymentAdminService.cs +81 −0
@@ -0,0 +1,81 @@
1 +using SplitApp.WebApp.Application.Contracts;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.WebApp.Application.UsersService;
5 +
6 +namespace SplitApp.WebApp.Application.Services.Admin;
7 +
8 +public class SettlementPaymentAdminService : ISettlementPaymentAdminService
9 +{
10 + private readonly IAppUnitOfWork _uow;
11 + private readonly IUsersServiceClient _usersService;
12 +
13 + public SettlementPaymentAdminService(IAppUnitOfWork uow, IUsersServiceClient usersService)
14 + {
15 + _uow = uow;
16 + _usersService = usersService;
17 + }
18 +
19 + public async Task<List<SettlementPaymentBllDto>> GetAllAsync(string? search)
20 + {
21 + var payments = (await _uow.SettlementPayments.GetAllAsync()).ToList();
22 + if (!string.IsNullOrEmpty(search))
23 + payments = payments.Where(s =>
24 + (s.FromUser?.Email != null && s.FromUser.Email.Contains(search)) ||
25 + (s.ToUser?.Email != null && s.ToUser.Email.Contains(search))).ToList();
26 + return SettlementPaymentBllDtoFactory.CreateList(payments.OrderByDescending(s => s.Id));
27 + }
28 +
29 + public async Task<SettlementPaymentBllDto?> GetByIdAsync(Guid id)
30 + {
31 + var entity = await _uow.SettlementPayments.GetByIdAsync(id);
32 + return entity == null ? null : SettlementPaymentBllDtoFactory.Create(entity);
33 + }
34 +
35 + public async Task CreateAsync(SettlementPaymentBllDto entity)
36 + {
37 + var domainEntity = SettlementPaymentBllDtoFactory.ToEntity(entity);
38 + domainEntity.Id = Guid.NewGuid();
39 + _uow.SettlementPayments.Add(domainEntity);
40 + await _uow.SaveChangesAsync();
41 + }
42 +
43 + public async Task UpdateAsync(SettlementPaymentBllDto entity)
44 + {
45 + var existing = await _uow.SettlementPayments.GetByIdAsync(entity.Id);
46 + if (existing == null) return;
47 + existing.SettlementPlanId = entity.SettlementPlanId;
48 + existing.FromUserId = entity.FromUserId;
49 + existing.ToUserId = entity.ToUserId;
50 + existing.Amount = entity.Amount;
51 + existing.Status = entity.Status;
52 + existing.MarkedPaidAt = entity.MarkedPaidAt;
53 + existing.ConfirmedAt = entity.ConfirmedAt;
54 + _uow.SettlementPayments.Update(existing);
55 + await _uow.SaveChangesAsync();
56 + }
57 +
58 + public async Task DeleteAsync(Guid id)
59 + {
60 + await _uow.SettlementPayments.RemoveAsync(id);
61 + await _uow.SaveChangesAsync();
62 + }
63 +
64 + public async Task<List<SettlementPlanBllDto>> GetSettlementPlansAsync()
65 + {
66 + var plans = await _uow.SettlementPlans.GetAllAsync();
67 + return SettlementBllDtoFactory.CreateList(plans);
68 + }
69 +
70 + public async Task<List<AppUserBllDto>> GetUsersAsync()
71 + {
72 + var users = await _usersService.ListUsersAsync();
73 + return users.Select(u => new AppUserBllDto
74 + {
75 + Id = u.Id,
76 + FirstName = u.FirstName,
77 + LastName = u.LastName,
78 + Email = u.Email,
79 + }).ToList();
80 + }
81 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPlanAdminService.cs +79 −0
@@ -0,0 +1,79 @@
1 +using SplitApp.WebApp.Application.Contracts;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.WebApp.Application.UsersService;
5 +
6 +namespace SplitApp.WebApp.Application.Services.Admin;
7 +
8 +public class SettlementPlanAdminService : ISettlementPlanAdminService
9 +{
10 + private readonly IAppUnitOfWork _uow;
11 + private readonly IUsersServiceClient _usersService;
12 +
13 + public SettlementPlanAdminService(IAppUnitOfWork uow, IUsersServiceClient usersService)
14 + {
15 + _uow = uow;
16 + _usersService = usersService;
17 + }
18 +
19 + public async Task<List<SettlementPlanBllDto>> GetAllAsync(Guid? tripId)
20 + {
21 + var plans = (await _uow.SettlementPlans.GetAllAsync()).ToList();
22 + if (tripId.HasValue)
23 + plans = plans.Where(s => s.TripId == tripId.Value).ToList();
24 + return SettlementBllDtoFactory.CreateList(plans.OrderByDescending(s => s.Id));
25 + }
26 +
27 + public async Task<SettlementPlanBllDto?> GetByIdAsync(Guid id)
28 + {
29 + var entity = await _uow.SettlementPlans.GetByIdAsync(id);
30 + return entity == null ? null : SettlementBllDtoFactory.Create(entity);
31 + }
32 +
33 + public async Task CreateAsync(SettlementPlanBllDto entity)
34 + {
35 + var domainEntity = SettlementBllDtoFactory.ToEntity(entity);
36 + domainEntity.Id = Guid.NewGuid();
37 + _uow.SettlementPlans.Add(domainEntity);
38 + await _uow.SaveChangesAsync();
39 + }
40 +
41 + public async Task UpdateAsync(SettlementPlanBllDto entity)
42 + {
43 + var existing = await _uow.SettlementPlans.GetByIdAsync(entity.Id);
44 + if (existing == null) return;
45 + existing.TripId = entity.TripId;
46 + existing.CreatedByUserId = entity.CreatedByUserId;
47 + existing.TotalAmount = entity.TotalAmount;
48 + existing.Status = entity.Status;
49 + existing.CompletedAt = entity.CompletedAt;
50 + _uow.SettlementPlans.Update(existing);
51 + await _uow.SaveChangesAsync();
52 + }
53 +
54 + public async Task DeleteAsync(Guid id)
55 + {
56 + await _uow.SettlementPlans.RemoveAsync(id);
57 + await _uow.SaveChangesAsync();
58 + }
59 +
60 + public Task<bool> ExistsAsync(Guid id) => _uow.SettlementPlans.ExistsAsync(id);
61 +
62 + public async Task<List<TripBllDto>> GetTripsAsync()
63 + {
64 + var trips = await _uow.Trips.GetAllAsync();
65 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
66 + }
67 +
68 + public async Task<List<AppUserBllDto>> GetUsersAsync()
69 + {
70 + var users = await _usersService.ListUsersAsync();
71 + return users.Select(u => new AppUserBllDto
72 + {
73 + Id = u.Id,
74 + FirstName = u.FirstName,
75 + LastName = u.LastName,
76 + Email = u.Email,
77 + }).ToList();
78 + }
79 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SplitPresetAdminService.cs +64 −0
@@ -0,0 +1,64 @@
1 +using SplitApp.WebApp.Application.Contracts;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.WebApp.Application.UsersService;
5 +
6 +namespace SplitApp.WebApp.Application.Services.Admin;
7 +
8 +public class SplitPresetAdminService : ISplitPresetAdminService
9 +{
10 + private readonly IAppUnitOfWork _uow;
11 + private readonly IUsersServiceClient _usersService;
12 +
13 + public SplitPresetAdminService(IAppUnitOfWork uow, IUsersServiceClient usersService)
14 + {
15 + _uow = uow;
16 + _usersService = usersService;
17 + }
18 +
19 + public async Task<List<SplitPresetBllDto>> GetAllAsync(string? search)
20 + {
21 + var presets = (await _uow.SplitPresets.GetAllAsync()).ToList();
22 + if (!string.IsNullOrEmpty(search))
23 + presets = presets.Where(s => s.Name.Contains(search)).ToList();
24 + return SplitPresetBllDtoFactory.CreateList(presets.OrderByDescending(s => s.Id), includeMembers: true);
25 + }
26 +
27 + public async Task<SplitPresetBllDto?> GetByIdAsync(Guid id)
28 + {
29 + var entity = await _uow.SplitPresets.GetByIdAsync(id);
30 + return entity == null ? null : SplitPresetBllDtoFactory.Create(entity, includeMembers: true);
31 + }
32 +
33 + public async Task CreateAsync(SplitPresetBllDto entity)
34 + {
35 + var domainEntity = SplitPresetBllDtoFactory.ToEntity(entity);
36 + domainEntity.Id = Guid.NewGuid();
37 + _uow.SplitPresets.Add(domainEntity);
38 + await _uow.SaveChangesAsync();
39 + }
40 +
41 + public async Task DeleteAsync(Guid id)
42 + {
43 + await _uow.SplitPresets.RemoveAsync(id);
44 + await _uow.SaveChangesAsync();
45 + }
46 +
47 + public async Task<List<TripBllDto>> GetTripsAsync()
48 + {
49 + var trips = await _uow.Trips.GetAllAsync();
50 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
51 + }
52 +
53 + public async Task<List<AppUserBllDto>> GetUsersAsync()
54 + {
55 + var users = await _usersService.ListUsersAsync();
56 + return users.Select(u => new AppUserBllDto
57 + {
58 + Id = u.Id,
59 + FirstName = u.FirstName,
60 + LastName = u.LastName,
61 + Email = u.Email,
62 + }).ToList();
63 + }
64 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripAdminService.cs +70 −0
@@ -0,0 +1,70 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.WebApp.Application.Contracts;
8 +
9 +namespace SplitApp.WebApp.Application.Services.Admin;
10 +
11 +public class TripAdminService : ITripAdminService
12 +{
13 + private readonly IAppUnitOfWork _uow;
14 +
15 + public TripAdminService(IAppUnitOfWork uow)
16 + {
17 + _uow = uow;
18 + }
19 +
20 + public async Task<List<TripBllDto>> GetAllAsync(string? search)
21 + {
22 + var items = (await _uow.Trips.GetAllAsync()).ToList();
23 + if (!string.IsNullOrEmpty(search))
24 + items = items.Where(t => t.Name.Contains(search)).ToList();
25 + return TripBllDtoFactory.CreateList(items.OrderByDescending(t => t.CreatedAt));
26 + }
27 +
28 + public async Task<List<CurrencyBllDto>> GetAllCurrenciesAsync()
29 + {
30 + var currencies = await _uow.GetRepository<Currency>().GetAllAsync();
31 + return CurrencyBllDtoFactory.CreateList(currencies);
32 + }
33 +
34 + public async Task<TripBllDto?> GetByIdAsync(Guid id)
35 + {
36 + var trip = await _uow.Trips.GetByIdAsync(id);
37 + return trip == null ? null : TripBllDtoFactory.Create(trip);
38 + }
39 +
40 + public async Task CreateAsync(TripBllDto entity)
41 + {
42 + var domainEntity = TripBllDtoFactory.ToEntity(entity);
43 + domainEntity.Id = Guid.NewGuid();
44 + _uow.Trips.Add(domainEntity);
45 + await _uow.SaveChangesAsync();
46 + }
47 +
48 + public async Task UpdateAsync(TripBllDto entity)
49 + {
50 + var existing = await _uow.Trips.GetByIdAsync(entity.Id);
51 + if (existing == null) return;
52 + existing.Name = entity.Name;
53 + existing.Description = entity.Description;
54 + existing.Destination = entity.Destination;
55 + existing.StartDate = entity.StartDate;
56 + existing.EndDate = entity.EndDate;
57 + existing.Status = entity.Status;
58 + existing.DefaultCurrencyId = entity.DefaultCurrencyId;
59 + _uow.Trips.Update(existing);
60 + await _uow.SaveChangesAsync();
61 + }
62 +
63 + public async Task DeleteAsync(Guid id)
64 + {
65 + await _uow.Trips.RemoveAsync(id);
66 + await _uow.SaveChangesAsync();
67 + }
68 +
69 + public Task<bool> ExistsAsync(Guid id) => _uow.Trips.ExistsAsync(id);
70 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripParticipantAdminService.cs +88 −0
@@ -0,0 +1,88 @@
1 +using SplitApp.WebApp.Application.Contracts;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.WebApp.Application.UsersService;
5 +
6 +namespace SplitApp.WebApp.Application.Services.Admin;
7 +
8 +public class TripParticipantAdminService : ITripParticipantAdminService
9 +{
10 + private readonly IAppUnitOfWork _uow;
11 + private readonly IUsersServiceClient _usersService;
12 +
13 + public TripParticipantAdminService(IAppUnitOfWork uow, IUsersServiceClient usersService)
14 + {
15 + _uow = uow;
16 + _usersService = usersService;
17 + }
18 +
19 + public async Task<List<TripParticipantBllDto>> GetAllAsync(Guid? tripId, string? search)
20 + {
21 + var participants = (await _uow.TripParticipants.GetAllAsync()).ToList();
22 +
23 + if (tripId.HasValue)
24 + participants = participants.Where(tp => tp.TripId == tripId.Value).ToList();
25 +
26 + if (!string.IsNullOrEmpty(search))
27 + participants = participants.Where(tp => tp.User != null && (
28 + (tp.User.DisplayName != null && tp.User.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)) ||
29 + (tp.User.Email != null && tp.User.Email.Contains(search, StringComparison.OrdinalIgnoreCase)))).ToList();
30 +
31 + return TripParticipantBllDtoFactory.CreateList(participants.OrderByDescending(tp => tp.JoinedAt));
32 + }
33 +
34 + public async Task<TripParticipantBllDto?> GetByIdAsync(Guid id)
35 + {
36 + var entity = await _uow.TripParticipants.GetByIdAsync(id);
37 + return entity == null ? null : TripParticipantBllDtoFactory.Create(entity);
38 + }
39 +
40 + public async Task CreateAsync(TripParticipantBllDto entity)
41 + {
42 + var domainEntity = TripParticipantBllDtoFactory.ToEntity(entity);
43 + domainEntity.Id = Guid.NewGuid();
44 + _uow.TripParticipants.Add(domainEntity);
45 + await _uow.SaveChangesAsync();
46 + }
47 +
48 + public async Task UpdateAsync(TripParticipantBllDto entity)
49 + {
50 + var existing = await _uow.TripParticipants.GetByIdAsync(entity.Id);
51 + if (existing == null) return;
52 + existing.TripId = entity.TripId;
53 + existing.UserId = entity.UserId;
54 + existing.Role = entity.Role;
55 + existing.Nickname = entity.Nickname;
56 + existing.JoinedAt = entity.JoinedAt;
57 + existing.LeftAt = entity.LeftAt;
58 + existing.IsActive = entity.IsActive;
59 + _uow.TripParticipants.Update(existing);
60 + await _uow.SaveChangesAsync();
61 + }
62 +
63 + public async Task DeleteAsync(Guid id)
64 + {
65 + await _uow.TripParticipants.RemoveAsync(id);
66 + await _uow.SaveChangesAsync();
67 + }
68 +
69 + public Task<bool> ExistsAsync(Guid id) => _uow.TripParticipants.ExistsAsync(id);
70 +
71 + public async Task<List<TripBllDto>> GetTripsAsync()
72 + {
73 + var trips = await _uow.Trips.GetAllAsync();
74 + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name));
75 + }
76 +
77 + public async Task<List<AppUserBllDto>> GetUsersAsync()
78 + {
79 + var users = await _usersService.ListUsersAsync();
80 + return users.Select(u => new AppUserBllDto
81 + {
82 + Id = u.Id,
83 + FirstName = u.FirstName,
84 + LastName = u.LastName,
85 + Email = u.Email,
86 + }).ToList();
87 + }
88 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/WishlistAdminService.cs +86 −0
@@ -0,0 +1,86 @@
1 +using SplitApp.WebApp.Application.Contracts;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.WebApp.Application.UsersService;
5 +
6 +namespace SplitApp.WebApp.Application.Services.Admin;
7 +
8 +public class WishlistAdminService : IWishlistAdminService
9 +{
10 + private readonly IAppUnitOfWork _uow;
11 + private readonly IUsersServiceClient _usersService;
12 +
13 + public WishlistAdminService(IAppUnitOfWork uow, IUsersServiceClient usersService)
14 + {
15 + _uow = uow;
16 + _usersService = usersService;
17 + }
18 +
19 + public async Task<List<TripWishlistItemBllDto>> GetAllAsync(string? search)
20 + {
21 + var items = (await _uow.TripWishlistItems.GetAllAsync()).ToList();
22 + if (!string.IsNullOrEmpty(search))
23 + items = items.Where(w => w.Title.Contains(search)).ToList();
24 + return WishlistBllDtoFactory.CreateList(items.OrderByDescending(w => w.Id));
25 + }
26 +
27 + public async Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id)
28 + {
29 + var entity = await _uow.TripWishlistItems.GetByIdAsync(id);
30 + return entity == null ? null : WishlistBllDtoFactory.Create(entity);
31 + }
32 +
33 + public async Task CreateAsync(TripWishlistItemBllDto entity)
34 + {
35 + var domainEntity = WishlistBllDtoFactory.ToEntity(entity);
36 + domainEntity.Id = Guid.NewGuid();
37 + _uow.TripWishlistItems.Add(domainEntity);
38 + await _uow.SaveChangesAsync();
39 + }
40 +
41 + public async Task UpdateAsync(TripWishlistItemBllDto entity)
42 + {
43 + var existing = await _uow.TripWishlistItems.GetByIdAsync(entity.Id);
44 + if (existing == null) return;
45 + existing.TripId = entity.TripId;
46 + existing.AddedByUserId = entity.AddedByUserId;
47 + existing.Title = entity.Title;
48 + existing.Description = entity.Description;
49 + existing.Category = entity.Category;
50 + existing.Priority = entity.Priority;
51 + existing.EstimatedCost = entity.EstimatedCost;
52 + existing.Url = entity.Url;
53 + existing.Location = entity.Location;
54 + existing.IsCompleted = entity.IsCompleted;
55 + existing.CompletedAt = entity.CompletedAt;
56 + existing.DisplayOrder = entity.DisplayOrder;
57 + _uow.TripWishlistItems.Update(existing);
58 + await _uow.SaveChangesAsync();
59 + }
60 +
61 + public async Task DeleteAsync(Guid id)
62 + {
63 + await _uow.TripWishlistItems.RemoveAsync(id);
64 + await _uow.SaveChangesAsync();
65 + }
66 +
67 + public Task<bool> ExistsAsync(Guid id) => _uow.TripWishlistItems.ExistsAsync(id);
68 +
69 + public async Task<List<TripBllDto>> GetTripsAsync()
70 + {
71 + var trips = await _uow.Trips.GetAllAsync();
72 + return TripBllDtoFactory.CreateList(trips);
73 + }
74 +
75 + public async Task<List<AppUserBllDto>> GetUsersAsync()
76 + {
77 + var users = await _usersService.ListUsersAsync();
78 + return users.Select(u => new AppUserBllDto
79 + {
80 + Id = u.Id,
81 + FirstName = u.FirstName,
82 + LastName = u.LastName,
83 + Email = u.Email,
84 + }).ToList();
85 + }
86 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/BudgetCategoryService.cs +86 −0
@@ -0,0 +1,86 @@
1 +using MediatR;
2 +using SplitApp.Shared.Contracts.Expenses.Queries;
3 +using SplitApp.WebApp.Application.DTO;
4 +using SplitApp.WebApp.Application.Mappers;
5 +using SplitApp.WebApp.Application.Contracts;
6 +
7 +namespace SplitApp.WebApp.Application.Services;
8 +
9 +public class BudgetCategoryService : IBudgetCategoryService
10 +{
11 + private readonly IAppUnitOfWork _uow;
12 + private readonly IMediator _mediator;
13 +
14 + public BudgetCategoryService(IAppUnitOfWork uow, IMediator mediator)
15 + {
16 + _uow = uow;
17 + _mediator = mediator;
18 + }
19 +
20 + public async Task<List<BudgetCategoryBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
21 + {
22 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
23 + return new List<BudgetCategoryBllDto>();
24 + var categories = await _uow.BudgetCategories.GetByTripIdAsync(tripId);
25 + var spent = await _mediator.Send(new GetBudgetCategorySpentQuery(tripId));
26 + return BudgetCategoryBllDtoFactory.CreateList(categories, spent);
27 + }
28 +
29 + public async Task<List<BudgetCategoryBllDto>> GetByTripIdRawAsync(Guid tripId)
30 + {
31 + var categories = await _uow.BudgetCategories.GetByTripIdAsync(tripId);
32 + var spent = await _mediator.Send(new GetBudgetCategorySpentQuery(tripId));
33 + return BudgetCategoryBllDtoFactory.CreateList(categories, spent);
34 + }
35 +
36 + public async Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id)
37 + {
38 + var category = await _uow.BudgetCategories.GetByIdAsync(id);
39 + return category == null ? null : BudgetCategoryBllDtoFactory.Create(category);
40 + }
41 +
42 + public async Task<(BudgetCategoryBllDto? category, string? errorCode)> CreateAsync(BudgetCategoryBllDto category, Guid userId)
43 + {
44 + if (!await _uow.TripParticipants.IsOrganizerAsync(category.TripId, userId))
45 + return (null, "forbidden");
46 +
47 + var entity = BudgetCategoryBllDtoFactory.ToEntity(category);
48 + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid();
49 + _uow.BudgetCategories.Add(entity);
50 + await _uow.SaveChangesAsync();
51 +
52 + var reloaded = await _uow.BudgetCategories.GetByIdAsync(entity.Id);
53 + return (reloaded == null ? null : BudgetCategoryBllDtoFactory.Create(reloaded), null);
54 + }
55 +
56 + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, BudgetCategoryBllDto incoming, Guid userId)
57 + {
58 + var existing = await _uow.BudgetCategories.GetByIdAsync(id);
59 + if (existing == null) return (false, "notfound");
60 +
61 + if (!await _uow.TripParticipants.IsOrganizerAsync(existing.TripId, userId))
62 + return (false, "forbidden");
63 +
64 + existing.Name = incoming.Name;
65 + existing.IconName = incoming.IconName;
66 + existing.PlannedAmount = incoming.PlannedAmount;
67 + existing.DisplayOrder = incoming.DisplayOrder;
68 +
69 + _uow.BudgetCategories.Update(existing);
70 + await _uow.SaveChangesAsync();
71 + return (true, null);
72 + }
73 +
74 + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId)
75 + {
76 + var existing = await _uow.BudgetCategories.GetByIdAsync(id);
77 + if (existing == null) return (false, "notfound");
78 +
79 + if (!await _uow.TripParticipants.IsOrganizerAsync(existing.TripId, userId))
80 + return (false, "forbidden");
81 +
82 + await _uow.BudgetCategories.RemoveAsync(id);
83 + await _uow.SaveChangesAsync();
84 + return (true, null);
85 + }
86 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ExpenseService.cs +347 −0
@@ -0,0 +1,347 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.WebApp.Application.Contracts;
8 +
9 +namespace SplitApp.WebApp.Application.Services;
10 +
11 +public class ExpenseService : IExpenseService
12 +{
13 + private readonly IAppUnitOfWork _uow;
14 +
15 + public ExpenseService(IAppUnitOfWork uow)
16 + {
17 + _uow = uow;
18 + }
19 +
20 + public async Task<ExpenseBllDto> CreateExpenseWithSplitsAsync(ExpenseBllDto expense, Guid[] participants, decimal[] amounts, decimal[] percentages)
21 + {
22 + var entity = ExpenseBllDtoFactory.ToEntity(expense);
23 + entity.Id = Guid.NewGuid();
24 + _uow.Expenses.Add(entity);
25 +
26 + var splitRepo = _uow.GetRepository<ExpenseSplit>();
27 +
28 + switch (entity.SplitMethod)
29 + {
30 + case ESplitMethod.EqualAll:
31 + {
32 + var allParticipants = (await _uow.TripParticipants.GetByTripIdAsync(entity.TripId)).ToList();
33 + var count = allParticipants.Count;
34 + if (count > 0)
35 + {
36 + var baseAmount = Math.Floor(entity.Amount / count * 100) / 100;
37 + var remainder = entity.Amount - baseAmount * count;
38 +
39 + for (var i = 0; i < allParticipants.Count; i++)
40 + {
41 + var amount = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0);
42 + splitRepo.Add(new ExpenseSplit
43 + {
44 + Id = Guid.NewGuid(),
45 + ExpenseId = entity.Id,
46 + UserId = allParticipants[i].UserId,
47 + Amount = amount
48 + });
49 + }
50 + }
51 + break;
52 + }
53 + case ESplitMethod.EqualSubset:
54 + {
55 + if (participants.Length > 0)
56 + {
57 + var count = participants.Length;
58 + var baseAmount = Math.Floor(entity.Amount / count * 100) / 100;
59 + var remainder = entity.Amount - baseAmount * count;
60 +
61 + for (var i = 0; i < participants.Length; i++)
62 + {
63 + var amount = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0);
64 + splitRepo.Add(new ExpenseSplit
65 + {
66 + Id = Guid.NewGuid(),
67 + ExpenseId = entity.Id,
68 + UserId = participants[i],
69 + Amount = amount
70 + });
71 + }
72 + }
73 + break;
74 + }
75 + case ESplitMethod.ExactAmounts:
76 + {
77 + if (participants.Length > 0 && amounts.Length == participants.Length)
78 + {
79 + for (var i = 0; i < participants.Length; i++)
80 + {
81 + splitRepo.Add(new ExpenseSplit
82 + {
83 + Id = Guid.NewGuid(),
84 + ExpenseId = entity.Id,
85 + UserId = participants[i],
86 + Amount = amounts[i]
87 + });
88 + }
89 + }
90 + break;
91 + }
92 + case ESplitMethod.Percentages:
93 + {
94 + if (participants.Length > 0 && percentages.Length == participants.Length)
95 + {
96 + for (var i = 0; i < participants.Length; i++)
97 + {
98 + var amount = Math.Round(entity.Amount * percentages[i] / 100, 2);
99 + splitRepo.Add(new ExpenseSplit
100 + {
101 + Id = Guid.NewGuid(),
102 + ExpenseId = entity.Id,
103 + UserId = participants[i],
104 + Amount = amount,
105 + Percentage = percentages[i]
106 + });
107 + }
108 + }
109 + break;
110 + }
111 + }
112 +
113 + await _uow.SaveChangesAsync();
114 +
115 + return ExpenseBllDtoFactory.Create(entity);
116 + }
117 +
118 + public async Task DeleteExpenseWithSplitsAsync(Guid expenseId)
119 + {
120 + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(expenseId);
121 + if (expense == null) return;
122 +
123 + var splitRepo = _uow.GetRepository<ExpenseSplit>();
124 +
125 + if (expense.Splits != null)
126 + {
127 + foreach (var split in expense.Splits.ToList())
128 + {
129 + await splitRepo.RemoveAsync(split.Id);
130 + }
131 + }
132 +
133 + await _uow.Expenses.RemoveAsync(expenseId);
134 + await _uow.SaveChangesAsync();
135 + }
136 +
137 + public async Task<List<ExpenseBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
138 + {
139 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
140 + return new List<ExpenseBllDto>();
141 + var expenses = await _uow.Expenses.GetByTripIdAsync(tripId);
142 + return ExpenseBllDtoFactory.CreateList(expenses, includeSplits: true);
143 + }
144 +
145 + public async Task<ExpenseBllDto?> GetByIdAsync(Guid expenseId, Guid userId)
146 + {
147 + var expense = await _uow.Expenses.GetByIdAsync(expenseId);
148 + if (expense == null) return null;
149 + if (!await _uow.TripParticipants.IsParticipantAsync(expense.TripId, userId)) return null;
150 + return ExpenseBllDtoFactory.Create(expense);
151 + }
152 +
153 + public async Task<ExpenseBllDto?> GetByIdWithDetailsAsync(Guid expenseId, Guid userId)
154 + {
155 + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(expenseId);
156 + if (expense == null) return null;
157 + if (!await _uow.TripParticipants.IsParticipantAsync(expense.TripId, userId)) return null;
158 + return ExpenseBllDtoFactory.Create(expense, includeSplits: true);
159 + }
160 +
161 + public async Task<ExpenseBllDto?> GetRawByIdAsync(Guid expenseId)
162 + {
163 + var expense = await _uow.Expenses.GetByIdAsync(expenseId);
164 + return expense == null ? null : ExpenseBllDtoFactory.Create(expense);
165 + }
166 +
167 + public async Task<bool> CanEditExpenseAsync(ExpenseBllDto expense, Guid userId)
168 + {
169 + if (expense.PaidByUserId == userId) return true;
170 + return await _uow.TripParticipants.IsOrganizerAsync(expense.TripId, userId);
171 + }
172 +
173 + public async Task<(bool success, string? errorCode)> UpdateExpenseWithSplitsFromDtoAsync(
174 + Guid id,
175 + decimal amount,
176 + string? description,
177 + DateTime expenseDate,
178 + ESplitMethod splitMethod,
179 + Guid? budgetCategoryId,
180 + Guid? currencyId,
181 + List<(Guid UserId, decimal Amount, decimal? Percentage)> splits,
182 + Guid userId)
183 + {
184 + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(id);
185 + if (expense == null) return (false, "notfound");
186 +
187 + if (!await CanEditExpenseAsync(ExpenseBllDtoFactory.Create(expense), userId))
188 + return (false, "forbidden");
189 +
190 + var trip = await _uow.Trips.GetByIdAsync(expense.TripId);
191 + if (trip != null && trip.Status != ETripStatus.Active)
192 + return (false, "badstatus");
193 +
194 + expense.BudgetCategoryId = budgetCategoryId;
195 + expense.CurrencyId = currencyId;
196 + expense.Amount = amount;
197 + expense.Description = description;
198 + expense.ExpenseDate = expenseDate;
199 + expense.SplitMethod = splitMethod;
200 +
201 + _uow.Expenses.Update(expense);
202 +
203 + var splitRepo = _uow.GetRepository<ExpenseSplit>();
204 + if (expense.Splits != null)
205 + {
206 + foreach (var split in expense.Splits.ToList())
207 + {
208 + await splitRepo.RemoveAsync(split.Id);
209 + }
210 + }
211 +
212 + foreach (var s in splits)
213 + {
214 + splitRepo.Add(new ExpenseSplit
215 + {
216 + ExpenseId = expense.Id,
217 + UserId = s.UserId,
218 + Amount = s.Amount,
219 + Percentage = s.Percentage
220 + });
221 + }
222 +
223 + await _uow.SaveChangesAsync();
224 + return (true, null);
225 + }
226 +
227 + public async Task<(bool success, string? errorCode)> UpdateExpenseAsync(Guid id, ExpenseBllDto incoming, Guid userId)
228 + {
229 + var existing = await _uow.Expenses.GetByIdAsync(id);
230 + if (existing == null) return (false, "notfound");
231 +
232 + if (!await CanEditExpenseAsync(ExpenseBllDtoFactory.Create(existing), userId))
233 + return (false, "forbidden");
234 +
235 + var trip = await _uow.Trips.GetByIdAsync(existing.TripId);
236 + if (trip != null && trip.Status != ETripStatus.Active)
237 + return (false, "badstatus");
238 +
239 + existing.Amount = incoming.Amount;
240 + existing.Description = incoming.Description;
241 + existing.ExpenseDate = incoming.ExpenseDate;
242 + existing.SplitMethod = incoming.SplitMethod;
243 + existing.BudgetCategoryId = incoming.BudgetCategoryId;
244 + existing.CurrencyId = incoming.CurrencyId;
245 + existing.PaidByUserId = incoming.PaidByUserId;
246 +
247 + _uow.Expenses.Update(existing);
248 +
249 + if (incoming.SplitMethod == ESplitMethod.EqualAll)
250 + {
251 + var splitRepo = _uow.GetRepository<ExpenseSplit>();
252 + var expenseWithSplits = await _uow.Expenses.GetByIdWithDetailsAsync(id);
253 + if (expenseWithSplits?.Splits != null)
254 + {
255 + foreach (var oldSplit in expenseWithSplits.Splits.ToList())
256 + {
257 + await splitRepo.RemoveAsync(oldSplit.Id);
258 + }
259 + }
260 +
261 + var participants = (await _uow.TripParticipants.GetByTripIdAsync(existing.TripId)).ToList();
262 +
263 + var count = participants.Count;
264 + if (count > 0)
265 + {
266 + var baseAmount = Math.Floor(incoming.Amount / count * 100) / 100;
267 + var remainder = incoming.Amount - baseAmount * count;
268 +
269 + for (var i = 0; i < participants.Count; i++)
270 + {
271 + var amountPortion = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0);
272 + splitRepo.Add(new ExpenseSplit
273 + {
274 + Id = Guid.NewGuid(),
275 + ExpenseId = id,
276 + UserId = participants[i].UserId,
277 + Amount = amountPortion
278 + });
279 + }
280 + }
281 + }
282 +
283 + await _uow.SaveChangesAsync();
284 + return (true, null);
285 + }
286 +
287 + public async Task<List<SplitPresetBllDto>> GetSplitPresetsByTripAsync(Guid tripId, Guid userId)
288 + {
289 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
290 + return new List<SplitPresetBllDto>();
291 + var presets = await _uow.SplitPresets.GetByTripIdAsync(tripId);
292 + return SplitPresetBllDtoFactory.CreateList(presets, includeMembers: true);
293 + }
294 +
295 + public async Task<bool> SavePresetAsync(Guid tripId, string presetName, ESplitMethod splitMethod,
296 + Guid[] selectedParticipants, decimal[] splitPercentages, Guid userId)
297 + {
298 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return false;
299 +
300 + if (string.IsNullOrWhiteSpace(presetName) || selectedParticipants.Length == 0)
301 + return false;
302 +
303 + var preset = new SplitPreset
304 + {
305 + TripId = tripId,
306 + Name = presetName,
307 + SplitMethod = splitMethod,
308 + CreatedById = userId
309 + };
310 + _uow.SplitPresets.Add(preset);
311 +
312 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
313 + for (var i = 0; i < selectedParticipants.Length; i++)
314 + {
315 + memberRepo.Add(new SplitPresetMember
316 + {
317 + SplitPresetId = preset.Id,
318 + UserId = selectedParticipants[i],
319 + Percentage = splitPercentages.Length > i ? splitPercentages[i] : null
320 + });
321 + }
322 +
323 + await _uow.SaveChangesAsync();
324 + return true;
325 + }
326 +
327 + public async Task<bool> DeletePresetAsync(Guid presetId, Guid tripId, Guid userId)
328 + {
329 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return false;
330 +
331 + var presets = (await _uow.SplitPresets.GetByTripIdAsync(tripId)).ToList();
332 + var preset = presets.FirstOrDefault(p => p.Id == presetId);
333 + if (preset == null) return false;
334 +
335 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
336 + if (preset.Members != null)
337 + {
338 + foreach (var member in preset.Members.ToList())
339 + {
340 + await memberRepo.RemoveAsync(member.Id);
341 + }
342 + }
343 + await _uow.SplitPresets.RemoveAsync(presetId);
344 + await _uow.SaveChangesAsync();
345 + return true;
346 + }
347 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IBudgetCategoryService.cs +14 −0
@@ -0,0 +1,14 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services;
4 +
5 +public interface IBudgetCategoryService
6 +{
7 + Task<List<BudgetCategoryBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only
8 + Task<List<BudgetCategoryBllDto>> GetByTripIdRawAsync(Guid tripId); // no IDOR, used for dropdowns post-check
9 + Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id); // raw (no IDOR yet)
10 +
11 + Task<(BudgetCategoryBllDto? category, string? errorCode)> CreateAsync(BudgetCategoryBllDto category, Guid userId); // organizer only
12 + Task<(bool success, string? errorCode)> UpdateAsync(Guid id, BudgetCategoryBllDto incoming, Guid userId); // organizer only
13 + Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId); // organizer only
14 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IExpenseService.cs +43 −0
@@ -0,0 +1,43 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +
7 +namespace SplitApp.WebApp.Application.Services;
8 +
9 +public interface IExpenseService
10 +{
11 + Task<ExpenseBllDto> CreateExpenseWithSplitsAsync(ExpenseBllDto expense, Guid[] participants, decimal[] amounts, decimal[] percentages);
12 + Task DeleteExpenseWithSplitsAsync(Guid expenseId);
13 +
14 + // Queries (IDOR-aware)
15 + Task<List<ExpenseBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only
16 + Task<ExpenseBllDto?> GetByIdAsync(Guid expenseId, Guid userId); // participant-only
17 + Task<ExpenseBllDto?> GetByIdWithDetailsAsync(Guid expenseId, Guid userId); // participant-only
18 + Task<ExpenseBllDto?> GetRawByIdAsync(Guid expenseId); // no IDOR, raw entity
19 +
20 + // Edit authorization
21 + Task<bool> CanEditExpenseAsync(ExpenseBllDto expense, Guid userId);
22 +
23 + // API-level full update (re-populates splits from explicit DTO data)
24 + Task<(bool success, string? errorCode)> UpdateExpenseWithSplitsFromDtoAsync(
25 + Guid id,
26 + decimal amount,
27 + string? description,
28 + DateTime expenseDate,
29 + ESplitMethod splitMethod,
30 + Guid? budgetCategoryId,
31 + Guid? currencyId,
32 + List<(Guid UserId, decimal Amount, decimal? Percentage)> splits,
33 + Guid userId);
34 +
35 + // MVC-level partial update (re-splits for EqualAll only)
36 + Task<(bool success, string? errorCode)> UpdateExpenseAsync(Guid id, ExpenseBllDto incoming, Guid userId);
37 +
38 + // Split presets (used by MVC Expenses controller dropdowns)
39 + Task<List<SplitPresetBllDto>> GetSplitPresetsByTripAsync(Guid tripId, Guid userId);
40 + Task<bool> SavePresetAsync(Guid tripId, string presetName, ESplitMethod splitMethod,
41 + Guid[] selectedParticipants, decimal[] splitPercentages, Guid userId);
42 + Task<bool> DeletePresetAsync(Guid presetId, Guid tripId, Guid userId);
43 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IInvitationService.cs +29 −0
@@ -0,0 +1,29 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +
7 +namespace SplitApp.WebApp.Application.Services;
8 +
9 +public interface IInvitationService
10 +{
11 + Task<TripInvitationBllDto> CreateInvitationAsync(Guid tripId, Guid userId);
12 + Task<bool> AcceptInvitationAsync(string token, Guid userId);
13 +
14 + // Guarded variant: also checks organizer
15 + Task<(TripInvitationBllDto? invitation, string? errorCode)> CreateInvitationGuardedAsync(Guid tripId, Guid userId);
16 +
17 + // Lookups
18 + Task<TripInvitationBllDto?> GetByIdAsync(Guid id);
19 + Task<TripInvitationBllDto?> GetByTokenAsync(string token);
20 + Task<List<TripInvitationBllDto>> GetPendingByTripIdAsync(Guid tripId, Guid userId);
21 +
22 + // Accept: full guarded flow (API)
23 + Task<(bool success, string? errorCode)> AcceptInvitationGuardedAsync(string token, Guid userId);
24 +
25 + // Revoke & Decline
26 + Task<(bool success, string? errorCode)> RevokeInvitationAsync(Guid invitationId, Guid tripId, Guid userId);
27 + Task<(bool success, string? errorCode)> RevokeInvitationByTokenAsync(string token, Guid userId);
28 + Task<(bool success, string? errorCode)> DeclineInvitationAsync(string token);
29 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IPollService.cs +27 −0
@@ -0,0 +1,27 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services;
4 +
5 +public interface IPollService
6 +{
7 + Task<TripPollBllDto> CreatePollWithOptionsAsync(TripPollBllDto poll, List<string> optionTexts);
8 + Task ToggleVoteAsync(Guid pollId, Guid optionId, Guid userId);
9 + Task DeletePollCascadeAsync(Guid pollId);
10 +
11 + // Queries
12 + Task<List<TripPollBllDto>> GetByTripIdAsync(Guid tripId, Guid userId);
13 + Task<TripPollBllDto?> GetByIdAsync(Guid pollId, Guid userId);
14 + Task<TripPollBllDto?> GetByIdWithDetailsAsync(Guid pollId, Guid userId);
15 +
16 + // Guarded create (participant required)
17 + Task<(TripPollBllDto? poll, string? errorCode)> CreatePollGuardedAsync(TripPollBllDto poll, List<string> optionTexts, Guid userId);
18 +
19 + // Vote
20 + Task<(bool success, string? errorCode)> CastVoteAsync(Guid pollId, Guid optionId, Guid userId);
21 +
22 + // Close
23 + Task<(bool success, string? errorCode)> ClosePollAsync(Guid pollId, Guid userId, bool organizerAllowed);
24 +
25 + // Delete
26 + Task<(bool success, string? errorCode)> DeletePollGuardedAsync(Guid pollId, Guid userId);
27 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISettlementService.cs +45 −0
@@ -0,0 +1,45 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +
7 +namespace SplitApp.WebApp.Application.Services;
8 +
9 +public interface ISettlementService
10 +{
11 + Task<List<BalanceEntry>> CalculateBalancesAsync(Guid tripId);
12 + Task<SettlementPlanBllDto?> CalculateSettlementAsync(Guid tripId, Guid userId);
13 + List<PreviewPayment> PreviewSettlement(List<BalanceEntry> balances);
14 + Task MarkPaidAsync(Guid paymentId, Guid userId);
15 + Task ConfirmPaymentAsync(Guid paymentId, Guid userId);
16 +
17 + // IDOR-protected queries
18 + Task<List<BalanceEntry>> GetBalancesAsync(Guid tripId, Guid userId); // participant-only, empty if forbidden
19 + Task<SettlementPlanBllDto?> GetLatestPlanAsync(Guid tripId, Guid userId); // participant-only
20 + Task<SettlementPlanBllDto?> GetLatestPlanRawAsync(Guid tripId); // no IDOR (used internally after IDOR checked)
21 +
22 + // Payment lookups
23 + Task<SettlementPaymentBllDto?> GetPaymentByIdAsync(Guid paymentId);
24 + Task<SettlementPlanBllDto?> GetPlanByIdAsync(Guid planId);
25 +
26 + // Guarded mark/confirm that also validate trip participation
27 + Task<(bool success, string? errorCode)> MarkPaidGuardedAsync(Guid paymentId, Guid userId);
28 + Task<(bool success, string? errorCode)> ConfirmPaymentGuardedAsync(Guid paymentId, Guid userId);
29 +}
30 +
31 +public class BalanceEntry
32 +{
33 + public Guid UserId { get; set; }
34 + public string UserName { get; set; } = default!;
35 + public decimal TotalPaid { get; set; }
36 + public decimal TotalOwed { get; set; }
37 + public decimal NetBalance => TotalPaid - TotalOwed;
38 +}
39 +
40 +public class PreviewPayment
41 +{
42 + public string FromUserName { get; set; } = default!;
43 + public string ToUserName { get; set; } = default!;
44 + public decimal Amount { get; set; }
45 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISplitPresetService.cs +19 −0
@@ -0,0 +1,19 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.Modules.Trips.Domain.Entities;
3 +using SplitApp.Modules.Trips.Domain.Enums;
4 +using SplitApp.Modules.Expenses.Domain.Entities;
5 +using SplitApp.Modules.Expenses.Domain.Enums;
6 +
7 +namespace SplitApp.WebApp.Application.Services;
8 +
9 +public interface ISplitPresetService
10 +{
11 + Task<List<SplitPresetBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only
12 + Task<SplitPresetBllDto?> GetByIdAsync(Guid id, Guid userId); // participant-only
13 +
14 + Task<(SplitPresetBllDto? preset, string? errorCode)> CreateAsync(SplitPresetBllDto preset,
15 + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId); // participant
16 + Task<(bool success, string? errorCode)> UpdateAsync(Guid id, string name, ESplitMethod splitMethod,
17 + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId); // participant
18 + Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId); // participant
19 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ITripService.cs +38 −0
@@ -0,0 +1,38 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services;
4 +
5 +public interface ITripService
6 +{
7 + Task<TripBllDto> CreateTripAsync(TripBllDto trip, Guid userId);
8 +
9 + // Queries
10 + Task<List<TripBllDto>> GetUserTripsAsync(Guid userId);
11 + Task<TripBllDto?> GetByIdAsync(Guid tripId, Guid userId); // IDOR: must be participant
12 + Task<TripBllDto?> GetByIdWithDetailsAsync(Guid tripId, Guid userId); // IDOR: must be participant
13 + Task<TripBllDto?> GetByIdForOrganizerAsync(Guid tripId, Guid userId); // IDOR: must be organizer
14 + Task<TripBllDto?> GetRawByIdAsync(Guid tripId); // no IDOR (used when invitation is valid)
15 +
16 + // Authorization helpers
17 + Task<bool> IsParticipantAsync(Guid tripId, Guid userId);
18 + Task<bool> IsOrganizerAsync(Guid tripId, Guid userId);
19 +
20 + // Mutations
21 + Task<TripBllDto?> UpdateAsync(TripBllDto trip, Guid userId); // null if not organizer
22 + Task<bool> DeleteAsync(Guid tripId, Guid userId); // false if not organizer
23 +
24 + // Participant queries/actions
25 + Task<List<TripParticipantBllDto>> GetParticipantsAsync(Guid tripId, Guid userId); // participant-only
26 + Task<List<TripParticipantBllDto>> GetParticipantsForIndexAsync(Guid tripId, Guid userId);
27 + Task<TripParticipantBllDto?> GetParticipantByIdAsync(Guid participantId);
28 +
29 + Task<(bool success, string? errorCode)> RemoveParticipantAsync(Guid tripId, Guid participantUserId, Guid currentUserId);
30 + Task<bool> RemoveParticipantByIdAsync(Guid tripId, Guid participantId, Guid currentUserId);
31 +
32 + // Trip lifecycle
33 + Task<(bool success, string? errorCode)> FinalizeTripAsync(Guid tripId, Guid userId);
34 + Task<(bool success, string? errorCode)> ReopenTripAsync(Guid tripId, Guid userId);
35 +
36 + // Dropdown data
37 + Task<List<CurrencyBllDto>> GetAllCurrenciesAsync();
38 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IWishlistService.cs +16 −0
@@ -0,0 +1,16 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +
3 +namespace SplitApp.WebApp.Application.Services;
4 +
5 +public interface IWishlistService
6 +{
7 + Task<List<TripWishlistItemBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only
8 + Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id, Guid userId); // participant-only
9 + Task<TripWishlistItemBllDto?> GetByIdRawAsync(Guid id); // no IDOR
10 +
11 + Task<(TripWishlistItemBllDto? item, string? errorCode)> CreateAsync(TripWishlistItemBllDto item, Guid userId); // participant
12 + Task<(bool success, string? errorCode)> UpdateAsync(Guid id, TripWishlistItemBllDto incoming, Guid userId); // creator only
13 + Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId); // creator only
14 + Task<(bool success, string? errorCode)> ToggleVoteAsync(Guid id, Guid userId); // participant
15 + Task<(bool success, string? errorCode)> ToggleCompleteAsync(Guid id, Guid userId); // participant
16 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/InvitationService.cs +192 −0
@@ -0,0 +1,192 @@
1 +using System.Security.Cryptography;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Domain.Entities;
7 +using SplitApp.Modules.Expenses.Domain.Enums;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services;
11 +
12 +public class InvitationService : IInvitationService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 +
16 + public InvitationService(IAppUnitOfWork uow)
17 + {
18 + _uow = uow;
19 + }
20 +
21 + public async Task<TripInvitationBllDto> CreateInvitationAsync(Guid tripId, Guid userId)
22 + {
23 + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
24 + .Replace("+", "-").Replace("/", "_").TrimEnd('=');
25 +
26 + var invitation = new TripInvitation
27 + {
28 + Id = Guid.NewGuid(),
29 + TripId = tripId,
30 + InvitedByUserId = userId,
31 + Token = token,
32 + Status = EInvitationStatus.Pending,
33 + ExpiresAt = DateTime.UtcNow.AddDays(7)
34 + };
35 +
36 + _uow.TripInvitations.Add(invitation);
37 + await _uow.SaveChangesAsync();
38 +
39 + return InvitationBllDtoFactory.Create(invitation);
40 + }
41 +
42 + public async Task<bool> AcceptInvitationAsync(string token, Guid userId)
43 + {
44 + var invitation = await _uow.TripInvitations.GetByTokenAsync(token);
45 + if (invitation == null) return false;
46 +
47 + if (invitation.Status != EInvitationStatus.Pending || invitation.ExpiresAt < DateTime.UtcNow)
48 + return false;
49 +
50 + // Check if already a participant
51 + var participants = (await _uow.TripParticipants.GetByTripIdAsync(invitation.TripId)).ToList();
52 + var existingParticipant = participants.FirstOrDefault(tp => tp.UserId == userId);
53 +
54 + var allParticipants = await _uow.TripParticipants.GetAllAsync();
55 + var inactiveParticipant = allParticipants
56 + .FirstOrDefault(tp => tp.TripId == invitation.TripId && tp.UserId == userId && !tp.IsActive);
57 +
58 + if (existingParticipant != null)
59 + {
60 + // Already an active participant
61 + }
62 + else if (inactiveParticipant != null)
63 + {
64 + inactiveParticipant.IsActive = true;
65 + inactiveParticipant.LeftAt = null;
66 + _uow.TripParticipants.Update(inactiveParticipant);
67 + }
68 + else
69 + {
70 + var participant = new TripParticipant
71 + {
72 + Id = Guid.NewGuid(),
73 + TripId = invitation.TripId,
74 + UserId = userId,
75 + Role = EParticipantRole.Participant,
76 + JoinedAt = DateTime.UtcNow,
77 + IsActive = true
78 + };
79 + _uow.TripParticipants.Add(participant);
80 + }
81 +
82 + invitation.Status = EInvitationStatus.Accepted;
83 + invitation.RespondedAt = DateTime.UtcNow;
84 + _uow.TripInvitations.Update(invitation);
85 +
86 + await _uow.SaveChangesAsync();
87 +
88 + return true;
89 + }
90 +
91 + public async Task<(TripInvitationBllDto? invitation, string? errorCode)> CreateInvitationGuardedAsync(Guid tripId, Guid userId)
92 + {
93 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId))
94 + return (null, "forbidden");
95 + var invitation = await CreateInvitationAsync(tripId, userId);
96 + return (invitation, null);
97 + }
98 +
99 + public async Task<TripInvitationBllDto?> GetByIdAsync(Guid id)
100 + {
101 + var entity = await _uow.TripInvitations.GetByIdAsync(id);
102 + return entity == null ? null : InvitationBllDtoFactory.Create(entity);
103 + }
104 +
105 + public async Task<TripInvitationBllDto?> GetByTokenAsync(string token)
106 + {
107 + var entity = await _uow.TripInvitations.GetByTokenAsync(token);
108 + return entity == null ? null : InvitationBllDtoFactory.Create(entity);
109 + }
110 +
111 + public async Task<List<TripInvitationBllDto>> GetPendingByTripIdAsync(Guid tripId, Guid userId)
112 + {
113 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
114 + return new List<TripInvitationBllDto>();
115 + var invitations = await _uow.TripInvitations.GetPendingByTripIdAsync(tripId);
116 + return InvitationBllDtoFactory.CreateList(invitations);
117 + }
118 +
119 + public async Task<(bool success, string? errorCode)> AcceptInvitationGuardedAsync(string token, Guid userId)
120 + {
121 + var invitation = await _uow.TripInvitations.GetByTokenAsync(token);
122 + if (invitation == null) return (false, "notfound");
123 +
124 + if (invitation.Status != EInvitationStatus.Pending)
125 + return (false, "not-pending");
126 +
127 + if (invitation.ExpiresAt < DateTime.UtcNow)
128 + {
129 + invitation.Status = EInvitationStatus.Expired;
130 + _uow.TripInvitations.Update(invitation);
131 + await _uow.SaveChangesAsync();
132 + return (false, "expired");
133 + }
134 +
135 + if (await _uow.TripParticipants.IsParticipantAsync(invitation.TripId, userId))
136 + return (false, "already-participant");
137 +
138 + var accepted = await AcceptInvitationAsync(token, userId);
139 + if (!accepted) return (false, "failed");
140 +
141 + return (true, null);
142 + }
143 +
144 + public async Task<(bool success, string? errorCode)> RevokeInvitationAsync(Guid invitationId, Guid tripId, Guid userId)
145 + {
146 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId))
147 + return (false, "forbidden");
148 +
149 + var invitation = await _uow.TripInvitations.GetByIdAsync(invitationId);
150 + if (invitation == null || invitation.TripId != tripId) return (false, "notfound");
151 +
152 + if (invitation.Status == EInvitationStatus.Pending)
153 + {
154 + invitation.Status = EInvitationStatus.Revoked;
155 + invitation.RespondedAt = DateTime.UtcNow;
156 + _uow.TripInvitations.Update(invitation);
157 + await _uow.SaveChangesAsync();
158 + }
159 +
160 + return (true, null);
161 + }
162 +
163 + public async Task<(bool success, string? errorCode)> RevokeInvitationByTokenAsync(string token, Guid userId)
164 + {
165 + var invitation = await _uow.TripInvitations.GetByTokenAsync(token);
166 + if (invitation == null) return (false, "notfound");
167 +
168 + if (!await _uow.TripParticipants.IsOrganizerAsync(invitation.TripId, userId))
169 + return (false, "forbidden");
170 +
171 + invitation.Status = EInvitationStatus.Revoked;
172 + invitation.RespondedAt = DateTime.UtcNow;
173 + _uow.TripInvitations.Update(invitation);
174 + await _uow.SaveChangesAsync();
175 + return (true, null);
176 + }
177 +
178 + public async Task<(bool success, string? errorCode)> DeclineInvitationAsync(string token)
179 + {
180 + var invitation = await _uow.TripInvitations.GetByTokenAsync(token);
181 + if (invitation == null) return (false, "notfound");
182 +
183 + if (invitation.Status != EInvitationStatus.Pending)
184 + return (false, "not-pending");
185 +
186 + invitation.Status = EInvitationStatus.Declined;
187 + invitation.RespondedAt = DateTime.UtcNow;
188 + _uow.TripInvitations.Update(invitation);
189 + await _uow.SaveChangesAsync();
190 + return (true, null);
191 + }
192 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/PollService.cs +206 −0
@@ -0,0 +1,206 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.WebApp.Application.Contracts;
8 +
9 +namespace SplitApp.WebApp.Application.Services;
10 +
11 +public class PollService : IPollService
12 +{
13 + private readonly IAppUnitOfWork _uow;
14 +
15 + public PollService(IAppUnitOfWork uow)
16 + {
17 + _uow = uow;
18 + }
19 +
20 + public async Task<TripPollBllDto> CreatePollWithOptionsAsync(TripPollBllDto poll, List<string> optionTexts)
21 + {
22 + var entity = PollBllDtoFactory.ToEntity(poll);
23 + entity.Id = Guid.NewGuid();
24 + _uow.TripPolls.Add(entity);
25 +
26 + var optionRepo = _uow.GetRepository<TripPollOption>();
27 + var order = 0;
28 +
29 + foreach (var text in optionTexts.Where(t => !string.IsNullOrWhiteSpace(t)))
30 + {
31 + optionRepo.Add(new TripPollOption
32 + {
33 + Id = Guid.NewGuid(),
34 + PollId = entity.Id,
35 + Text = text,
36 + DisplayOrder = order++
37 + });
38 + }
39 +
40 + await _uow.SaveChangesAsync();
41 +
42 + return PollBllDtoFactory.Create(entity);
43 + }
44 +
45 + public async Task ToggleVoteAsync(Guid pollId, Guid optionId, Guid userId)
46 + {
47 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
48 + if (poll == null) return;
49 +
50 + if (poll.ClosedAt != null) return;
51 +
52 + var option = poll.Options?.FirstOrDefault(o => o.Id == optionId);
53 + if (option == null) return;
54 +
55 + var voteRepo = _uow.GetRepository<TripPollVote>();
56 +
57 + if (!poll.AllowMultipleVotes)
58 + {
59 + if (poll.Options != null)
60 + {
61 + foreach (var opt in poll.Options)
62 + {
63 + if (opt.Votes != null)
64 + {
65 + foreach (var vote in opt.Votes.Where(v => v.UserId == userId).ToList())
66 + {
67 + await voteRepo.RemoveAsync(vote.Id);
68 + }
69 + }
70 + }
71 + }
72 + }
73 +
74 + var existingVote = option.Votes?.FirstOrDefault(v => v.UserId == userId);
75 +
76 + if (existingVote != null)
77 + {
78 + await voteRepo.RemoveAsync(existingVote.Id);
79 + }
80 + else
81 + {
82 + voteRepo.Add(new TripPollVote
83 + {
84 + Id = Guid.NewGuid(),
85 + PollOptionId = optionId,
86 + UserId = userId
87 + });
88 + }
89 +
90 + await _uow.SaveChangesAsync();
91 + }
92 +
93 + public async Task<List<TripPollBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
94 + {
95 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
96 + return new List<TripPollBllDto>();
97 + var polls = await _uow.TripPolls.GetByTripIdAsync(tripId);
98 + return PollBllDtoFactory.CreateList(polls, includeOptions: true);
99 + }
100 +
101 + public async Task<TripPollBllDto?> GetByIdAsync(Guid pollId, Guid userId)
102 + {
103 + var poll = await _uow.TripPolls.GetByIdAsync(pollId);
104 + if (poll == null) return null;
105 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) return null;
106 + return PollBllDtoFactory.Create(poll);
107 + }
108 +
109 + public async Task<TripPollBllDto?> GetByIdWithDetailsAsync(Guid pollId, Guid userId)
110 + {
111 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
112 + if (poll == null) return null;
113 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) return null;
114 + return PollBllDtoFactory.Create(poll, includeOptions: true);
115 + }
116 +
117 + public async Task<(TripPollBllDto? poll, string? errorCode)> CreatePollGuardedAsync(TripPollBllDto poll, List<string> optionTexts, Guid userId)
118 + {
119 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId))
120 + return (null, "forbidden");
121 + var created = await CreatePollWithOptionsAsync(poll, optionTexts);
122 + return (created, null);
123 + }
124 +
125 + public async Task<(bool success, string? errorCode)> CastVoteAsync(Guid pollId, Guid optionId, Guid userId)
126 + {
127 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
128 + if (poll == null) return (false, "notfound");
129 +
130 + if (poll.ClosedAt != null) return (false, "closed");
131 +
132 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId))
133 + return (false, "forbidden");
134 +
135 + var option = poll.Options?.FirstOrDefault(o => o.Id == optionId);
136 + if (option == null) return (false, "invalid-option");
137 +
138 + await ToggleVoteAsync(pollId, optionId, userId);
139 + return (true, null);
140 + }
141 +
142 + public async Task<(bool success, string? errorCode)> ClosePollAsync(Guid pollId, Guid userId, bool organizerAllowed)
143 + {
144 + var poll = await _uow.TripPolls.GetByIdAsync(pollId);
145 + if (poll == null) return (false, "notfound");
146 +
147 + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId))
148 + return (false, "forbidden");
149 +
150 + var isCreator = poll.CreatedByUserId == userId;
151 + var isOrganizer = organizerAllowed && await _uow.TripParticipants.IsOrganizerAsync(poll.TripId, userId);
152 +
153 + if (!isCreator && !isOrganizer) return (false, "forbidden");
154 +
155 + poll.ClosedAt = DateTime.UtcNow;
156 + _uow.TripPolls.Update(poll);
157 + await _uow.SaveChangesAsync();
158 + return (true, null);
159 + }
160 +
161 + public async Task<(bool success, string? errorCode)> DeletePollGuardedAsync(Guid pollId, Guid userId)
162 + {
163 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
164 + if (poll == null) return (false, "notfound");
165 +
166 + var isCreator = poll.CreatedByUserId == userId;
167 + var isOrganizer = await _uow.TripParticipants.IsOrganizerAsync(poll.TripId, userId);
168 +
169 + if (!isCreator && !isOrganizer) return (false, "forbidden");
170 +
171 + await DeletePollCascadeAsync(pollId);
172 + return (true, null);
173 + }
174 +
175 + public async Task DeletePollCascadeAsync(Guid pollId)
176 + {
177 + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId);
178 + if (poll == null) return;
179 +
180 + var voteRepo = _uow.GetRepository<TripPollVote>();
181 + var optionRepo = _uow.GetRepository<TripPollOption>();
182 +
183 + if (poll.Options != null)
184 + {
185 + foreach (var option in poll.Options)
186 + {
187 + if (option.Votes != null)
188 + {
189 + foreach (var vote in option.Votes.ToList())
190 + {
191 + await voteRepo.RemoveAsync(vote.Id);
192 + }
193 + }
194 + }
195 +
196 + foreach (var option in poll.Options.ToList())
197 + {
198 + await optionRepo.RemoveAsync(option.Id);
199 + }
200 + }
201 +
202 + await _uow.TripPolls.RemoveAsync(pollId);
203 +
204 + await _uow.SaveChangesAsync();
205 + }
206 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SettlementService.cs +337 −0
@@ -0,0 +1,337 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Helpers;
3 +using SplitApp.WebApp.Application.Mappers;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Domain.Entities;
7 +using SplitApp.Modules.Expenses.Domain.Enums;
8 +using SplitApp.WebApp.Application.Contracts;
9 +
10 +namespace SplitApp.WebApp.Application.Services;
11 +
12 +public class SettlementService : ISettlementService
13 +{
14 + private readonly IAppUnitOfWork _uow;
15 +
16 + public SettlementService(IAppUnitOfWork uow)
17 + {
18 + _uow = uow;
19 + }
20 +
21 + public async Task<List<BalanceEntry>> CalculateBalancesAsync(Guid tripId)
22 + {
23 + var trip = await _uow.Trips.GetByIdWithDetailsAsync(tripId);
24 + if (trip == null) return new List<BalanceEntry>();
25 +
26 + var defaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR";
27 +
28 + var participants = (await _uow.TripParticipants.GetByTripIdAsync(tripId)).ToList();
29 +
30 + var balances = new Dictionary<Guid, BalanceEntry>();
31 + foreach (var p in participants)
32 + {
33 + var displayName = p.User?.DisplayName ?? "";
34 + balances[p.UserId] = new BalanceEntry
35 + {
36 + UserId = p.UserId,
37 + UserName = !string.IsNullOrEmpty(displayName)
38 + ? displayName
39 + : (p.User?.Email ?? "Unknown"),
40 + TotalPaid = 0,
41 + TotalOwed = 0
42 + };
43 + }
44 +
45 + var expenses = (await _uow.Expenses.GetByTripIdAsync(tripId)).ToList();
46 +
47 + // Need expenses with splits - fetch each with details
48 + foreach (var expense in expenses)
49 + {
50 + var expenseWithSplits = await _uow.Expenses.GetByIdWithDetailsAsync(expense.Id);
51 + if (expenseWithSplits == null) continue;
52 +
53 + var expCurrency = expenseWithSplits.Currency?.Code ?? defaultCurrencyCode;
54 + var convertedAmount = CurrencyConverter.Convert(expenseWithSplits.Amount, expCurrency, defaultCurrencyCode);
55 +
56 + if (balances.ContainsKey(expenseWithSplits.PaidByUserId))
57 + {
58 + balances[expenseWithSplits.PaidByUserId].TotalPaid += convertedAmount;
59 + }
60 +
61 + if (expenseWithSplits.Splits != null)
62 + {
63 + foreach (var split in expenseWithSplits.Splits)
64 + {
65 + var convertedSplit = CurrencyConverter.Convert(split.Amount, expCurrency, defaultCurrencyCode);
66 + if (balances.ContainsKey(split.UserId))
67 + {
68 + balances[split.UserId].TotalOwed += convertedSplit;
69 + }
70 + }
71 + }
72 + }
73 +
74 + return balances.Values.OrderByDescending(b => b.NetBalance).ToList();
75 + }
76 +
77 + public async Task<SettlementPlanBllDto?> CalculateSettlementAsync(Guid tripId, Guid userId)
78 + {
79 + var balanceList = await CalculateBalancesAsync(tripId);
80 +
81 + var creditors = balanceList.Where(b => b.NetBalance > 0.01m)
82 + .Select(b => new { b.UserId, Amount = b.NetBalance })
83 + .OrderByDescending(c => c.Amount)
84 + .ToList();
85 +
86 + var debtors = balanceList.Where(b => b.NetBalance < -0.01m)
87 + .Select(b => new { b.UserId, Amount = -b.NetBalance })
88 + .OrderByDescending(d => d.Amount)
89 + .ToList();
90 +
91 + if (!creditors.Any() || !debtors.Any()) return null;
92 +
93 + var plan = new SettlementPlan
94 + {
95 + Id = Guid.NewGuid(),
96 + TripId = tripId,
97 + CreatedByUserId = userId,
98 + TotalAmount = creditors.Sum(c => c.Amount),
99 + Status = ESettlementStatus.Pending
100 + };
101 +
102 + _uow.SettlementPlans.Add(plan);
103 +
104 + var paymentRepo = _uow.GetRepository<SettlementPayment>();
105 +
106 + var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount);
107 + var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount);
108 + var sortedCreditors = creditBalances.Keys.ToList();
109 + var sortedDebtors = debtBalances.Keys.ToList();
110 + var ci = 0;
111 + var di = 0;
112 +
113 + while (ci < sortedCreditors.Count && di < sortedDebtors.Count)
114 + {
115 + var creditorId = sortedCreditors[ci];
116 + var debtorId = sortedDebtors[di];
117 + var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]);
118 +
119 + if (amount > 0.01m)
120 + {
121 + paymentRepo.Add(new SettlementPayment
122 + {
123 + Id = Guid.NewGuid(),
124 + SettlementPlanId = plan.Id,
125 + FromUserId = debtorId,
126 + ToUserId = creditorId,
127 + Amount = Math.Round(amount, 2),
128 + Status = EPaymentStatus.Pending
129 + });
130 + }
131 +
132 + creditBalances[creditorId] -= amount;
133 + debtBalances[debtorId] -= amount;
134 + if (creditBalances[creditorId] < 0.01m) ci++;
135 + if (debtBalances[debtorId] < 0.01m) di++;
136 + }
137 +
138 + await _uow.SaveChangesAsync();
139 +
140 + // Return the plan with navigation properties loaded
141 + var reloaded = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId);
142 + return reloaded == null ? null : SettlementBllDtoFactory.Create(reloaded, includePayments: true);
143 + }
144 +
145 + public List<PreviewPayment> PreviewSettlement(List<BalanceEntry> balanceList)
146 + {
147 + var result = new List<PreviewPayment>();
148 +
149 + var creditors = balanceList.Where(b => b.NetBalance > 0.01m)
150 + .Select(b => new { b.UserName, Amount = b.NetBalance })
151 + .OrderByDescending(c => c.Amount).ToList();
152 +
153 + var debtors = balanceList.Where(b => b.NetBalance < -0.01m)
154 + .Select(b => new { b.UserName, Amount = -b.NetBalance })
155 + .OrderByDescending(d => d.Amount).ToList();
156 +
157 + if (!creditors.Any() || !debtors.Any()) return result;
158 +
159 + var creditBalances = creditors.ToDictionary(c => c.UserName, c => c.Amount);
160 + var debtBalances = debtors.ToDictionary(d => d.UserName, d => d.Amount);
161 + var sortedCreditors = creditBalances.Keys.ToList();
162 + var sortedDebtors = debtBalances.Keys.ToList();
163 + var ci = 0;
164 + var di = 0;
165 +
166 + while (ci < sortedCreditors.Count && di < sortedDebtors.Count)
167 + {
168 + var creditor = sortedCreditors[ci];
169 + var debtor = sortedDebtors[di];
170 + var amount = Math.Min(creditBalances[creditor], debtBalances[debtor]);
171 +
172 + if (amount > 0.01m)
173 + {
174 + result.Add(new PreviewPayment
175 + {
176 + FromUserName = debtor,
177 + ToUserName = creditor,
178 + Amount = Math.Round(amount, 2)
179 + });
180 + }
181 +
182 + creditBalances[creditor] -= amount;
183 + debtBalances[debtor] -= amount;
184 + if (creditBalances[creditor] < 0.01m) ci++;
185 + if (debtBalances[debtor] < 0.01m) di++;
186 + }
187 +
188 + return result;
189 + }
190 +
191 + public async Task MarkPaidAsync(Guid paymentId, Guid userId)
192 + {
193 + var paymentRepo = _uow.GetRepository<SettlementPayment>();
194 + var payment = await paymentRepo.GetByIdAsync(paymentId);
195 + if (payment == null) return;
196 +
197 + if (payment.FromUserId != userId) return;
198 +
199 + payment.Status = EPaymentStatus.MarkedPaid;
200 + payment.MarkedPaidAt = DateTime.UtcNow;
201 +
202 + paymentRepo.Update(payment);
203 + await _uow.SaveChangesAsync();
204 + }
205 +
206 + // --- New IDOR-protected helpers ---
207 +
208 + public async Task<List<BalanceEntry>> GetBalancesAsync(Guid tripId, Guid userId)
209 + {
210 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
211 + return new List<BalanceEntry>();
212 + return await CalculateBalancesAsync(tripId);
213 + }
214 +
215 + public async Task<SettlementPlanBllDto?> GetLatestPlanAsync(Guid tripId, Guid userId)
216 + {
217 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
218 + return null;
219 + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId);
220 + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true);
221 + }
222 +
223 + public async Task<SettlementPlanBllDto?> GetLatestPlanRawAsync(Guid tripId)
224 + {
225 + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId);
226 + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true);
227 + }
228 +
229 + public async Task<SettlementPaymentBllDto?> GetPaymentByIdAsync(Guid paymentId)
230 + {
231 + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId);
232 + return payment == null ? null : SettlementPaymentBllDtoFactory.Create(payment);
233 + }
234 +
235 + public async Task<SettlementPlanBllDto?> GetPlanByIdAsync(Guid planId)
236 + {
237 + var plan = await _uow.SettlementPlans.GetByIdAsync(planId);
238 + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true);
239 + }
240 +
241 + public async Task<(bool success, string? errorCode)> MarkPaidGuardedAsync(Guid paymentId, Guid userId)
242 + {
243 + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId);
244 + if (payment == null) return (false, "notfound");
245 +
246 + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
247 + if (plan == null) return (false, "notfound");
248 +
249 + if (!await _uow.TripParticipants.IsParticipantAsync(plan.TripId, userId))
250 + return (false, "forbidden");
251 +
252 + if (payment.FromUserId != userId) return (false, "forbidden");
253 +
254 + await MarkPaidAsync(paymentId, userId);
255 + return (true, null);
256 + }
257 +
258 + public async Task<(bool success, string? errorCode)> ConfirmPaymentGuardedAsync(Guid paymentId, Guid userId)
259 + {
260 + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId);
261 + if (payment == null) return (false, "notfound");
262 +
263 + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
264 + if (plan == null) return (false, "notfound");
265 +
266 + if (!await _uow.TripParticipants.IsParticipantAsync(plan.TripId, userId))
267 + return (false, "forbidden");
268 +
269 + if (payment.ToUserId != userId) return (false, "forbidden");
270 +
271 + await ConfirmPaymentAsync(paymentId, userId);
272 + return (true, null);
273 + }
274 +
275 + public async Task ConfirmPaymentAsync(Guid paymentId, Guid userId)
276 + {
277 + // DAL uses NoTrackingWithIdentityResolution, so every load returns a
278 + // detached entity. Mutations only persist via an explicit Update() call.
279 + var paymentRepo = _uow.GetRepository<SettlementPayment>();
280 + var payment = await paymentRepo.GetByIdAsync(paymentId);
281 + if (payment == null) return;
282 + if (payment.ToUserId != userId) return;
283 +
284 + payment.Status = EPaymentStatus.Confirmed;
285 + payment.ConfirmedAt = DateTime.UtcNow;
286 + paymentRepo.Update(payment);
287 +
288 + // Read the plan with its Payments to check whether the plan is now
289 + // fully confirmed. This load is read-only — used only for the All()
290 + // check below — so we don't Update() it (its FromUser/ToUser includes
291 + // would make DbSet.Update cascade into the AppUser graph and corrupt
292 + // Identity rows on SaveChanges).
293 + var planForCheck = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId);
294 + if (planForCheck?.Payments == null)
295 + {
296 + await _uow.SaveChangesAsync();
297 + return;
298 + }
299 +
300 + // The just-mutated payment is a different instance from the one inside
301 + // planForCheck.Payments (no tracking → no identity map across queries).
302 + // Treat the current paymentId as already Confirmed when checking.
303 + var allConfirmed = planForCheck.Payments.All(p =>
304 + p.Id == paymentId || p.Status == EPaymentStatus.Confirmed);
305 +
306 + // Update plan + trip via the base repo (no Includes) so Update() only
307 + // touches the plan/trip rows themselves.
308 + var planRepo = _uow.GetRepository<SettlementPlan>();
309 + var plan = await planRepo.GetByIdAsync(payment.SettlementPlanId);
310 + if (plan == null)
311 + {
312 + await _uow.SaveChangesAsync();
313 + return;
314 + }
315 +
316 + if (allConfirmed)
317 + {
318 + plan.Status = ESettlementStatus.Completed;
319 + plan.CompletedAt = DateTime.UtcNow;
320 +
321 + var tripRepo = _uow.GetRepository<Trip>();
322 + var trip = await tripRepo.GetByIdAsync(plan.TripId);
323 + if (trip != null && trip.Status == ETripStatus.Finalizing)
324 + {
325 + trip.Status = ETripStatus.Settled;
326 + tripRepo.Update(trip);
327 + }
328 + }
329 + else
330 + {
331 + plan.Status = ESettlementStatus.InProgress;
332 + }
333 + planRepo.Update(plan);
334 +
335 + await _uow.SaveChangesAsync();
336 + }
337 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SplitPresetService.cs +123 −0
@@ -0,0 +1,123 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.WebApp.Application.Contracts;
8 +
9 +namespace SplitApp.WebApp.Application.Services;
10 +
11 +public class SplitPresetService : ISplitPresetService
12 +{
13 + private readonly IAppUnitOfWork _uow;
14 +
15 + public SplitPresetService(IAppUnitOfWork uow)
16 + {
17 + _uow = uow;
18 + }
19 +
20 + public async Task<List<SplitPresetBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
21 + {
22 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
23 + return new List<SplitPresetBllDto>();
24 + var presets = await _uow.SplitPresets.GetByTripIdAsync(tripId);
25 + return SplitPresetBllDtoFactory.CreateList(presets, includeMembers: true);
26 + }
27 +
28 + public async Task<SplitPresetBllDto?> GetByIdAsync(Guid id, Guid userId)
29 + {
30 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
31 + if (preset == null) return null;
32 + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) return null;
33 + return SplitPresetBllDtoFactory.Create(preset, includeMembers: true);
34 + }
35 +
36 + public async Task<(SplitPresetBllDto? preset, string? errorCode)> CreateAsync(SplitPresetBllDto preset,
37 + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId)
38 + {
39 + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId))
40 + return (null, "forbidden");
41 +
42 + var entity = SplitPresetBllDtoFactory.ToEntity(preset);
43 + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid();
44 + entity.CreatedById = userId;
45 + _uow.SplitPresets.Add(entity);
46 +
47 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
48 + foreach (var m in members)
49 + {
50 + memberRepo.Add(new SplitPresetMember
51 + {
52 + SplitPresetId = entity.Id,
53 + UserId = m.UserId,
54 + ShareWeight = m.ShareWeight,
55 + Percentage = m.Percentage
56 + });
57 + }
58 +
59 + await _uow.SaveChangesAsync();
60 +
61 + var reloaded = await _uow.SplitPresets.GetByIdAsync(entity.Id);
62 + return (reloaded == null ? null : SplitPresetBllDtoFactory.Create(reloaded, includeMembers: true), null);
63 + }
64 +
65 + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, string name, ESplitMethod splitMethod,
66 + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId)
67 + {
68 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
69 + if (preset == null) return (false, "notfound");
70 +
71 + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId))
72 + return (false, "forbidden");
73 +
74 + preset.Name = name;
75 + preset.SplitMethod = splitMethod;
76 + _uow.SplitPresets.Update(preset);
77 +
78 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
79 + if (preset.Members != null)
80 + {
81 + foreach (var member in preset.Members.ToList())
82 + {
83 + await memberRepo.RemoveAsync(member.Id);
84 + }
85 + }
86 +
87 + foreach (var m in members)
88 + {
89 + memberRepo.Add(new SplitPresetMember
90 + {
91 + SplitPresetId = preset.Id,
92 + UserId = m.UserId,
93 + ShareWeight = m.ShareWeight,
94 + Percentage = m.Percentage
95 + });
96 + }
97 +
98 + await _uow.SaveChangesAsync();
99 + return (true, null);
100 + }
101 +
102 + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId)
103 + {
104 + var preset = await _uow.SplitPresets.GetByIdAsync(id);
105 + if (preset == null) return (false, "notfound");
106 +
107 + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId))
108 + return (false, "forbidden");
109 +
110 + var memberRepo = _uow.GetRepository<SplitPresetMember>();
111 + if (preset.Members != null)
112 + {
113 + foreach (var member in preset.Members.ToList())
114 + {
115 + await memberRepo.RemoveAsync(member.Id);
116 + }
117 + }
118 +
119 + await _uow.SplitPresets.RemoveAsync(id);
120 + await _uow.SaveChangesAsync();
121 + return (true, null);
122 + }
123 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/TripService.cs +239 −0
@@ -0,0 +1,239 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.WebApp.Application.Contracts;
8 +
9 +namespace SplitApp.WebApp.Application.Services;
10 +
11 +public class TripService : ITripService
12 +{
13 + private readonly IAppUnitOfWork _uow;
14 + private readonly ISettlementService _settlementService;
15 +
16 + public TripService(IAppUnitOfWork uow, ISettlementService settlementService)
17 + {
18 + _uow = uow;
19 + _settlementService = settlementService;
20 + }
21 +
22 + public async Task<TripBllDto> CreateTripAsync(TripBllDto trip, Guid userId)
23 + {
24 + var entity = TripBllDtoFactory.ToEntity(trip);
25 + entity.Id = Guid.NewGuid();
26 + entity.CreatedById = userId;
27 + entity.Status = ETripStatus.Active;
28 +
29 + _uow.Trips.Add(entity);
30 +
31 + var participant = new TripParticipant
32 + {
33 + Id = Guid.NewGuid(),
34 + TripId = entity.Id,
35 + UserId = userId,
36 + Role = EParticipantRole.Organizer,
37 + JoinedAt = DateTime.UtcNow,
38 + IsActive = true
39 + };
40 +
41 + _uow.TripParticipants.Add(participant);
42 +
43 + await _uow.SaveChangesAsync();
44 +
45 + return TripBllDtoFactory.Create(entity);
46 + }
47 +
48 + public async Task<List<TripBllDto>> GetUserTripsAsync(Guid userId)
49 + {
50 + var trips = await _uow.Trips.GetUserTripsAsync(userId);
51 + return TripBllDtoFactory.CreateList(trips, includeParticipants: true);
52 + }
53 +
54 + public async Task<TripBllDto?> GetByIdAsync(Guid tripId, Guid userId)
55 + {
56 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return null;
57 + var trip = await _uow.Trips.GetByIdAsync(tripId);
58 + return trip == null ? null : TripBllDtoFactory.Create(trip);
59 + }
60 +
61 + public async Task<TripBllDto?> GetByIdWithDetailsAsync(Guid tripId, Guid userId)
62 + {
63 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return null;
64 + var trip = await _uow.Trips.GetByIdWithDetailsAsync(tripId);
65 + return trip == null ? null : TripBllDtoFactory.Create(trip, includeParticipants: true, includeExpenses: true);
66 + }
67 +
68 + public async Task<TripBllDto?> GetByIdForOrganizerAsync(Guid tripId, Guid userId)
69 + {
70 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) return null;
71 + var trip = await _uow.Trips.GetByIdAsync(tripId);
72 + return trip == null ? null : TripBllDtoFactory.Create(trip);
73 + }
74 +
75 + public async Task<TripBllDto?> GetRawByIdAsync(Guid tripId)
76 + {
77 + var trip = await _uow.Trips.GetByIdAsync(tripId);
78 + return trip == null ? null : TripBllDtoFactory.Create(trip);
79 + }
80 +
81 + public Task<bool> IsParticipantAsync(Guid tripId, Guid userId)
82 + => _uow.TripParticipants.IsParticipantAsync(tripId, userId);
83 +
84 + public Task<bool> IsOrganizerAsync(Guid tripId, Guid userId)
85 + => _uow.TripParticipants.IsOrganizerAsync(tripId, userId);
86 +
87 + public async Task<TripBllDto?> UpdateAsync(TripBllDto trip, Guid userId)
88 + {
89 + if (!await _uow.TripParticipants.IsOrganizerAsync(trip.Id, userId)) return null;
90 +
91 + var existing = await _uow.Trips.GetByIdAsync(trip.Id);
92 + if (existing == null) return null;
93 +
94 + existing.Name = trip.Name;
95 + existing.Description = trip.Description;
96 + existing.Destination = trip.Destination;
97 + existing.StartDate = trip.StartDate;
98 + existing.EndDate = trip.EndDate;
99 + existing.DefaultCurrencyId = trip.DefaultCurrencyId;
100 + existing.Status = trip.Status;
101 +
102 + _uow.Trips.Update(existing);
103 + await _uow.SaveChangesAsync();
104 + return TripBllDtoFactory.Create(existing);
105 + }
106 +
107 + public async Task<bool> DeleteAsync(Guid tripId, Guid userId)
108 + {
109 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) return false;
110 +
111 + var trip = await _uow.Trips.GetByIdAsync(tripId);
112 + if (trip == null) return false;
113 +
114 + await _uow.Trips.RemoveAsync(tripId);
115 + await _uow.SaveChangesAsync();
116 + return true;
117 + }
118 +
119 + public async Task<List<TripParticipantBllDto>> GetParticipantsAsync(Guid tripId, Guid userId)
120 + {
121 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
122 + return new List<TripParticipantBllDto>();
123 + var participants = await _uow.TripParticipants.GetByTripIdAsync(tripId);
124 + return TripParticipantBllDtoFactory.CreateList(participants);
125 + }
126 +
127 + public Task<List<TripParticipantBllDto>> GetParticipantsForIndexAsync(Guid tripId, Guid userId)
128 + => GetParticipantsAsync(tripId, userId);
129 +
130 + public async Task<TripParticipantBllDto?> GetParticipantByIdAsync(Guid participantId)
131 + {
132 + var participant = await _uow.TripParticipants.GetByIdAsync(participantId);
133 + return participant == null ? null : TripParticipantBllDtoFactory.Create(participant);
134 + }
135 +
136 + public async Task<(bool success, string? errorCode)> RemoveParticipantAsync(Guid tripId, Guid participantUserId, Guid currentUserId)
137 + {
138 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, currentUserId))
139 + return (false, "forbidden");
140 +
141 + var participants = (await _uow.TripParticipants.GetByTripIdAsync(tripId)).ToList();
142 + var participant = participants.FirstOrDefault(tp => tp.UserId == participantUserId);
143 + if (participant == null) return (false, "notfound");
144 +
145 + if (participant.Role == EParticipantRole.Organizer)
146 + return (false, "organizer");
147 + if (participant.UserId == currentUserId)
148 + return (false, "self");
149 +
150 + participant.IsActive = false;
151 + participant.LeftAt = DateTime.UtcNow;
152 + _uow.TripParticipants.Update(participant);
153 + await _uow.SaveChangesAsync();
154 +
155 + return (true, null);
156 + }
157 +
158 + public async Task<bool> RemoveParticipantByIdAsync(Guid tripId, Guid participantId, Guid currentUserId)
159 + {
160 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, currentUserId))
161 + return false;
162 +
163 + var participant = await _uow.TripParticipants.GetByIdAsync(participantId);
164 + if (participant == null || participant.TripId != tripId) return false;
165 +
166 + // Cannot remove yourself
167 + if (participant.UserId == currentUserId) return false;
168 +
169 + participant.IsActive = false;
170 + participant.LeftAt = DateTime.UtcNow;
171 + _uow.TripParticipants.Update(participant);
172 + await _uow.SaveChangesAsync();
173 +
174 + return true;
175 + }
176 +
177 + public async Task<(bool success, string? errorCode)> FinalizeTripAsync(Guid tripId, Guid userId)
178 + {
179 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId))
180 + return (false, "forbidden");
181 +
182 + var trip = await _uow.Trips.GetByIdAsync(tripId);
183 + if (trip == null) return (false, "notfound");
184 + if (trip.Status != ETripStatus.Active) return (false, "badstatus");
185 +
186 + trip.Status = ETripStatus.Finalizing;
187 + _uow.Trips.Update(trip);
188 + await _uow.SaveChangesAsync();
189 +
190 + // Auto-create settlement plan
191 + var createdPlan = await _settlementService.CalculateSettlementAsync(tripId, userId);
192 +
193 + // If nobody owes anyone, skip straight to Settled.
194 + if (createdPlan == null)
195 + {
196 + trip.Status = ETripStatus.Settled;
197 + await _uow.SaveChangesAsync();
198 + }
199 +
200 + return (true, null);
201 + }
202 +
203 + public async Task<(bool success, string? errorCode)> ReopenTripAsync(Guid tripId, Guid userId)
204 + {
205 + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId))
206 + return (false, "forbidden");
207 +
208 + var trip = await _uow.Trips.GetByIdAsync(tripId);
209 + if (trip == null) return (false, "notfound");
210 + if (trip.Status != ETripStatus.Finalizing && trip.Status != ETripStatus.Settled)
211 + return (false, "badstatus");
212 +
213 + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId);
214 + if (plan?.Payments != null && plan.Payments.Any(p => p.Status == EPaymentStatus.Confirmed))
215 + return (false, "payments-confirmed");
216 +
217 + if (plan != null)
218 + {
219 + await _uow.SettlementPlans.DeletePlanWithPaymentsAsync(plan.Id);
220 + }
221 +
222 + var tripToUpdate = await _uow.Trips.GetByIdAsync(tripId);
223 + if (tripToUpdate == null) return (false, "notfound");
224 + tripToUpdate.Status = ETripStatus.Active;
225 + _uow.Trips.Update(tripToUpdate);
226 + await _uow.SaveChangesAsync();
227 +
228 + return (true, null);
229 + }
230 +
231 + public async Task<List<CurrencyBllDto>> GetAllCurrenciesAsync()
232 + {
233 + var currencies = await _uow.GetRepository<Currency>().GetAllAsync();
234 + return currencies
235 + .OrderBy(c => c.Code)
236 + .Select(CurrencyBllDtoFactory.Create)
237 + .ToList();
238 + }
239 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/WishlistService.cs +148 −0
@@ -0,0 +1,148 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Mappers;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using SplitApp.WebApp.Application.Contracts;
8 +
9 +namespace SplitApp.WebApp.Application.Services;
10 +
11 +public class WishlistService : IWishlistService
12 +{
13 + private readonly IAppUnitOfWork _uow;
14 +
15 + public WishlistService(IAppUnitOfWork uow)
16 + {
17 + _uow = uow;
18 + }
19 +
20 + public async Task<List<TripWishlistItemBllDto>> GetByTripIdAsync(Guid tripId, Guid userId)
21 + {
22 + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId))
23 + return new List<TripWishlistItemBllDto>();
24 + var items = await _uow.TripWishlistItems.GetByTripIdAsync(tripId);
25 + return WishlistBllDtoFactory.CreateList(items);
26 + }
27 +
28 + public async Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id, Guid userId)
29 + {
30 + var item = await _uow.TripWishlistItems.GetByIdAsync(id);
31 + if (item == null) return null;
32 + if (!await _uow.TripParticipants.IsParticipantAsync(item.TripId, userId)) return null;
33 + return WishlistBllDtoFactory.Create(item);
34 + }
35 +
36 + public async Task<TripWishlistItemBllDto?> GetByIdRawAsync(Guid id)
37 + {
38 + var item = await _uow.TripWishlistItems.GetByIdAsync(id);
39 + return item == null ? null : WishlistBllDtoFactory.Create(item);
40 + }
41 +
42 + public async Task<(TripWishlistItemBllDto? item, string? errorCode)> CreateAsync(TripWishlistItemBllDto item, Guid userId)
43 + {
44 + if (!await _uow.TripParticipants.IsParticipantAsync(item.TripId, userId))
45 + return (null, "forbidden");
46 +
47 + var entity = WishlistBllDtoFactory.ToEntity(item);
48 + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid();
49 + entity.AddedByUserId = userId;
50 + _uow.TripWishlistItems.Add(entity);
51 + await _uow.SaveChangesAsync();
52 +
53 + var reloaded = await _uow.TripWishlistItems.GetByIdAsync(entity.Id);
54 + return (reloaded == null ? null : WishlistBllDtoFactory.Create(reloaded), null);
55 + }
56 +
57 + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, TripWishlistItemBllDto incoming, Guid userId)
58 + {
59 + var existing = await _uow.TripWishlistItems.GetByIdAsync(id);
60 + if (existing == null) return (false, "notfound");
61 +
62 + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId))
63 + return (false, "forbidden");
64 +
65 + if (existing.AddedByUserId != userId) return (false, "forbidden");
66 +
67 + existing.Title = incoming.Title;
68 + existing.Description = incoming.Description;
69 + existing.Category = incoming.Category;
70 + existing.Priority = incoming.Priority;
71 + existing.EstimatedCost = incoming.EstimatedCost;
72 + existing.Url = incoming.Url;
73 + existing.Location = incoming.Location;
74 +
75 + _uow.TripWishlistItems.Update(existing);
76 + await _uow.SaveChangesAsync();
77 + return (true, null);
78 + }
79 +
80 + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId)
81 + {
82 + var existing = await _uow.TripWishlistItems.GetByIdAsync(id);
83 + if (existing == null) return (false, "notfound");
84 +
85 + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId))
86 + return (false, "forbidden");
87 +
88 + if (existing.AddedByUserId != userId) return (false, "forbidden");
89 +
90 + var voteRepo = _uow.GetRepository<TripWishlistVote>();
91 + var allVotes = (await voteRepo.GetAllAsync()).Where(v => v.WishlistItemId == id).ToList();
92 + foreach (var vote in allVotes)
93 + {
94 + await voteRepo.RemoveAsync(vote.Id);
95 + }
96 +
97 + await _uow.TripWishlistItems.RemoveAsync(id);
98 + await _uow.SaveChangesAsync();
99 + return (true, null);
100 + }
101 +
102 + public async Task<(bool success, string? errorCode)> ToggleVoteAsync(Guid id, Guid userId)
103 + {
104 + var existing = await _uow.TripWishlistItems.GetByIdAsync(id);
105 + if (existing == null) return (false, "notfound");
106 +
107 + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId))
108 + return (false, "forbidden");
109 +
110 + var voteRepo = _uow.GetRepository<TripWishlistVote>();
111 + var allVotes = (await voteRepo.GetAllAsync()).ToList();
112 + var existingVote = allVotes.FirstOrDefault(v => v.WishlistItemId == id && v.UserId == userId);
113 +
114 + if (existingVote != null)
115 + {
116 + await voteRepo.RemoveAsync(existingVote.Id);
117 + }
118 + else
119 + {
120 + voteRepo.Add(new TripWishlistVote
121 + {
122 + Id = Guid.NewGuid(),
123 + WishlistItemId = id,
124 + UserId = userId,
125 + IsInterested = true
126 + });
127 + }
128 +
129 + await _uow.SaveChangesAsync();
130 + return (true, null);
131 + }
132 +
133 + public async Task<(bool success, string? errorCode)> ToggleCompleteAsync(Guid id, Guid userId)
134 + {
135 + var existing = await _uow.TripWishlistItems.GetByIdAsync(id);
136 + if (existing == null) return (false, "notfound");
137 +
138 + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId))
139 + return (false, "forbidden");
140 +
141 + existing.IsCompleted = !existing.IsCompleted;
142 + existing.CompletedAt = existing.IsCompleted ? DateTime.UtcNow : null;
143 +
144 + _uow.TripWishlistItems.Update(existing);
145 + await _uow.SaveChangesAsync();
146 + return (true, null);
147 + }
148 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/Dtos/AccountDtos.cs +43 −0
@@ -0,0 +1,43 @@
1 +namespace SplitApp.WebApp.Application.UsersService.Dtos;
2 +
3 +// Wire format mirrors SplitApp.Modules.Users.Api.Dto.v1 (intentional — WebApp must not
4 +// project-reference Users.Api). Serialization names match the JSON the service emits.
5 +
6 +public class LoginRequestPayload
7 +{
8 + public string Email { get; set; } = "";
9 + public string Password { get; set; } = "";
10 +}
11 +
12 +public class RegisterRequestPayload
13 +{
14 + public string Email { get; set; } = "";
15 + public string Password { get; set; } = "";
16 + public string Firstname { get; set; } = "";
17 + public string Lastname { get; set; } = "";
18 +}
19 +
20 +public class RefreshRequestPayload
21 +{
22 + public string Jwt { get; set; } = "";
23 + public string RefreshToken { get; set; } = "";
24 +}
25 +
26 +public class LogoutRequestPayload
27 +{
28 + public string RefreshToken { get; set; } = "";
29 +}
30 +
31 +public class JwtResponsePayload
32 +{
33 + public string Jwt { get; set; } = "";
34 + public string RefreshToken { get; set; } = "";
35 + public string FirstName { get; set; } = "";
36 + public string LastName { get; set; } = "";
37 +}
38 +
39 +public class ServiceErrorPayload
40 +{
41 + public string? Error { get; set; }
42 + public IEnumerable<string>? Errors { get; set; }
43 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/Dtos/AdminDtos.cs +28 −0
@@ -0,0 +1,28 @@
1 +namespace SplitApp.WebApp.Application.UsersService.Dtos;
2 +
3 +public record AdminUserListItem(
4 + Guid Id,
5 + string Email,
6 + string FirstName,
7 + string LastName,
8 + string[] Roles);
9 +
10 +public record AdminUserDetails(
11 + Guid Id,
12 + string Email,
13 + string FirstName,
14 + string LastName,
15 + string[] Roles);
16 +
17 +public class AdminUserUpdatePayload
18 +{
19 + public string FirstName { get; set; } = "";
20 + public string LastName { get; set; } = "";
21 +}
22 +
23 +public class AdminUserRolesUpdatePayload
24 +{
25 + public string[] RoleNames { get; set; } = Array.Empty<string>();
26 +}
27 +
28 +public record AdminRoleInfo(string Name);
added SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/IUsersServiceClient.cs +37 −0
@@ -0,0 +1,37 @@
1 +using SplitApp.WebApp.Application.UsersService.Dtos;
2 +
3 +namespace SplitApp.WebApp.Application.UsersService;
4 +
5 +public interface IUsersServiceClient
6 +{
7 + Task<UsersServiceResult<JwtResponsePayload>> LoginAsync(
8 + string email, string password, CancellationToken ct = default);
9 +
10 + Task<UsersServiceResult<JwtResponsePayload>> RegisterAsync(
11 + string email, string password, string firstName, string lastName, CancellationToken ct = default);
12 +
13 + Task<UsersServiceResult<JwtResponsePayload>> RefreshAsync(
14 + string jwt, string refreshToken, CancellationToken ct = default);
15 +
16 + Task LogoutAsync(string refreshToken, CancellationToken ct = default);
17 +
18 + // Admin
19 + Task<IReadOnlyList<AdminUserListItem>> ListUsersAsync(CancellationToken ct = default);
20 + Task<AdminUserDetails?> GetUserAsync(Guid id, CancellationToken ct = default);
21 + Task<AdminUserDetails?> UpdateUserAsync(Guid id, string firstName, string lastName, CancellationToken ct = default);
22 + Task<bool> DeleteUserAsync(Guid id, CancellationToken ct = default);
23 + Task<IReadOnlyList<string>> GetUserRolesAsync(Guid id, CancellationToken ct = default);
24 + Task SetUserRolesAsync(Guid id, IReadOnlyCollection<string> roleNames, CancellationToken ct = default);
25 + Task<IReadOnlyList<AdminRoleInfo>> ListRolesAsync(CancellationToken ct = default);
26 +}
27 +
28 +/// <summary>Outcome of a Users-service call where business-level errors (4xx) need to be surfaced to MVC views.</summary>
29 +public class UsersServiceResult<T>
30 +{
31 + public bool Success { get; init; }
32 + public T? Value { get; init; }
33 + public string? Error { get; init; }
34 +
35 + public static UsersServiceResult<T> Ok(T value) => new() { Success = true, Value = value };
36 + public static UsersServiceResult<T> Fail(string error) => new() { Success = false, Error = error };
37 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/JwtForwardingHandler.cs +47 −0
@@ -0,0 +1,47 @@
1 +using System.Net.Http.Headers;
2 +
3 +namespace SplitApp.WebApp.Application.UsersService;
4 +
5 +/// <summary>
6 +/// DelegatingHandler used by the <see cref="IUsersServiceClient"/> typed HttpClient.
7 +/// Forwards the inbound JWT (cookie or Authorization header) on the outbound call so
8 +/// the Users service can enforce <c>[Authorize]</c> + role checks on admin endpoints.
9 +/// </summary>
10 +public class JwtForwardingHandler : DelegatingHandler
11 +{
12 + private readonly IHttpContextAccessor _httpContext;
13 +
14 + public JwtForwardingHandler(IHttpContextAccessor httpContext) => _httpContext = httpContext;
15 +
16 + protected override Task<HttpResponseMessage> SendAsync(
17 + HttpRequestMessage request, CancellationToken cancellationToken)
18 + {
19 + if (request.Headers.Authorization is null)
20 + {
21 + var ctx = _httpContext.HttpContext;
22 + string? token = null;
23 +
24 + if (ctx is not null)
25 + {
26 + // 1) Authorization header from the inbound request
27 + if (ctx.Request.Headers.TryGetValue("Authorization", out var authHdr))
28 + {
29 + var value = authHdr.ToString();
30 + if (value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
31 + {
32 + token = value["Bearer ".Length..];
33 + }
34 + }
35 + // 2) Fallback: jwt cookie (MVC views path)
36 + token ??= ctx.Request.Cookies["jwt"];
37 + }
38 +
39 + if (!string.IsNullOrEmpty(token))
40 + {
41 + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
42 + }
43 + }
44 +
45 + return base.SendAsync(request, cancellationToken);
46 + }
47 +}
added SplitApp.Modular/src/SplitApp.WebApp/Application/UsersService/UsersServiceClient.cs +141 −0
@@ -0,0 +1,141 @@
1 +using System.Net;
2 +using System.Net.Http.Json;
3 +using SplitApp.WebApp.Application.UsersService.Dtos;
4 +
5 +namespace SplitApp.WebApp.Application.UsersService;
6 +
7 +public class UsersServiceClient : IUsersServiceClient
8 +{
9 + private readonly HttpClient _http;
10 +
11 + public UsersServiceClient(HttpClient http) => _http = http;
12 +
13 + public async Task<UsersServiceResult<JwtResponsePayload>> LoginAsync(
14 + string email, string password, CancellationToken ct = default)
15 + {
16 + var resp = await _http.PostAsJsonAsync(
17 + "api/v1/identity/account/login",
18 + new LoginRequestPayload { Email = email, Password = password },
19 + ct);
20 + return await ParseJwtResponseAsync(resp, ct);
21 + }
22 +
23 + public async Task<UsersServiceResult<JwtResponsePayload>> RegisterAsync(
24 + string email, string password, string firstName, string lastName, CancellationToken ct = default)
25 + {
26 + var resp = await _http.PostAsJsonAsync(
27 + "api/v1/identity/account/register",
28 + new RegisterRequestPayload
29 + {
30 + Email = email,
31 + Password = password,
32 + Firstname = firstName,
33 + Lastname = lastName,
34 + },
35 + ct);
36 + return await ParseJwtResponseAsync(resp, ct);
37 + }
38 +
39 + public async Task<UsersServiceResult<JwtResponsePayload>> RefreshAsync(
40 + string jwt, string refreshToken, CancellationToken ct = default)
41 + {
42 + var resp = await _http.PostAsJsonAsync(
43 + "api/v1/identity/account/refreshtokendata",
44 + new RefreshRequestPayload { Jwt = jwt, RefreshToken = refreshToken },
45 + ct);
46 + return await ParseJwtResponseAsync(resp, ct);
47 + }
48 +
49 + public async Task LogoutAsync(string refreshToken, CancellationToken ct = default)
50 + {
51 + var resp = await _http.PostAsJsonAsync(
52 + "api/v1/identity/account/logout",
53 + new LogoutRequestPayload { RefreshToken = refreshToken },
54 + ct);
55 + // Logout is best-effort: even if the service is unreachable, the MVC layer will still
56 + // clear local cookies. So we swallow non-2xx here.
57 + _ = resp;
58 + }
59 +
60 + public async Task<IReadOnlyList<AdminUserListItem>> ListUsersAsync(CancellationToken ct = default)
61 + {
62 + var list = await _http.GetFromJsonAsync<List<AdminUserListItem>>("api/v1/identity/admin/users", ct);
63 + return list ?? new List<AdminUserListItem>();
64 + }
65 +
66 + public async Task<AdminUserDetails?> GetUserAsync(Guid id, CancellationToken ct = default)
67 + {
68 + var resp = await _http.GetAsync($"api/v1/identity/admin/users/{id}", ct);
69 + if (resp.StatusCode == HttpStatusCode.NotFound) return null;
70 + resp.EnsureSuccessStatusCode();
71 + return await resp.Content.ReadFromJsonAsync<AdminUserDetails>(cancellationToken: ct);
72 + }
73 +
74 + public async Task<AdminUserDetails?> UpdateUserAsync(
75 + Guid id, string firstName, string lastName, CancellationToken ct = default)
76 + {
77 + var resp = await _http.PutAsJsonAsync(
78 + $"api/v1/identity/admin/users/{id}",
79 + new AdminUserUpdatePayload { FirstName = firstName, LastName = lastName },
80 + ct);
81 + if (resp.StatusCode == HttpStatusCode.NotFound) return null;
82 + resp.EnsureSuccessStatusCode();
83 + return await resp.Content.ReadFromJsonAsync<AdminUserDetails>(cancellationToken: ct);
84 + }
85 +
86 + public async Task<bool> DeleteUserAsync(Guid id, CancellationToken ct = default)
87 + {
88 + var resp = await _http.DeleteAsync($"api/v1/identity/admin/users/{id}", ct);
89 + if (resp.StatusCode == HttpStatusCode.NotFound) return false;
90 + resp.EnsureSuccessStatusCode();
91 + return true;
92 + }
93 +
94 + public async Task<IReadOnlyList<string>> GetUserRolesAsync(Guid id, CancellationToken ct = default)
95 + {
96 + var roles = await _http.GetFromJsonAsync<List<string>>(
97 + $"api/v1/identity/admin/users/{id}/roles", ct);
98 + return roles ?? new List<string>();
99 + }
100 +
101 + public async Task SetUserRolesAsync(
102 + Guid id, IReadOnlyCollection<string> roleNames, CancellationToken ct = default)
103 + {
104 + var resp = await _http.PutAsJsonAsync(
105 + $"api/v1/identity/admin/users/{id}/roles",
106 + new AdminUserRolesUpdatePayload { RoleNames = roleNames.ToArray() },
107 + ct);
108 + resp.EnsureSuccessStatusCode();
109 + }
110 +
111 + public async Task<IReadOnlyList<AdminRoleInfo>> ListRolesAsync(CancellationToken ct = default)
112 + {
113 + var roles = await _http.GetFromJsonAsync<List<AdminRoleInfo>>(
114 + "api/v1/identity/admin/roles", ct);
115 + return roles ?? new List<AdminRoleInfo>();
116 + }
117 +
118 + private static async Task<UsersServiceResult<JwtResponsePayload>> ParseJwtResponseAsync(
119 + HttpResponseMessage resp, CancellationToken ct)
120 + {
121 + if (resp.IsSuccessStatusCode)
122 + {
123 + var payload = await resp.Content.ReadFromJsonAsync<JwtResponsePayload>(cancellationToken: ct);
124 + return payload is null
125 + ? UsersServiceResult<JwtResponsePayload>.Fail("Empty response from users service")
126 + : UsersServiceResult<JwtResponsePayload>.Ok(payload);
127 + }
128 +
129 + string? err = null;
130 + try
131 + {
132 + var body = await resp.Content.ReadFromJsonAsync<ServiceErrorPayload>(cancellationToken: ct);
133 + err = body?.Error ?? body?.Errors?.FirstOrDefault();
134 + }
135 + catch
136 + {
137 + // body wasn't JSON; fall through
138 + }
139 + return UsersServiceResult<JwtResponsePayload>.Fail(err ?? $"Users service returned {(int)resp.StatusCode}");
140 + }
141 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +146 −0
@@ -0,0 +1,146 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.AspNetCore.Mvc.Rendering;
10 +using Microsoft.EntityFrameworkCore;
11 +using Microsoft.Extensions.Localization;
12 +using SplitApp.WebApp.Areas.Admin.Models;
13 +
14 +namespace SplitApp.WebApp.Areas.Admin.Controllers
15 +{
16 + [Area("Admin")]
17 + [Authorize(Roles = "admin")]
18 + public class BudgetCategoriesController : Controller
19 + {
20 + private readonly IBudgetCategoryAdminService _service;
21 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
22 +
23 + public BudgetCategoriesController(IBudgetCategoryAdminService service,
24 + IStringLocalizer<App.Resources.Views.Shared> l)
25 + {
26 + _service = service;
27 + _l = l;
28 + }
29 +
30 + public async Task<IActionResult> Index(Guid? tripId, string? search)
31 + {
32 + var vm = new AdminBudgetCategoryIndexViewModel
33 + {
34 + Title = _l["Budget Categories"].Value,
35 + Items = await _service.GetAllAsync(tripId, search),
36 + Trips = await _service.GetAllTripsAsync(),
37 + CurrentTripId = tripId,
38 + CurrentSearch = search
39 + };
40 + return View(vm);
41 + }
42 +
43 + public async Task<IActionResult> Details(Guid? id)
44 + {
45 + if (id == null) return NotFound();
46 + var entity = await _service.GetByIdAsync(id.Value);
47 + if (entity == null) return NotFound();
48 +
49 + return View(new AdminDetailsViewModel<BudgetCategoryBllDto>
50 + {
51 + Title = _l["Budget Category details"].Value,
52 + Item = entity
53 + });
54 + }
55 +
56 + public async Task<IActionResult> Create()
57 + {
58 + var vm = new AdminBudgetCategoryFormViewModel
59 + {
60 + Title = _l["New budget category"].Value,
61 + TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name")
62 + };
63 + return View(vm);
64 + }
65 +
66 + [HttpPost]
67 + [ValidateAntiForgeryToken]
68 + public async Task<IActionResult> Create(AdminBudgetCategoryFormViewModel vm, string? nameEn, string? nameEt)
69 + {
70 + ModelState.Remove("BudgetCategory.Name");
71 +
72 + if (ModelState.IsValid)
73 + {
74 + await _service.CreateAsync(vm.BudgetCategory, nameEn, nameEt);
75 + return RedirectToAction(nameof(Index));
76 + }
77 +
78 + vm.Title = _l["New budget category"].Value;
79 + vm.TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", vm.BudgetCategory.TripId);
80 + return View(vm);
81 + }
82 +
83 + public async Task<IActionResult> Edit(Guid? id)
84 + {
85 + if (id == null) return NotFound();
86 + var entity = await _service.GetByIdAsync(id.Value);
87 + if (entity == null) return NotFound();
88 +
89 + var vm = new AdminBudgetCategoryFormViewModel
90 + {
91 + Title = _l["Edit budget category"].Value,
92 + BudgetCategory = entity,
93 + TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", entity.TripId)
94 + };
95 + return View(vm);
96 + }
97 +
98 + [HttpPost]
99 + [ValidateAntiForgeryToken]
100 + public async Task<IActionResult> Edit(Guid id, AdminBudgetCategoryFormViewModel vm, string? nameEn, string? nameEt)
101 + {
102 + if (id != vm.BudgetCategory.Id) return NotFound();
103 +
104 + ModelState.Remove("BudgetCategory.Name");
105 +
106 + if (ModelState.IsValid)
107 + {
108 + try
109 + {
110 + await _service.UpdateAsync(vm.BudgetCategory, nameEn, nameEt);
111 + }
112 + catch (DbUpdateConcurrencyException)
113 + {
114 + if (!await _service.ExistsAsync(vm.BudgetCategory.Id)) return NotFound();
115 + throw;
116 + }
117 + return RedirectToAction(nameof(Index));
118 + }
119 +
120 + vm.Title = _l["Edit budget category"].Value;
121 + vm.TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", vm.BudgetCategory.TripId);
122 + return View(vm);
123 + }
124 +
125 + public async Task<IActionResult> Delete(Guid? id)
126 + {
127 + if (id == null) return NotFound();
128 + var entity = await _service.GetByIdAsync(id.Value);
129 + if (entity == null) return NotFound();
130 +
131 + return View(new AdminDeleteViewModel<BudgetCategoryBllDto>
132 + {
133 + Title = _l["Delete budget category"].Value,
134 + Item = entity
135 + });
136 + }
137 +
138 + [HttpPost, ActionName("Delete")]
139 + [ValidateAntiForgeryToken]
140 + public async Task<IActionResult> DeleteConfirmed(Guid id)
141 + {
142 + await _service.DeleteAsync(id);
143 + return RedirectToAction(nameof(Index));
144 + }
145 + }
146 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/CurrenciesController.cs +133 −0
@@ -0,0 +1,133 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.EntityFrameworkCore;
10 +using Microsoft.Extensions.Localization;
11 +using SplitApp.WebApp.Areas.Admin.Models;
12 +
13 +namespace SplitApp.WebApp.Areas.Admin.Controllers
14 +{
15 + [Area("Admin")]
16 + [Authorize(Roles = "admin")]
17 + public class CurrenciesController : Controller
18 + {
19 + private readonly ICurrencyAdminService _service;
20 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
21 +
22 + public CurrenciesController(ICurrencyAdminService service,
23 + IStringLocalizer<App.Resources.Views.Shared> l)
24 + {
25 + _service = service;
26 + _l = l;
27 + }
28 +
29 + public async Task<IActionResult> Index(string? search)
30 + {
31 + return View(new AdminCurrencyIndexViewModel
32 + {
33 + Title = _l["Currencies"].Value,
34 + Items = await _service.GetAllAsync(search),
35 + CurrentSearch = search
36 + });
37 + }
38 +
39 + public async Task<IActionResult> Details(Guid? id)
40 + {
41 + if (id == null) return NotFound();
42 + var currency = await _service.GetByIdAsync(id.Value);
43 + if (currency == null) return NotFound();
44 +
45 + return View(new AdminDetailsViewModel<CurrencyBllDto>
46 + {
47 + Title = _l["Currency details"].Value,
48 + Item = currency
49 + });
50 + }
51 +
52 + public IActionResult Create()
53 + {
54 + return View(new AdminCurrencyFormViewModel { Title = _l["New currency"].Value });
55 + }
56 +
57 + [HttpPost]
58 + [ValidateAntiForgeryToken]
59 + public async Task<IActionResult> Create(AdminCurrencyFormViewModel vm, string? nameEn, string? nameEt)
60 + {
61 + ModelState.Remove("Currency.Name");
62 +
63 + if (ModelState.IsValid)
64 + {
65 + await _service.CreateAsync(vm.Currency, nameEn, nameEt);
66 + return RedirectToAction(nameof(Index));
67 + }
68 +
69 + vm.Title = _l["New currency"].Value;
70 + return View(vm);
71 + }
72 +
73 + public async Task<IActionResult> Edit(Guid? id)
74 + {
75 + if (id == null) return NotFound();
76 + var currency = await _service.GetByIdAsync(id.Value);
77 + if (currency == null) return NotFound();
78 +
79 + return View(new AdminCurrencyFormViewModel
80 + {
81 + Title = _l["Edit currency"].Value,
82 + Currency = currency
83 + });
84 + }
85 +
86 + [HttpPost]
87 + [ValidateAntiForgeryToken]
88 + public async Task<IActionResult> Edit(Guid id, AdminCurrencyFormViewModel vm, string? nameEn, string? nameEt)
89 + {
90 + if (id != vm.Currency.Id) return NotFound();
91 +
92 + ModelState.Remove("Currency.Name");
93 +
94 + if (ModelState.IsValid)
95 + {
96 + try
97 + {
98 + await _service.UpdateAsync(vm.Currency, nameEn, nameEt);
99 + }
100 + catch (DbUpdateConcurrencyException)
101 + {
102 + if (!await _service.ExistsAsync(vm.Currency.Id)) return NotFound();
103 + throw;
104 + }
105 + return RedirectToAction(nameof(Index));
106 + }
107 +
108 + vm.Title = _l["Edit currency"].Value;
109 + return View(vm);
110 + }
111 +
112 + public async Task<IActionResult> Delete(Guid? id)
113 + {
114 + if (id == null) return NotFound();
115 + var currency = await _service.GetByIdAsync(id.Value);
116 + if (currency == null) return NotFound();
117 +
118 + return View(new AdminDeleteViewModel<CurrencyBllDto>
119 + {
120 + Title = _l["Delete currency"].Value,
121 + Item = currency
122 + });
123 + }
124 +
125 + [HttpPost, ActionName("Delete")]
126 + [ValidateAntiForgeryToken]
127 + public async Task<IActionResult> DeleteConfirmed(Guid id)
128 + {
129 + await _service.DeleteAsync(id);
130 + return RedirectToAction(nameof(Index));
131 + }
132 + }
133 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/DashboardController.cs +88 −0
@@ -0,0 +1,88 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using Microsoft.AspNetCore.Authorization;
4 +using Microsoft.AspNetCore.Mvc;
5 +using Microsoft.Extensions.Localization;
6 +using SplitApp.WebApp.Areas.Admin.Models;
7 +
8 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
9 +
10 +[Area("Admin")]
11 +[Authorize(Roles = "admin")]
12 +public class DashboardController : Controller
13 +{
14 + private readonly IAdminStatsService _statsService;
15 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
16 +
17 + public DashboardController(IAdminStatsService statsService,
18 + IStringLocalizer<App.Resources.Views.Shared> l)
19 + {
20 + _statsService = statsService;
21 + _l = l;
22 + }
23 +
24 + public async Task<IActionResult> Index()
25 + {
26 + var data = await _statsService.GetDashboardStatsAsync();
27 +
28 + var vm = new AdminDashboardViewModel
29 + {
30 + Title = _l["Dashboard"].Value,
31 +
32 + TripCount = data.TripCount,
33 + UserCount = data.UserCount,
34 + ExpenseCount = data.ExpenseCount,
35 + CategoryCount = data.CategoryCount,
36 + SettlementCount = data.SettlementCount,
37 + WishlistCount = data.WishlistCount,
38 + PollCount = data.PollCount,
39 + InvitationCount = data.InvitationCount,
40 + CurrencyCount = data.CurrencyCount,
41 + ParticipantCount = data.ParticipantCount,
42 +
43 + ActiveTrips = data.ActiveTrips,
44 + SettledTrips = data.SettledTrips,
45 + ArchivedTrips = data.ArchivedTrips,
46 + TotalExpenseAmount = data.TotalExpenseAmount,
47 + PendingSettlements = data.PendingSettlements,
48 + InProgressSettlements = data.InProgressSettlements,
49 + CompletedSettlements = data.CompletedSettlements,
50 + PendingInvitations = data.PendingInvitations,
51 + PendingPayments = data.PendingPayments,
52 + MarkedPaidPayments = data.MarkedPaidPayments,
53 +
54 + RecentTrips = data.RecentTrips,
55 + RecentExpenses = data.RecentExpenses,
56 + RecentUsers = data.RecentUsers,
57 +
58 + TopActiveTrips = data.TopActiveTrips.Select(t => new TopActiveTripItem
59 + {
60 + Trip = t.Trip,
61 + ParticipantCount = t.ParticipantCount,
62 + ExpenseSum = t.ExpenseSum,
63 + ExpenseCount = t.ExpenseCount
64 + }).ToList(),
65 + BiggestExpenses = data.BiggestExpenses,
66 + NewUsersLast7Days = data.NewUsersLast7Days,
67 + NewUsersLast30Days = data.NewUsersLast30Days,
68 + TopActiveUsers = data.TopActiveUsers.Select(u => new TopActiveUserItem
69 + {
70 + UserId = u.UserId,
71 + Email = u.Email,
72 + FullName = u.FullName,
73 + ExpenseCount = u.ExpenseCount,
74 + TotalAmount = u.TotalAmount
75 + }).ToList(),
76 + ActivityFeed = data.ActivityFeed.Select(a => new ActivityFeedItem
77 + {
78 + Type = a.Type,
79 + Message = _l[a.MessageKey, a.MessageArgs].Value,
80 + Date = a.Date,
81 + IconCssClass = a.IconCssClass,
82 + BadgeCssClass = a.BadgeCssClass
83 + }).ToList()
84 + };
85 +
86 + return View(vm);
87 + }
88 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/ExpensesController.cs +148 −0
@@ -0,0 +1,148 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.AspNetCore.Mvc.Rendering;
10 +using Microsoft.EntityFrameworkCore;
11 +using Microsoft.Extensions.Localization;
12 +using SplitApp.WebApp.Areas.Admin.Models;
13 +
14 +namespace SplitApp.WebApp.Areas.Admin.Controllers
15 +{
16 + [Area("Admin")]
17 + [Authorize(Roles = "admin")]
18 + public class ExpensesController : Controller
19 + {
20 + private readonly IExpenseAdminService _service;
21 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
22 +
23 + public ExpensesController(IExpenseAdminService service, IStringLocalizer<App.Resources.Views.Shared> l)
24 + {
25 + _service = service;
26 + _l = l;
27 + }
28 +
29 + private async Task PopulateSelectListsAsync(AdminExpenseFormViewModel vm)
30 + {
31 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Expense.TripId);
32 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Expense.PaidByUserId);
33 + vm.CurrencyList = new SelectList(await _service.GetCurrenciesAsync(), "Id", "Code", vm.Expense.CurrencyId);
34 + vm.BudgetCategoryList = new SelectList(await _service.GetBudgetCategoriesAsync(), "Id", "Name", vm.Expense.BudgetCategoryId);
35 + }
36 +
37 + public async Task<IActionResult> Index(Guid? tripId, string? search)
38 + {
39 + var expenses = await _service.GetAllAsync(tripId, search);
40 + var trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList();
41 +
42 + return View(new AdminExpenseIndexViewModel
43 + {
44 + Title = _l["Expenses"].Value,
45 + Items = expenses.OrderByDescending(e => e.ExpenseDate).ToList(),
46 + Trips = trips,
47 + CurrentTripId = tripId,
48 + CurrentSearch = search
49 + });
50 + }
51 +
52 + public async Task<IActionResult> Details(Guid? id)
53 + {
54 + if (id == null) return NotFound();
55 + var expense = await _service.GetByIdAsync(id.Value);
56 + if (expense == null) return NotFound();
57 +
58 + return View(new AdminDetailsViewModel<ExpenseBllDto>
59 + {
60 + Title = _l["Expense details"].Value,
61 + Item = expense
62 + });
63 + }
64 +
65 + public async Task<IActionResult> Create()
66 + {
67 + var vm = new AdminExpenseFormViewModel { Title = _l["New expense"].Value };
68 + await PopulateSelectListsAsync(vm);
69 + return View(vm);
70 + }
71 +
72 + [HttpPost]
73 + [ValidateAntiForgeryToken]
74 + public async Task<IActionResult> Create(AdminExpenseFormViewModel vm)
75 + {
76 + if (ModelState.IsValid)
77 + {
78 + await _service.CreateAsync(vm.Expense);
79 + return RedirectToAction(nameof(Index));
80 + }
81 +
82 + vm.Title = _l["New expense"].Value;
83 + await PopulateSelectListsAsync(vm);
84 + return View(vm);
85 + }
86 +
87 + public async Task<IActionResult> Edit(Guid? id)
88 + {
89 + if (id == null) return NotFound();
90 + var expense = await _service.GetByIdAsync(id.Value);
91 + if (expense == null) return NotFound();
92 +
93 + var vm = new AdminExpenseFormViewModel
94 + {
95 + Title = _l["Edit expense"].Value,
96 + Expense = expense
97 + };
98 + await PopulateSelectListsAsync(vm);
99 + return View(vm);
100 + }
101 +
102 + [HttpPost]
103 + [ValidateAntiForgeryToken]
104 + public async Task<IActionResult> Edit(Guid id, AdminExpenseFormViewModel vm)
105 + {
106 + if (id != vm.Expense.Id) return NotFound();
107 +
108 + if (ModelState.IsValid)
109 + {
110 + try
111 + {
112 + await _service.UpdateAsync(vm.Expense);
113 + }
114 + catch (DbUpdateConcurrencyException)
115 + {
116 + if (!await _service.ExistsAsync(vm.Expense.Id)) return NotFound();
117 + throw;
118 + }
119 + return RedirectToAction(nameof(Index));
120 + }
121 +
122 + vm.Title = _l["Edit expense"].Value;
123 + await PopulateSelectListsAsync(vm);
124 + return View(vm);
125 + }
126 +
127 + public async Task<IActionResult> Delete(Guid? id)
128 + {
129 + if (id == null) return NotFound();
130 + var expense = await _service.GetByIdAsync(id.Value);
131 + if (expense == null) return NotFound();
132 +
133 + return View(new AdminDeleteViewModel<ExpenseBllDto>
134 + {
135 + Title = _l["Delete expense"].Value,
136 + Item = expense
137 + });
138 + }
139 +
140 + [HttpPost, ActionName("Delete")]
141 + [ValidateAntiForgeryToken]
142 + public async Task<IActionResult> DeleteConfirmed(Guid id)
143 + {
144 + await _service.DeleteAsync(id);
145 + return RedirectToAction(nameof(Index));
146 + }
147 + }
148 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/InvitationsController.cs +157 −0
@@ -0,0 +1,157 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.Extensions.Localization;
10 +using SplitApp.WebApp.Areas.Admin.Models;
11 +
12 +namespace SplitApp.WebApp.Areas.Admin.Controllers
13 +{
14 + [Area("Admin")]
15 + [Authorize(Roles = "admin")]
16 + public class InvitationsController : Controller
17 + {
18 + private readonly IInvitationAdminService _service;
19 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
20 +
21 + public InvitationsController(IInvitationAdminService service,
22 + IStringLocalizer<App.Resources.Views.Shared> l)
23 + {
24 + _service = service;
25 + _l = l;
26 + }
27 +
28 + // GET: Admin/Invitations
29 + public async Task<IActionResult> Index(string? search)
30 + {
31 + var invitations = await _service.GetAllAsync(search);
32 +
33 + var vm = new AdminInvitationIndexViewModel
34 + {
35 + Title = _l["Invitations"].Value,
36 + Items = invitations.OrderByDescending(i => i.Id).ToList(),
37 + CurrentSearch = search
38 + };
39 + return View(vm);
40 + }
41 +
42 + // GET: Admin/Invitations/Details/5
43 + public async Task<IActionResult> Details(Guid? id)
44 + {
45 + if (id == null)
46 + {
47 + return NotFound();
48 + }
49 +
50 + var tripInvitation = await _service.GetByIdAsync(id.Value);
51 + if (tripInvitation == null)
52 + {
53 + return NotFound();
54 + }
55 +
56 + return View(new AdminDetailsViewModel<TripInvitationBllDto>
57 + {
58 + Title = _l["Invitation details"].Value,
59 + Item = tripInvitation
60 + });
61 + }
62 +
63 + public async Task<IActionResult> Create()
64 + {
65 + return View(new AdminInvitationFormViewModel
66 + {
67 + Title = _l["New invitation"].Value,
68 + Invitation = new TripInvitationBllDto
69 + {
70 + ExpiresAt = DateTime.UtcNow.AddDays(7),
71 + Status = EInvitationStatus.Pending
72 + },
73 + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name"),
74 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email")
75 + });
76 + }
77 +
78 + [HttpPost]
79 + [ValidateAntiForgeryToken]
80 + public async Task<IActionResult> Create(AdminInvitationFormViewModel vm)
81 + {
82 + if (ModelState.IsValid)
83 + {
84 + await _service.CreateAsync(vm.Invitation);
85 + return RedirectToAction(nameof(Index));
86 + }
87 +
88 + vm.Title = _l["New invitation"].Value;
89 + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Invitation.TripId);
90 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Invitation.InvitedByUserId);
91 + return View(vm);
92 + }
93 +
94 + public async Task<IActionResult> Edit(Guid? id)
95 + {
96 + if (id == null) return NotFound();
97 + var invitation = await _service.GetByIdAsync(id.Value);
98 + if (invitation == null) return NotFound();
99 +
100 + return View(new AdminInvitationFormViewModel
101 + {
102 + Title = _l["Edit invitation"].Value,
103 + Invitation = invitation,
104 + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", invitation.TripId),
105 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", invitation.InvitedByUserId)
106 + });
107 + }
108 +
109 + [HttpPost]
110 + [ValidateAntiForgeryToken]
111 + public async Task<IActionResult> Edit(Guid id, AdminInvitationFormViewModel vm)
112 + {
113 + if (id != vm.Invitation.Id) return NotFound();
114 +
115 + if (ModelState.IsValid)
116 + {
117 + await _service.UpdateAsync(vm.Invitation);
118 + return RedirectToAction(nameof(Index));
119 + }
120 +
121 + vm.Title = _l["Edit invitation"].Value;
122 + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Invitation.TripId);
123 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Invitation.InvitedByUserId);
124 + return View(vm);
125 + }
126 +
127 + // GET: Admin/Invitations/Delete/5
128 + public async Task<IActionResult> Delete(Guid? id)
129 + {
130 + if (id == null)
131 + {
132 + return NotFound();
133 + }
134 +
135 + var tripInvitation = await _service.GetByIdAsync(id.Value);
136 + if (tripInvitation == null)
137 + {
138 + return NotFound();
139 + }
140 +
141 + return View(new AdminDeleteViewModel<TripInvitationBllDto>
142 + {
143 + Title = _l["Delete invitation"].Value,
144 + Item = tripInvitation
145 + });
146 + }
147 +
148 + // POST: Admin/Invitations/Delete/5
149 + [HttpPost, ActionName("Delete")]
150 + [ValidateAntiForgeryToken]
151 + public async Task<IActionResult> DeleteConfirmed(Guid id)
152 + {
153 + await _service.DeleteAsync(id);
154 + return RedirectToAction(nameof(Index));
155 + }
156 + }
157 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/PollsController.cs +128 −0
@@ -0,0 +1,128 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.AspNetCore.Mvc.Rendering;
10 +using Microsoft.Extensions.Localization;
11 +using SplitApp.WebApp.Areas.Admin.Models;
12 +
13 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
14 +
15 +[Area("Admin")]
16 +[Authorize(Roles = "admin")]
17 +public class PollsController : Controller
18 +{
19 + private readonly IPollAdminService _service;
20 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
21 +
22 + public PollsController(IPollAdminService service, IStringLocalizer<App.Resources.Views.Shared> l)
23 + {
24 + _service = service;
25 + _l = l;
26 + }
27 +
28 + public async Task<IActionResult> Index(string? search)
29 + {
30 + var polls = await _service.GetAllAsync(search);
31 +
32 + var vm = new AdminPollIndexViewModel
33 + {
34 + Title = _l["Polls"].Value,
35 + Items = polls.OrderByDescending(p => p.Id).ToList(),
36 + CurrentSearch = search
37 + };
38 + return View(vm);
39 + }
40 +
41 + public async Task<IActionResult> Details(Guid id)
42 + {
43 + var poll = await _service.GetByIdAsync(id);
44 + if (poll == null) return NotFound();
45 +
46 + return View(new AdminDetailsViewModel<TripPollBllDto>
47 + {
48 + Title = _l["Poll details"].Value,
49 + Item = poll
50 + });
51 + }
52 +
53 + public async Task<IActionResult> Create()
54 + {
55 + var vm = new AdminPollFormViewModel
56 + {
57 + Title = _l["New poll"].Value,
58 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
59 + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name")
60 + };
61 + return View(vm);
62 + }
63 +
64 + [HttpPost]
65 + [ValidateAntiForgeryToken]
66 + public async Task<IActionResult> Create(AdminPollFormViewModel vm)
67 + {
68 + if (ModelState.IsValid)
69 + {
70 + await _service.CreateAsync(vm.Poll);
71 + return RedirectToAction(nameof(Index));
72 + }
73 + vm.Title = _l["New poll"].Value;
74 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name");
75 + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name");
76 + return View(vm);
77 + }
78 +
79 + public async Task<IActionResult> Edit(Guid id)
80 + {
81 + var poll = await _service.GetByIdAsync(id);
82 + if (poll == null) return NotFound();
83 + var vm = new AdminPollFormViewModel
84 + {
85 + Title = _l["Edit poll"].Value,
86 + Poll = poll,
87 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
88 + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name")
89 + };
90 + return View(vm);
91 + }
92 +
93 + [HttpPost]
94 + [ValidateAntiForgeryToken]
95 + public async Task<IActionResult> Edit(Guid id, AdminPollFormViewModel vm)
96 + {
97 + if (id != vm.Poll.Id) return NotFound();
98 + if (ModelState.IsValid)
99 + {
100 + await _service.UpdateAsync(vm.Poll);
101 + return RedirectToAction(nameof(Index));
102 + }
103 + vm.Title = _l["Edit poll"].Value;
104 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name");
105 + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name");
106 + return View(vm);
107 + }
108 +
109 + public async Task<IActionResult> Delete(Guid id)
110 + {
111 + var poll = await _service.GetByIdAsync(id);
112 + if (poll == null) return NotFound();
113 +
114 + return View(new AdminDeleteViewModel<TripPollBllDto>
115 + {
116 + Title = _l["Delete poll"].Value,
117 + Item = poll
118 + });
119 + }
120 +
121 + [HttpPost, ActionName("Delete")]
122 + [ValidateAntiForgeryToken]
123 + public async Task<IActionResult> DeleteConfirmed(Guid id)
124 + {
125 + await _service.DeleteAsync(id);
126 + return RedirectToAction(nameof(Index));
127 + }
128 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +152 −0
@@ -0,0 +1,152 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.Extensions.Localization;
10 +using SplitApp.WebApp.Areas.Admin.Models;
11 +
12 +namespace SplitApp.WebApp.Areas.Admin.Controllers
13 +{
14 + [Area("Admin")]
15 + [Authorize(Roles = "admin")]
16 + public class SettlementPaymentsController : Controller
17 + {
18 + private readonly ISettlementPaymentAdminService _service;
19 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
20 +
21 + public SettlementPaymentsController(ISettlementPaymentAdminService service,
22 + IStringLocalizer<App.Resources.Views.Shared> l)
23 + {
24 + _service = service;
25 + _l = l;
26 + }
27 +
28 + // GET: Admin/SettlementPayments
29 + public async Task<IActionResult> Index(string? search)
30 + {
31 + var payments = await _service.GetAllAsync(search);
32 +
33 + var vm = new AdminSettlementPaymentIndexViewModel
34 + {
35 + Title = _l["Settlement payments"].Value,
36 + Items = payments.OrderByDescending(s => s.Id).ToList(),
37 + CurrentSearch = search
38 + };
39 + return View(vm);
40 + }
41 +
42 + // GET: Admin/SettlementPayments/Details/5
43 + public async Task<IActionResult> Details(Guid? id)
44 + {
45 + if (id == null)
46 + {
47 + return NotFound();
48 + }
49 +
50 + var settlementPayment = await _service.GetByIdAsync(id.Value);
51 + if (settlementPayment == null)
52 + {
53 + return NotFound();
54 + }
55 +
56 + return View(new AdminDetailsViewModel<SettlementPaymentBllDto>
57 + {
58 + Title = _l["Settlement payment details"].Value,
59 + Item = settlementPayment
60 + });
61 + }
62 +
63 + public async Task<IActionResult> Create()
64 + {
65 + return View(new AdminSettlementPaymentFormViewModel
66 + {
67 + Title = _l["New settlement payment"].Value,
68 + SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id"),
69 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email")
70 + });
71 + }
72 +
73 + [HttpPost]
74 + [ValidateAntiForgeryToken]
75 + public async Task<IActionResult> Create(AdminSettlementPaymentFormViewModel vm)
76 + {
77 + if (ModelState.IsValid)
78 + {
79 + await _service.CreateAsync(vm.Payment);
80 + return RedirectToAction(nameof(Index));
81 + }
82 +
83 + vm.Title = _l["New settlement payment"].Value;
84 + vm.SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", vm.Payment.SettlementPlanId);
85 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Payment.FromUserId);
86 + return View(vm);
87 + }
88 +
89 + public async Task<IActionResult> Edit(Guid? id)
90 + {
91 + if (id == null) return NotFound();
92 + var payment = await _service.GetByIdAsync(id.Value);
93 + if (payment == null) return NotFound();
94 +
95 + return View(new AdminSettlementPaymentFormViewModel
96 + {
97 + Title = _l["Edit settlement payment"].Value,
98 + Payment = payment,
99 + SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", payment.SettlementPlanId),
100 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", payment.FromUserId)
101 + });
102 + }
103 +
104 + [HttpPost]
105 + [ValidateAntiForgeryToken]
106 + public async Task<IActionResult> Edit(Guid id, AdminSettlementPaymentFormViewModel vm)
107 + {
108 + if (id != vm.Payment.Id) return NotFound();
109 +
110 + if (ModelState.IsValid)
111 + {
112 + await _service.UpdateAsync(vm.Payment);
113 + return RedirectToAction(nameof(Index));
114 + }
115 +
116 + vm.Title = _l["Edit settlement payment"].Value;
117 + vm.SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", vm.Payment.SettlementPlanId);
118 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Payment.FromUserId);
119 + return View(vm);
120 + }
121 +
122 + // GET: Admin/SettlementPayments/Delete/5
123 + public async Task<IActionResult> Delete(Guid? id)
124 + {
125 + if (id == null)
126 + {
127 + return NotFound();
128 + }
129 +
130 + var settlementPayment = await _service.GetByIdAsync(id.Value);
131 + if (settlementPayment == null)
132 + {
133 + return NotFound();
134 + }
135 +
136 + return View(new AdminDeleteViewModel<SettlementPaymentBllDto>
137 + {
138 + Title = _l["Delete settlement payment"].Value,
139 + Item = settlementPayment
140 + });
141 + }
142 +
143 + // POST: Admin/SettlementPayments/Delete/5
144 + [HttpPost, ActionName("Delete")]
145 + [ValidateAntiForgeryToken]
146 + public async Task<IActionResult> DeleteConfirmed(Guid id)
147 + {
148 + await _service.DeleteAsync(id);
149 + return RedirectToAction(nameof(Index));
150 + }
151 + }
152 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPlansController.cs +186 −0
@@ -0,0 +1,186 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.AspNetCore.Mvc.Rendering;
10 +using Microsoft.EntityFrameworkCore;
11 +using Microsoft.Extensions.Localization;
12 +using SplitApp.WebApp.Areas.Admin.Models;
13 +
14 +namespace SplitApp.WebApp.Areas.Admin.Controllers
15 +{
16 + [Area("Admin")]
17 + [Authorize(Roles = "admin")]
18 + public class SettlementPlansController : Controller
19 + {
20 + private readonly ISettlementPlanAdminService _service;
21 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
22 +
23 + public SettlementPlansController(ISettlementPlanAdminService service,
24 + IStringLocalizer<App.Resources.Views.Shared> l)
25 + {
26 + _service = service;
27 + _l = l;
28 + }
29 +
30 + // GET: Admin/SettlementPlans
31 + public async Task<IActionResult> Index(Guid? tripId)
32 + {
33 + var plans = await _service.GetAllAsync(tripId);
34 +
35 + var vm = new AdminSettlementPlanIndexViewModel
36 + {
37 + Title = _l["Settlement plans"].Value,
38 + Items = plans.OrderByDescending(s => s.Id).ToList(),
39 + Trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(),
40 + CurrentTripId = tripId
41 + };
42 + return View(vm);
43 + }
44 +
45 + // GET: Admin/SettlementPlans/Details/5
46 + public async Task<IActionResult> Details(Guid? id)
47 + {
48 + if (id == null)
49 + {
50 + return NotFound();
51 + }
52 +
53 + var settlementPlan = await _service.GetByIdAsync(id.Value);
54 + if (settlementPlan == null)
55 + {
56 + return NotFound();
57 + }
58 +
59 + return View(new AdminDetailsViewModel<SettlementPlanBllDto>
60 + {
61 + Title = _l["Settlement plan details"].Value,
62 + Item = settlementPlan
63 + });
64 + }
65 +
66 + // GET: Admin/SettlementPlans/Create
67 + public async Task<IActionResult> Create()
68 + {
69 + var vm = new AdminSettlementPlanFormViewModel
70 + {
71 + Title = _l["New settlement plan"].Value,
72 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
73 + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email")
74 + };
75 + return View(vm);
76 + }
77 +
78 + // POST: Admin/SettlementPlans/Create
79 + [HttpPost]
80 + [ValidateAntiForgeryToken]
81 + public async Task<IActionResult> Create(AdminSettlementPlanFormViewModel vm)
82 + {
83 + if (ModelState.IsValid)
84 + {
85 + await _service.CreateAsync(vm.SettlementPlan);
86 + return RedirectToAction(nameof(Index));
87 + }
88 +
89 + vm.Title = _l["New settlement plan"].Value;
90 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SettlementPlan.TripId);
91 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SettlementPlan.CreatedByUserId);
92 + return View(vm);
93 + }
94 +
95 + // GET: Admin/SettlementPlans/Edit/5
96 + public async Task<IActionResult> Edit(Guid? id)
97 + {
98 + if (id == null)
99 + {
100 + return NotFound();
101 + }
102 +
103 + var settlementPlan = await _service.GetByIdAsync(id.Value);
104 + if (settlementPlan == null)
105 + {
106 + return NotFound();
107 + }
108 +
109 + var vm = new AdminSettlementPlanFormViewModel
110 + {
111 + Title = _l["Edit settlement plan"].Value,
112 + SettlementPlan = settlementPlan,
113 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", settlementPlan.TripId),
114 + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", settlementPlan.CreatedByUserId)
115 + };
116 + return View(vm);
117 + }
118 +
119 + // POST: Admin/SettlementPlans/Edit/5
120 + [HttpPost]
121 + [ValidateAntiForgeryToken]
122 + public async Task<IActionResult> Edit(Guid id, AdminSettlementPlanFormViewModel vm)
123 + {
124 + if (id != vm.SettlementPlan.Id)
125 + {
126 + return NotFound();
127 + }
128 +
129 + if (ModelState.IsValid)
130 + {
131 + try
132 + {
133 + await _service.UpdateAsync(vm.SettlementPlan);
134 + }
135 + catch (DbUpdateConcurrencyException)
136 + {
137 + if (!await _service.ExistsAsync(vm.SettlementPlan.Id))
138 + {
139 + return NotFound();
140 + }
141 + else
142 + {
143 + throw;
144 + }
145 + }
146 +
147 + return RedirectToAction(nameof(Index));
148 + }
149 +
150 + vm.Title = _l["Edit settlement plan"].Value;
151 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SettlementPlan.TripId);
152 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SettlementPlan.CreatedByUserId);
153 + return View(vm);
154 + }
155 +
156 + // GET: Admin/SettlementPlans/Delete/5
157 + public async Task<IActionResult> Delete(Guid? id)
158 + {
159 + if (id == null)
160 + {
161 + return NotFound();
162 + }
163 +
164 + var settlementPlan = await _service.GetByIdAsync(id.Value);
165 + if (settlementPlan == null)
166 + {
167 + return NotFound();
168 + }
169 +
170 + return View(new AdminDeleteViewModel<SettlementPlanBllDto>
171 + {
172 + Title = _l["Delete settlement plan"].Value,
173 + Item = settlementPlan
174 + });
175 + }
176 +
177 + // POST: Admin/SettlementPlans/Delete/5
178 + [HttpPost, ActionName("Delete")]
179 + [ValidateAntiForgeryToken]
180 + public async Task<IActionResult> DeleteConfirmed(Guid id)
181 + {
182 + await _service.DeleteAsync(id);
183 + return RedirectToAction(nameof(Index));
184 + }
185 + }
186 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SplitPresetsController.cs +114 −0
@@ -0,0 +1,114 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.Extensions.Localization;
10 +using SplitApp.WebApp.Areas.Admin.Models;
11 +
12 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
13 +
14 +[Area("Admin")]
15 +[Authorize(Roles = "admin")]
16 +public class SplitPresetsController : Controller
17 +{
18 + private readonly ISplitPresetAdminService _service;
19 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
20 +
21 + public SplitPresetsController(ISplitPresetAdminService service,
22 + IStringLocalizer<App.Resources.Views.Shared> l)
23 + {
24 + _service = service;
25 + _l = l;
26 + }
27 +
28 + public async Task<IActionResult> Index(string? search)
29 + {
30 + var presets = await _service.GetAllAsync(search);
31 +
32 + var vm = new AdminSplitPresetIndexViewModel
33 + {
34 + Title = _l["Split presets"].Value,
35 + Items = presets.OrderByDescending(s => s.Id).ToList(),
36 + CurrentSearch = search
37 + };
38 + return View(vm);
39 + }
40 +
41 + public async Task<IActionResult> Details(Guid? id)
42 + {
43 + if (id == null)
44 + {
45 + return NotFound();
46 + }
47 +
48 + var splitPreset = await _service.GetByIdAsync(id.Value);
49 + if (splitPreset == null)
50 + {
51 + return NotFound();
52 + }
53 +
54 + return View(new AdminDetailsViewModel<SplitPresetBllDto>
55 + {
56 + Title = _l["Split preset details"].Value,
57 + Item = splitPreset
58 + });
59 + }
60 +
61 + public async Task<IActionResult> Create()
62 + {
63 + return View(new AdminSplitPresetFormViewModel
64 + {
65 + Title = _l["New split preset"].Value,
66 + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name"),
67 + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email")
68 + });
69 + }
70 +
71 + [HttpPost]
72 + [ValidateAntiForgeryToken]
73 + public async Task<IActionResult> Create(AdminSplitPresetFormViewModel vm)
74 + {
75 + if (ModelState.IsValid)
76 + {
77 + await _service.CreateAsync(vm.SplitPreset);
78 + return RedirectToAction(nameof(Index));
79 + }
80 +
81 + vm.Title = _l["New split preset"].Value;
82 + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SplitPreset.TripId);
83 + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SplitPreset.CreatedById);
84 + return View(vm);
85 + }
86 +
87 + public async Task<IActionResult> Delete(Guid? id)
88 + {
89 + if (id == null)
90 + {
91 + return NotFound();
92 + }
93 +
94 + var splitPreset = await _service.GetByIdAsync(id.Value);
95 + if (splitPreset == null)
96 + {
97 + return NotFound();
98 + }
99 +
100 + return View(new AdminDeleteViewModel<SplitPresetBllDto>
101 + {
102 + Title = _l["Delete split preset"].Value,
103 + Item = splitPreset
104 + });
105 + }
106 +
107 + [HttpPost, ActionName("Delete")]
108 + [ValidateAntiForgeryToken]
109 + public async Task<IActionResult> DeleteConfirmed(Guid id)
110 + {
111 + await _service.DeleteAsync(id);
112 + return RedirectToAction(nameof(Index));
113 + }
114 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +187 −0
@@ -0,0 +1,187 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.AspNetCore.Mvc.Rendering;
10 +using Microsoft.EntityFrameworkCore;
11 +using Microsoft.Extensions.Localization;
12 +using SplitApp.WebApp.Areas.Admin.Models;
13 +
14 +namespace SplitApp.WebApp.Areas.Admin.Controllers
15 +{
16 + [Area("Admin")]
17 + [Authorize(Roles = "admin")]
18 + public class TripParticipantsController : Controller
19 + {
20 + private readonly ITripParticipantAdminService _service;
21 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
22 +
23 + public TripParticipantsController(ITripParticipantAdminService service,
24 + IStringLocalizer<App.Resources.Views.Shared> l)
25 + {
26 + _service = service;
27 + _l = l;
28 + }
29 +
30 + // GET: Admin/TripParticipants
31 + public async Task<IActionResult> Index(Guid? tripId, string? search)
32 + {
33 + var participants = await _service.GetAllAsync(tripId, search);
34 +
35 + var vm = new AdminTripParticipantIndexViewModel
36 + {
37 + Title = _l["Trip participants"].Value,
38 + Items = participants.OrderByDescending(tp => tp.JoinedAt).ToList(),
39 + Trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(),
40 + CurrentTripId = tripId,
41 + CurrentSearch = search
42 + };
43 + return View(vm);
44 + }
45 +
46 + // GET: Admin/TripParticipants/Details/5
47 + public async Task<IActionResult> Details(Guid? id)
48 + {
49 + if (id == null)
50 + {
51 + return NotFound();
52 + }
53 +
54 + var tripParticipant = await _service.GetByIdAsync(id.Value);
55 + if (tripParticipant == null)
56 + {
57 + return NotFound();
58 + }
59 +
60 + return View(new AdminDetailsViewModel<TripParticipantBllDto>
61 + {
62 + Title = _l["Trip participant details"].Value,
63 + Item = tripParticipant
64 + });
65 + }
66 +
67 + // GET: Admin/TripParticipants/Create
68 + public async Task<IActionResult> Create()
69 + {
70 + var vm = new AdminTripParticipantFormViewModel
71 + {
72 + Title = _l["New trip participant"].Value,
73 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
74 + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email")
75 + };
76 + return View(vm);
77 + }
78 +
79 + // POST: Admin/TripParticipants/Create
80 + [HttpPost]
81 + [ValidateAntiForgeryToken]
82 + public async Task<IActionResult> Create(AdminTripParticipantFormViewModel vm)
83 + {
84 + if (ModelState.IsValid)
85 + {
86 + await _service.CreateAsync(vm.TripParticipant);
87 + return RedirectToAction(nameof(Index));
88 + }
89 +
90 + vm.Title = _l["New trip participant"].Value;
91 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.TripParticipant.TripId);
92 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.TripParticipant.UserId);
93 + return View(vm);
94 + }
95 +
96 + // GET: Admin/TripParticipants/Edit/5
97 + public async Task<IActionResult> Edit(Guid? id)
98 + {
99 + if (id == null)
100 + {
101 + return NotFound();
102 + }
103 +
104 + var tripParticipant = await _service.GetByIdAsync(id.Value);
105 + if (tripParticipant == null)
106 + {
107 + return NotFound();
108 + }
109 +
110 + var vm = new AdminTripParticipantFormViewModel
111 + {
112 + Title = _l["Edit trip participant"].Value,
113 + TripParticipant = tripParticipant,
114 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", tripParticipant.TripId),
115 + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", tripParticipant.UserId)
116 + };
117 + return View(vm);
118 + }
119 +
120 + // POST: Admin/TripParticipants/Edit/5
121 + [HttpPost]
122 + [ValidateAntiForgeryToken]
123 + public async Task<IActionResult> Edit(Guid id, AdminTripParticipantFormViewModel vm)
124 + {
125 + if (id != vm.TripParticipant.Id)
126 + {
127 + return NotFound();
128 + }
129 +
130 + if (ModelState.IsValid)
131 + {
132 + try
133 + {
134 + await _service.UpdateAsync(vm.TripParticipant);
135 + }
136 + catch (DbUpdateConcurrencyException)
137 + {
138 + if (!await _service.ExistsAsync(vm.TripParticipant.Id))
139 + {
140 + return NotFound();
141 + }
142 + else
143 + {
144 + throw;
145 + }
146 + }
147 +
148 + return RedirectToAction(nameof(Index));
149 + }
150 +
151 + vm.Title = _l["Edit trip participant"].Value;
152 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.TripParticipant.TripId);
153 + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.TripParticipant.UserId);
154 + return View(vm);
155 + }
156 +
157 + // GET: Admin/TripParticipants/Delete/5
158 + public async Task<IActionResult> Delete(Guid? id)
159 + {
160 + if (id == null)
161 + {
162 + return NotFound();
163 + }
164 +
165 + var tripParticipant = await _service.GetByIdAsync(id.Value);
166 + if (tripParticipant == null)
167 + {
168 + return NotFound();
169 + }
170 +
171 + return View(new AdminDeleteViewModel<TripParticipantBllDto>
172 + {
173 + Title = _l["Delete trip participant"].Value,
174 + Item = tripParticipant
175 + });
176 + }
177 +
178 + // POST: Admin/TripParticipants/Delete/5
179 + [HttpPost, ActionName("Delete")]
180 + [ValidateAntiForgeryToken]
181 + public async Task<IActionResult> DeleteConfirmed(Guid id)
182 + {
183 + await _service.DeleteAsync(id);
184 + return RedirectToAction(nameof(Index));
185 + }
186 + }
187 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripsController.cs +139 −0
@@ -0,0 +1,139 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.AspNetCore.Mvc.Rendering;
10 +using Microsoft.EntityFrameworkCore;
11 +using Microsoft.Extensions.Localization;
12 +using SplitApp.WebApp.Areas.Admin.Models;
13 +
14 +namespace SplitApp.WebApp.Areas.Admin.Controllers
15 +{
16 + [Area("Admin")]
17 + [Authorize(Roles = "admin")]
18 + public class TripsController : Controller
19 + {
20 + private readonly ITripAdminService _service;
21 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
22 +
23 + public TripsController(ITripAdminService service, IStringLocalizer<App.Resources.Views.Shared> l)
24 + {
25 + _service = service;
26 + _l = l;
27 + }
28 +
29 + public async Task<IActionResult> Index(string? search)
30 + {
31 + var trips = await _service.GetAllAsync(search);
32 +
33 + return View(new AdminTripIndexViewModel
34 + {
35 + Title = _l["Trips"].Value,
36 + Items = trips.OrderByDescending(t => t.CreatedAt).ToList(),
37 + CurrentSearch = search
38 + });
39 + }
40 +
41 + public async Task<IActionResult> Details(Guid? id)
42 + {
43 + if (id == null) return NotFound();
44 + var trip = await _service.GetByIdAsync(id.Value);
45 + if (trip == null) return NotFound();
46 +
47 + return View(new AdminDetailsViewModel<TripBllDto>
48 + {
49 + Title = _l["Trip details"].Value,
50 + Item = trip
51 + });
52 + }
53 +
54 + public async Task<IActionResult> Create()
55 + {
56 + return View(new AdminTripFormViewModel
57 + {
58 + Title = _l["New trip"].Value,
59 + CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code")
60 + });
61 + }
62 +
63 + [HttpPost]
64 + [ValidateAntiForgeryToken]
65 + public async Task<IActionResult> Create(AdminTripFormViewModel vm)
66 + {
67 + if (ModelState.IsValid)
68 + {
69 + await _service.CreateAsync(vm.Trip);
70 + return RedirectToAction(nameof(Index));
71 + }
72 +
73 + vm.Title = _l["New trip"].Value;
74 + vm.CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", vm.Trip.DefaultCurrencyId);
75 + return View(vm);
76 + }
77 +
78 + public async Task<IActionResult> Edit(Guid? id)
79 + {
80 + if (id == null) return NotFound();
81 + var trip = await _service.GetByIdAsync(id.Value);
82 + if (trip == null) return NotFound();
83 +
84 + return View(new AdminTripFormViewModel
85 + {
86 + Title = _l["Edit trip"].Value,
87 + Trip = trip,
88 + CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", trip.DefaultCurrencyId)
89 + });
90 + }
91 +
92 + [HttpPost]
93 + [ValidateAntiForgeryToken]
94 + public async Task<IActionResult> Edit(Guid id, AdminTripFormViewModel vm)
95 + {
96 + if (id != vm.Trip.Id) return NotFound();
97 +
98 + if (ModelState.IsValid)
99 + {
100 + try
101 + {
102 + await _service.UpdateAsync(vm.Trip);
103 + }
104 + catch (DbUpdateConcurrencyException)
105 + {
106 + if (!await _service.ExistsAsync(vm.Trip.Id)) return NotFound();
107 + throw;
108 + }
109 +
110 + return RedirectToAction(nameof(Index));
111 + }
112 +
113 + vm.Title = _l["Edit trip"].Value;
114 + vm.CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", vm.Trip.DefaultCurrencyId);
115 + return View(vm);
116 + }
117 +
118 + public async Task<IActionResult> Delete(Guid? id)
119 + {
120 + if (id == null) return NotFound();
121 + var trip = await _service.GetByIdAsync(id.Value);
122 + if (trip == null) return NotFound();
123 +
124 + return View(new AdminDeleteViewModel<TripBllDto>
125 + {
126 + Title = _l["Delete trip"].Value,
127 + Item = trip
128 + });
129 + }
130 +
131 + [HttpPost, ActionName("Delete")]
132 + [ValidateAntiForgeryToken]
133 + public async Task<IActionResult> DeleteConfirmed(Guid id)
134 + {
135 + await _service.DeleteAsync(id);
136 + return RedirectToAction(nameof(Index));
137 + }
138 + }
139 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/UsersController.cs +139 −0
@@ -0,0 +1,139 @@
1 +using Microsoft.AspNetCore.Authorization;
2 +using Microsoft.AspNetCore.Mvc;
3 +using Microsoft.Extensions.Localization;
4 +using SplitApp.WebApp.Application.UsersService;
5 +using SplitApp.WebApp.Areas.Admin.Models;
6 +
7 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
8 +
9 +[Area("Admin")]
10 +[Authorize(Roles = "admin")]
11 +public class UsersController : Controller
12 +{
13 + private readonly IUsersServiceClient _users;
14 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
15 +
16 + public UsersController(IUsersServiceClient users, IStringLocalizer<App.Resources.Views.Shared> l)
17 + {
18 + _users = users;
19 + _l = l;
20 + }
21 +
22 + public async Task<IActionResult> Index()
23 + {
24 + var users = await _users.ListUsersAsync();
25 + var vm = new AdminUserIndexViewModel
26 + {
27 + Title = _l["Users"].Value,
28 + Users = users.Select(u => new AdminUserViewModel
29 + {
30 + Id = u.Id,
31 + Email = u.Email,
32 + FirstName = u.FirstName,
33 + LastName = u.LastName,
34 + Roles = u.Roles.ToList(),
35 + }).ToList(),
36 + };
37 + return View(vm);
38 + }
39 +
40 + public async Task<IActionResult> EditRoles(Guid id)
41 + {
42 + var user = await _users.GetUserAsync(id);
43 + if (user is null) return NotFound();
44 + var assigned = await _users.GetUserRolesAsync(id);
45 + var allRoles = await _users.ListRolesAsync();
46 + var vm = new AdminEditRolesViewModel
47 + {
48 + Title = _l["Edit user roles"].Value,
49 + UserEmail = user.Email,
50 + UserName = $"{user.FirstName} {user.LastName}",
51 + Roles = allRoles.Select(r => new RoleAssignmentViewModel
52 + {
53 + RoleName = r.Name,
54 + IsAssigned = assigned.Contains(r.Name),
55 + }).ToList(),
56 + };
57 + return View(vm);
58 + }
59 +
60 + [HttpPost]
61 + [ValidateAntiForgeryToken]
62 + public async Task<IActionResult> EditRoles(Guid id, AdminEditRolesViewModel vm)
63 + {
64 + var selected = vm.Roles.Where(r => r.IsAssigned).Select(r => r.RoleName).ToArray();
65 + await _users.SetUserRolesAsync(id, selected);
66 + return RedirectToAction(nameof(Index));
67 + }
68 +
69 + public async Task<IActionResult> Details(Guid id)
70 + {
71 + var user = await _users.GetUserAsync(id);
72 + if (user is null) return NotFound();
73 + var vm = new AdminUserDetailsViewModel
74 + {
75 + Title = _l["User details"].Value,
76 + Id = user.Id,
77 + Email = user.Email,
78 + FirstName = user.FirstName,
79 + LastName = user.LastName,
80 + Roles = user.Roles.ToList(),
81 + };
82 + return View(vm);
83 + }
84 +
85 + public async Task<IActionResult> Edit(Guid id)
86 + {
87 + var user = await _users.GetUserAsync(id);
88 + if (user is null) return NotFound();
89 + var vm = new AdminUserEditViewModel
90 + {
91 + Title = _l["Edit user"].Value,
92 + Id = user.Id,
93 + Email = user.Email,
94 + FirstName = user.FirstName,
95 + LastName = user.LastName,
96 + };
97 + return View(vm);
98 + }
99 +
100 + [HttpPost]
101 + [ValidateAntiForgeryToken]
102 + public async Task<IActionResult> Edit(Guid id, AdminUserEditViewModel vm)
103 + {
104 + if (id != vm.Id) return NotFound();
105 + if (!ModelState.IsValid)
106 + {
107 + vm.Title = _l["Edit user"].Value;
108 + return View(vm);
109 + }
110 + var updated = await _users.UpdateUserAsync(id, vm.FirstName, vm.LastName);
111 + if (updated is null) return NotFound();
112 + return RedirectToAction(nameof(Index));
113 + }
114 +
115 + public async Task<IActionResult> Delete(Guid id)
116 + {
117 + var user = await _users.GetUserAsync(id);
118 + if (user is null) return NotFound();
119 + var vm = new AdminUserDetailsViewModel
120 + {
121 + Title = _l["Delete user"].Value,
122 + Id = user.Id,
123 + Email = user.Email,
124 + FirstName = user.FirstName,
125 + LastName = user.LastName,
126 + Roles = user.Roles.ToList(),
127 + };
128 + return View(vm);
129 + }
130 +
131 + [HttpPost, ActionName("Delete")]
132 + [ValidateAntiForgeryToken]
133 + public async Task<IActionResult> DeleteConfirmed(Guid id)
134 + {
135 + var ok = await _users.DeleteUserAsync(id);
136 + if (!ok) TempData["Error"] = "User not found.";
137 + return RedirectToAction(nameof(Index));
138 + }
139 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/WishlistController.cs +128 −0
@@ -0,0 +1,128 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using SplitApp.WebApp.Application.Services.Admin;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.AspNetCore.Mvc.Rendering;
10 +using Microsoft.Extensions.Localization;
11 +using SplitApp.WebApp.Areas.Admin.Models;
12 +
13 +namespace SplitApp.WebApp.Areas.Admin.Controllers;
14 +
15 +[Area("Admin")]
16 +[Authorize(Roles = "admin")]
17 +public class WishlistController : Controller
18 +{
19 + private readonly IWishlistAdminService _service;
20 + private readonly IStringLocalizer<App.Resources.Views.Shared> _l;
21 +
22 + public WishlistController(IWishlistAdminService service, IStringLocalizer<App.Resources.Views.Shared> l)
23 + {
24 + _service = service;
25 + _l = l;
26 + }
27 +
28 + public async Task<IActionResult> Index(string? search)
29 + {
30 + var items = await _service.GetAllAsync(search);
31 +
32 + var vm = new AdminWishlistIndexViewModel
33 + {
34 + Title = _l["Wishlist"].Value,
35 + Items = items.OrderByDescending(w => w.Id).ToList(),
36 + CurrentSearch = search
37 + };
38 + return View(vm);
39 + }
40 +
41 + public async Task<IActionResult> Details(Guid id)
42 + {
43 + var item = await _service.GetByIdAsync(id);
44 + if (item == null) return NotFound();
45 +
46 + return View(new AdminDetailsViewModel<TripWishlistItemBllDto>
47 + {
48 + Title = _l["Wishlist item details"].Value,
49 + Item = item
50 + });
51 + }
52 +
53 + public async Task<IActionResult> Create()
54 + {
55 + var vm = new AdminWishlistFormViewModel
56 + {
57 + Title = _l["New wishlist item"].Value,
58 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
59 + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name")
60 + };
61 + return View(vm);
62 + }
63 +
64 + [HttpPost]
65 + [ValidateAntiForgeryToken]
66 + public async Task<IActionResult> Create(AdminWishlistFormViewModel vm)
67 + {
68 + if (ModelState.IsValid)
69 + {
70 + await _service.CreateAsync(vm.Item);
71 + return RedirectToAction(nameof(Index));
72 + }
73 + vm.Title = _l["New wishlist item"].Value;
74 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name");
75 + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name");
76 + return View(vm);
77 + }
78 +
79 + public async Task<IActionResult> Edit(Guid id)
80 + {
81 + var item = await _service.GetByIdAsync(id);
82 + if (item == null) return NotFound();
83 + var vm = new AdminWishlistFormViewModel
84 + {
85 + Title = _l["Edit wishlist item"].Value,
86 + Item = item,
87 + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"),
88 + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name")
89 + };
90 + return View(vm);
91 + }
92 +
93 + [HttpPost]
94 + [ValidateAntiForgeryToken]
95 + public async Task<IActionResult> Edit(Guid id, AdminWishlistFormViewModel vm)
96 + {
97 + if (id != vm.Item.Id) return NotFound();
98 + if (ModelState.IsValid)
99 + {
100 + await _service.UpdateAsync(vm.Item);
101 + return RedirectToAction(nameof(Index));
102 + }
103 + vm.Title = _l["Edit wishlist item"].Value;
104 + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name");
105 + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name");
106 + return View(vm);
107 + }
108 +
109 + public async Task<IActionResult> Delete(Guid id)
110 + {
111 + var item = await _service.GetByIdAsync(id);
112 + if (item == null) return NotFound();
113 +
114 + return View(new AdminDeleteViewModel<TripWishlistItemBllDto>
115 + {
116 + Title = _l["Delete wishlist item"].Value,
117 + Item = item
118 + });
119 + }
120 +
121 + [HttpPost, ActionName("Delete")]
122 + [ValidateAntiForgeryToken]
123 + public async Task<IActionResult> DeleteConfirmed(Guid id)
124 + {
125 + await _service.DeleteAsync(id);
126 + return RedirectToAction(nameof(Index));
127 + }
128 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Models/AdminViewModels.cs +287 −0
@@ -0,0 +1,287 @@
1 +using SplitApp.WebApp.Application.DTO;
2 +using Microsoft.AspNetCore.Mvc.Rendering;
3 +
4 +namespace SplitApp.WebApp.Areas.Admin.Models;
5 +
6 +// === BASE ===
7 +
8 +public interface ITitledViewModel
9 +{
10 + string Title { get; set; }
11 +}
12 +
13 +public abstract class AdminPageViewModel : ITitledViewModel
14 +{
15 + public string Title { get; set; } = "";
16 +}
17 +
18 +// Generic wrappers used for Details/Delete views so domain entities
19 +// don't leak directly into views (keeps us strictly on ViewModels).
20 +public class AdminDetailsViewModel<T> : AdminPageViewModel where T : class
21 +{
22 + public T Item { get; set; } = default!;
23 +}
24 +
25 +public class AdminDeleteViewModel<T> : AdminPageViewModel where T : class
26 +{
27 + public T Item { get; set; } = default!;
28 +}
29 +
30 +// === INDEX VIEWMODELS ===
31 +
32 +public class AdminTripIndexViewModel : AdminPageViewModel
33 +{
34 + public List<TripBllDto> Items { get; set; } = new();
35 + public string? CurrentSearch { get; set; }
36 +}
37 +
38 +public class AdminExpenseIndexViewModel : AdminPageViewModel
39 +{
40 + public List<ExpenseBllDto> Items { get; set; } = new();
41 + public List<TripBllDto> Trips { get; set; } = new();
42 + public Guid? CurrentTripId { get; set; }
43 + public string? CurrentSearch { get; set; }
44 +}
45 +
46 +public class AdminBudgetCategoryIndexViewModel : AdminPageViewModel
47 +{
48 + public List<BudgetCategoryBllDto> Items { get; set; } = new();
49 + public List<TripBllDto> Trips { get; set; } = new();
50 + public Guid? CurrentTripId { get; set; }
51 + public string? CurrentSearch { get; set; }
52 +}
53 +
54 +public class AdminSettlementPlanIndexViewModel : AdminPageViewModel
55 +{
56 + public List<SettlementPlanBllDto> Items { get; set; } = new();
57 + public List<TripBllDto> Trips { get; set; } = new();
58 + public Guid? CurrentTripId { get; set; }
59 +}
60 +
61 +public class AdminTripParticipantIndexViewModel : AdminPageViewModel
62 +{
63 + public List<TripParticipantBllDto> Items { get; set; } = new();
64 + public List<TripBllDto> Trips { get; set; } = new();
65 + public Guid? CurrentTripId { get; set; }
66 + public string? CurrentSearch { get; set; }
67 +}
68 +
69 +public class AdminSettlementPaymentIndexViewModel : AdminPageViewModel
70 +{
71 + public List<SettlementPaymentBllDto> Items { get; set; } = new();
72 + public string? CurrentSearch { get; set; }
73 +}
74 +
75 +public class AdminPollIndexViewModel : AdminPageViewModel
76 +{
77 + public List<TripPollBllDto> Items { get; set; } = new();
78 + public string? CurrentSearch { get; set; }
79 +}
80 +
81 +public class AdminWishlistIndexViewModel : AdminPageViewModel
82 +{
83 + public List<TripWishlistItemBllDto> Items { get; set; } = new();
84 + public string? CurrentSearch { get; set; }
85 +}
86 +
87 +public class AdminInvitationIndexViewModel : AdminPageViewModel
88 +{
89 + public List<TripInvitationBllDto> Items { get; set; } = new();
90 + public string? CurrentSearch { get; set; }
91 +}
92 +
93 +public class AdminCurrencyIndexViewModel : AdminPageViewModel
94 +{
95 + public List<CurrencyBllDto> Items { get; set; } = new();
96 + public string? CurrentSearch { get; set; }
97 +}
98 +
99 +public class AdminSplitPresetIndexViewModel : AdminPageViewModel
100 +{
101 + public List<SplitPresetBllDto> Items { get; set; } = new();
102 + public string? CurrentSearch { get; set; }
103 +}
104 +
105 +public class AdminUserIndexViewModel : AdminPageViewModel
106 +{
107 + public List<AdminUserViewModel> Users { get; set; } = new();
108 +}
109 +
110 +// === FORM VIEWMODELS ===
111 +
112 +public class AdminTripFormViewModel : AdminPageViewModel
113 +{
114 + public TripBllDto Trip { get; set; } = new();
115 + public SelectList CurrencyList { get; set; } = default!;
116 +}
117 +
118 +public class AdminExpenseFormViewModel : AdminPageViewModel
119 +{
120 + public ExpenseBllDto Expense { get; set; } = new();
121 + public SelectList TripList { get; set; } = default!;
122 + public SelectList UserList { get; set; } = default!;
123 + public SelectList CurrencyList { get; set; } = default!;
124 + public SelectList BudgetCategoryList { get; set; } = default!;
125 +}
126 +
127 +public class AdminBudgetCategoryFormViewModel : AdminPageViewModel
128 +{
129 + public BudgetCategoryBllDto BudgetCategory { get; set; } = new();
130 + public SelectList TripList { get; set; } = default!;
131 +}
132 +
133 +public class AdminSettlementPlanFormViewModel : AdminPageViewModel
134 +{
135 + public SettlementPlanBllDto SettlementPlan { get; set; } = new();
136 + public SelectList TripList { get; set; } = default!;
137 + public SelectList UserList { get; set; } = default!;
138 +}
139 +
140 +public class AdminTripParticipantFormViewModel : AdminPageViewModel
141 +{
142 + public TripParticipantBllDto TripParticipant { get; set; } = new();
143 + public SelectList TripList { get; set; } = default!;
144 + public SelectList UserList { get; set; } = default!;
145 +}
146 +
147 +public class AdminPollFormViewModel : AdminPageViewModel
148 +{
149 + public TripPollBllDto Poll { get; set; } = new();
150 + public SelectList TripList { get; set; } = default!;
151 + public SelectList UserList { get; set; } = default!;
152 +}
153 +
154 +public class AdminWishlistFormViewModel : AdminPageViewModel
155 +{
156 + public TripWishlistItemBllDto Item { get; set; } = new();
157 + public SelectList TripList { get; set; } = default!;
158 + public SelectList UserList { get; set; } = default!;
159 +}
160 +
161 +public class AdminCurrencyFormViewModel : AdminPageViewModel
162 +{
163 + public CurrencyBllDto Currency { get; set; } = new();
164 +}
165 +
166 +public class AdminSplitPresetFormViewModel : AdminPageViewModel
167 +{
168 + public SplitPresetBllDto SplitPreset { get; set; } = new();
169 + public SelectList TripList { get; set; } = default!;
170 + public SelectList UserList { get; set; } = default!;
171 +}
172 +
173 +public class AdminInvitationFormViewModel : AdminPageViewModel
174 +{
175 + public TripInvitationBllDto Invitation { get; set; } = new();
176 + public SelectList TripList { get; set; } = default!;
177 + public SelectList UserList { get; set; } = default!;
178 +}
179 +
180 +public class AdminSettlementPaymentFormViewModel : AdminPageViewModel
181 +{
182 + public SettlementPaymentBllDto Payment { get; set; } = new();
183 + public SelectList SettlementPlanList { get; set; } = default!;
184 + public SelectList UserList { get; set; } = default!;
185 +}
186 +
187 +public class AdminEditRolesViewModel : AdminPageViewModel
188 +{
189 + public string UserEmail { get; set; } = default!;
190 + public string UserName { get; set; } = default!;
191 + public List<RoleAssignmentViewModel> Roles { get; set; } = new();
192 +}
193 +
194 +public class AdminUserDetailsViewModel : AdminPageViewModel
195 +{
196 + public Guid Id { get; set; }
197 + public string Email { get; set; } = default!;
198 + public string FirstName { get; set; } = default!;
199 + public string LastName { get; set; } = default!;
200 + public List<string> Roles { get; set; } = new();
201 +}
202 +
203 +public class AdminUserEditViewModel : AdminPageViewModel
204 +{
205 + public Guid Id { get; set; }
206 + public string Email { get; set; } = default!;
207 + public string FirstName { get; set; } = default!;
208 + public string LastName { get; set; } = default!;
209 +}
210 +
211 +// === DASHBOARD ===
212 +
213 +public class AdminDashboardViewModel : AdminPageViewModel
214 +{
215 + public int TripCount { get; set; }
216 + public int UserCount { get; set; }
217 + public int ExpenseCount { get; set; }
218 + public int CategoryCount { get; set; }
219 + public int SettlementCount { get; set; }
220 + public int WishlistCount { get; set; }
221 + public int PollCount { get; set; }
222 + public int InvitationCount { get; set; }
223 + public int CurrencyCount { get; set; }
224 + public int ParticipantCount { get; set; }
225 + public int ActiveTrips { get; set; }
226 + public int SettledTrips { get; set; }
227 + public int ArchivedTrips { get; set; }
228 + public decimal TotalExpenseAmount { get; set; }
229 + public int PendingSettlements { get; set; }
230 + public int InProgressSettlements { get; set; }
231 + public int CompletedSettlements { get; set; }
232 + public int PendingInvitations { get; set; }
233 + public int PendingPayments { get; set; }
234 + public int MarkedPaidPayments { get; set; }
235 + public List<TripBllDto> RecentTrips { get; set; } = new();
236 + public List<ExpenseBllDto> RecentExpenses { get; set; } = new();
237 + public List<AppUserBllDto> RecentUsers { get; set; } = new();
238 +
239 + // Extended stats
240 + public List<TopActiveTripItem> TopActiveTrips { get; set; } = new();
241 + public List<ExpenseBllDto> BiggestExpenses { get; set; } = new();
242 + public int NewUsersLast7Days { get; set; }
243 + public int NewUsersLast30Days { get; set; }
244 + public List<TopActiveUserItem> TopActiveUsers { get; set; } = new();
245 + public List<ActivityFeedItem> ActivityFeed { get; set; } = new();
246 +}
247 +
248 +public class TopActiveTripItem
249 +{
250 + public TripBllDto Trip { get; set; } = default!;
251 + public int ParticipantCount { get; set; }
252 + public decimal ExpenseSum { get; set; }
253 + public int ExpenseCount { get; set; }
254 +}
255 +
256 +public class TopActiveUserItem
257 +{
258 + public Guid UserId { get; set; }
259 + public string Email { get; set; } = "";
260 + public string FullName { get; set; } = "";
261 + public int ExpenseCount { get; set; }
262 + public decimal TotalAmount { get; set; }
263 +}
264 +
265 +public class ActivityFeedItem
266 +{
267 + public string Type { get; set; } = "";
268 + public string Message { get; set; } = "";
269 + public DateTime Date { get; set; }
270 + public string IconCssClass { get; set; } = "bi-circle";
271 + public string BadgeCssClass { get; set; } = "bg-secondary";
272 +}
273 +
274 +public class AdminUserViewModel
275 +{
276 + public Guid Id { get; set; }
277 + public string Email { get; set; } = default!;
278 + public string FirstName { get; set; } = default!;
279 + public string LastName { get; set; } = default!;
280 + public List<string> Roles { get; set; } = new();
281 +}
282 +
283 +public class RoleAssignmentViewModel
284 +{
285 + public string RoleName { get; set; } = default!;
286 + public bool IsAssigned { get; set; }
287 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Create.cshtml +55 −0
@@ -0,0 +1,55 @@
1 +@model AdminBudgetCategoryFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-tags-fill"></i> @Localizer["Create"] @Localizer["BudgetCategory"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["BudgetCategory"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="BudgetCategory.TripId" class="control-label"></label>
19 + <select asp-for="BudgetCategory.TripId" class="form-control" asp-items="Model.TripList"></select>
20 + <span asp-validation-for="BudgetCategory.TripId" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label class="control-label">@Localizer["Name"] (EN)</label>
24 + <input name="nameEn" class="form-control" />
25 + </div>
26 + <div class="form-group mb-3">
27 + <label class="control-label">@Localizer["Name"] (ET)</label>
28 + <input name="nameEt" class="form-control" />
29 + </div>
30 + <div class="form-group mb-3">
31 + <label asp-for="BudgetCategory.IconName" class="control-label"></label>
32 + <input asp-for="BudgetCategory.IconName" class="form-control" />
33 + <span asp-validation-for="BudgetCategory.IconName" class="text-danger"></span>
34 + </div>
35 + <div class="form-group mb-3">
36 + <label asp-for="BudgetCategory.PlannedAmount" class="control-label"></label>
37 + <input asp-for="BudgetCategory.PlannedAmount" class="form-control" />
38 + <span asp-validation-for="BudgetCategory.PlannedAmount" class="text-danger"></span>
39 + </div>
40 + <div class="form-group mb-3">
41 + <label asp-for="BudgetCategory.DisplayOrder" class="control-label"></label>
42 + <input asp-for="BudgetCategory.DisplayOrder" class="form-control" />
43 + <span asp-validation-for="BudgetCategory.DisplayOrder" class="text-danger"></span>
44 + </div>
45 + <div class="form-group mb-3 d-flex gap-2">
46 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
47 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
48 + </div>
49 + </form>
50 + </div>
51 +</div>
52 +
53 +@section Scripts {
54 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
55 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Delete.cshtml +25 −0
@@ -0,0 +1,25 @@
1 +@model AdminDeleteViewModel<BudgetCategoryBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["BudgetCategory"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Name"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
14 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["PlannedAmount"]</dt>
17 + <dd class="col-sm-10">@Model.Item.PlannedAmount</dd>
18 + </dl>
19 +
20 + <form asp-action="Delete">
21 + <input type="hidden" asp-for="Item.Id" />
22 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
23 + <a asp-action="Index">@Localizer["Back"]</a>
24 + </form>
25 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Details.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDetailsViewModel<BudgetCategoryBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["BudgetCategory"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Name"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
13 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["IconName"]</dt>
16 + <dd class="col-sm-10">@Model.Item.IconName</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["PlannedAmount"]</dt>
19 + <dd class="col-sm-10">@Model.Item.PlannedAmount</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["DisplayOrder"]</dt>
22 + <dd class="col-sm-10">@Model.Item.DisplayOrder</dd>
23 + </dl>
24 +</div>
25 +<div>
26 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
27 + <a asp-action="Index">@Localizer["Back"]</a>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Edit.cshtml +56 −0
@@ -0,0 +1,56 @@
1 +@model AdminBudgetCategoryFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-tags-fill"></i> @Localizer["Edit"] @Localizer["BudgetCategory"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["BudgetCategory"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="BudgetCategory.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="BudgetCategory.TripId" class="control-label"></label>
20 + <select asp-for="BudgetCategory.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="BudgetCategory.TripId" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label class="control-label">@Localizer["Name"] (EN)</label>
25 + <input name="nameEn" class="form-control" value="@(Model.BudgetCategory.Name.ContainsKey("en") ? Model.BudgetCategory.Name["en"] : "")" />
26 + </div>
27 + <div class="form-group mb-3">
28 + <label class="control-label">@Localizer["Name"] (ET)</label>
29 + <input name="nameEt" class="form-control" value="@(Model.BudgetCategory.Name.ContainsKey("et") ? Model.BudgetCategory.Name["et"] : "")" />
30 + </div>
31 + <div class="form-group mb-3">
32 + <label asp-for="BudgetCategory.IconName" class="control-label"></label>
33 + <input asp-for="BudgetCategory.IconName" class="form-control" />
34 + <span asp-validation-for="BudgetCategory.IconName" class="text-danger"></span>
35 + </div>
36 + <div class="form-group mb-3">
37 + <label asp-for="BudgetCategory.PlannedAmount" class="control-label"></label>
38 + <input asp-for="BudgetCategory.PlannedAmount" class="form-control" />
39 + <span asp-validation-for="BudgetCategory.PlannedAmount" class="text-danger"></span>
40 + </div>
41 + <div class="form-group mb-3">
42 + <label asp-for="BudgetCategory.DisplayOrder" class="control-label"></label>
43 + <input asp-for="BudgetCategory.DisplayOrder" class="form-control" />
44 + <span asp-validation-for="BudgetCategory.DisplayOrder" class="text-danger"></span>
45 + </div>
46 + <div class="form-group mb-3 d-flex gap-2">
47 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
48 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
49 + </div>
50 + </form>
51 + </div>
52 +</div>
53 +
54 +@section Scripts {
55 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
56 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Index.cshtml +76 −0
@@ -0,0 +1,76 @@
1 +@model AdminBudgetCategoryIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-tags me-2"></i>@Localizer["BudgetCategories"]</h1>
5 + <p class="lead">@Localizer["Manage budget categories for trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <select name="tripId" class="form-select">
17 + <option value="">— @Localizer["All"] —</option>
18 + @foreach (var t in Model.Trips)
19 + {
20 + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option>
21 + }
22 + </select>
23 + </div>
24 + <div class="col-auto">
25 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
26 + </div>
27 + <div class="col-auto">
28 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
29 + </div>
30 + </form>
31 + </div>
32 +</div>
33 +
34 +<div class="admin-card">
35 + <div class="admin-card-body p-0">
36 + @if (Model.Items.Any())
37 + {
38 + <table class="table admin-table mb-0">
39 + <thead>
40 + <tr>
41 + <th>@Localizer["Name"]</th>
42 + <th>@Localizer["Trip"]</th>
43 + <th class="text-end">@Localizer["PlannedAmount"]</th>
44 + <th>@Localizer["DisplayOrder"]</th>
45 + <th class="text-end">@Localizer["Actions"]</th>
46 + </tr>
47 + </thead>
48 + <tbody>
49 + @foreach (var item in Model.Items)
50 + {
51 + <tr>
52 + <td>@item.Name</td>
53 + <td>@item.Trip?.Name</td>
54 + <td class="text-end fw-bold">@item.PlannedAmount</td>
55 + <td>@item.DisplayOrder</td>
56 + <td class="text-end">
57 + <div class="admin-action-group">
58 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
59 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
60 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
61 + </div>
62 + </td>
63 + </tr>
64 + }
65 + </tbody>
66 + </table>
67 + }
68 + else
69 + {
70 + <div class="admin-empty">
71 + <i class="bi bi-inbox"></i>
72 + <div>@Localizer["No items yet"]</div>
73 + </div>
74 + }
75 + </div>
76 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Create.cshtml +45 −0
@@ -0,0 +1,45 @@
1 +@model AdminCurrencyFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-currency-exchange"></i> @Localizer["Create"] @Localizer["Currency"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Currency"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="Currency.Code" class="control-label"></label>
19 + <input asp-for="Currency.Code" class="form-control" />
20 + <span asp-validation-for="Currency.Code" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label class="control-label">@Localizer["Name"] (EN)</label>
24 + <input name="NameEn" class="form-control" />
25 + </div>
26 + <div class="form-group mb-3">
27 + <label class="control-label">@Localizer["Name"] (ET)</label>
28 + <input name="NameEt" class="form-control" />
29 + </div>
30 + <div class="form-group mb-3">
31 + <label asp-for="Currency.Symbol" class="control-label"></label>
32 + <input asp-for="Currency.Symbol" class="form-control" />
33 + <span asp-validation-for="Currency.Symbol" class="text-danger"></span>
34 + </div>
35 + <div class="form-group mb-3 d-flex gap-2">
36 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
37 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
38 + </div>
39 + </form>
40 + </div>
41 +</div>
42 +
43 +@section Scripts {
44 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
45 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Delete.cshtml +25 −0
@@ -0,0 +1,25 @@
1 +@model AdminDeleteViewModel<CurrencyBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["Currency"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Code"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Code</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["Name"]</dt>
14 + <dd class="col-sm-10">@Model.Item.Name</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Symbol"]</dt>
17 + <dd class="col-sm-10">@Model.Item.Symbol</dd>
18 + </dl>
19 +
20 + <form asp-action="Delete">
21 + <input type="hidden" asp-for="Item.Id" />
22 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
23 + <a asp-action="Index">@Localizer["Back"]</a>
24 + </form>
25 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Details.cshtml +22 −0
@@ -0,0 +1,22 @@
1 +@model AdminDetailsViewModel<CurrencyBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["Currency"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Code"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Code</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["Name"]</dt>
13 + <dd class="col-sm-10">@Model.Item.Name</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["Symbol"]</dt>
16 + <dd class="col-sm-10">@Model.Item.Symbol</dd>
17 + </dl>
18 +</div>
19 +<div>
20 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
21 + <a asp-action="Index">@Localizer["Back"]</a>
22 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Edit.cshtml +46 −0
@@ -0,0 +1,46 @@
1 +@model AdminCurrencyFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-currency-exchange"></i> @Localizer["Edit"] @Localizer["Currency"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Currency"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="Currency.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="Currency.Code" class="control-label"></label>
20 + <input asp-for="Currency.Code" class="form-control" />
21 + <span asp-validation-for="Currency.Code" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label class="control-label">@Localizer["Name"] (EN)</label>
25 + <input name="NameEn" class="form-control" value="@(Model.Currency.Name.ContainsKey("en") ? Model.Currency.Name["en"] : "")" />
26 + </div>
27 + <div class="form-group mb-3">
28 + <label class="control-label">@Localizer["Name"] (ET)</label>
29 + <input name="NameEt" class="form-control" value="@(Model.Currency.Name.ContainsKey("et") ? Model.Currency.Name["et"] : "")" />
30 + </div>
31 + <div class="form-group mb-3">
32 + <label asp-for="Currency.Symbol" class="control-label"></label>
33 + <input asp-for="Currency.Symbol" class="form-control" />
34 + <span asp-validation-for="Currency.Symbol" class="text-danger"></span>
35 + </div>
36 + <div class="form-group mb-3 d-flex gap-2">
37 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
38 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
39 + </div>
40 + </form>
41 + </div>
42 +</div>
43 +
44 +@section Scripts {
45 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
46 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Index.cshtml +65 −0
@@ -0,0 +1,65 @@
1 +@model AdminCurrencyIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-currency-exchange me-2"></i>@Localizer["Currencies"]</h1>
5 + <p class="lead">@Localizer["Manage supported currencies"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
17 + </div>
18 + <div class="col-auto">
19 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
20 + </div>
21 + </form>
22 + </div>
23 +</div>
24 +
25 +<div class="admin-card">
26 + <div class="admin-card-body p-0">
27 + @if (Model.Items.Any())
28 + {
29 + <table class="table admin-table mb-0">
30 + <thead>
31 + <tr>
32 + <th>@Localizer["Code"]</th>
33 + <th>@Localizer["Name"]</th>
34 + <th>@Localizer["Symbol"]</th>
35 + <th class="text-end">@Localizer["Actions"]</th>
36 + </tr>
37 + </thead>
38 + <tbody>
39 + @foreach (var item in Model.Items)
40 + {
41 + <tr>
42 + <td><strong>@item.Code</strong></td>
43 + <td>@item.Name</td>
44 + <td>@item.Symbol</td>
45 + <td class="text-end">
46 + <div class="admin-action-group">
47 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
48 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
49 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
50 + </div>
51 + </td>
52 + </tr>
53 + }
54 + </tbody>
55 + </table>
56 + }
57 + else
58 + {
59 + <div class="admin-empty">
60 + <i class="bi bi-inbox"></i>
61 + <div>@Localizer["No items yet"]</div>
62 + </div>
63 + }
64 + </div>
65 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Dashboard/Index.cshtml +373 −0
@@ -0,0 +1,373 @@
1 +@model AdminDashboardViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-speedometer2 me-2"></i>@Localizer["Dashboard"]</h1>
6 + <p class="lead">@Localizer["System overview and real-time statistics"]</p>
7 + </div>
8 + <div class="text-muted small">
9 + <i class="bi bi-clock"></i> @DateTime.UtcNow.ToString("dd MMM yyyy, HH:mm") UTC
10 + </div>
11 +</div>
12 +
13 +@if (Model.PendingPayments > 0 || Model.MarkedPaidPayments > 0 || Model.PendingInvitations > 0)
14 +{
15 + <div class="row g-2 mb-3">
16 + @if (Model.PendingPayments > 0)
17 + {
18 + <div class="col-md-4"><div class="alert alert-warning mb-0"><i class="bi bi-exclamation-triangle me-1"></i><strong>@Model.PendingPayments</strong> @Localizer["payments awaiting action"]</div></div>
19 + }
20 + @if (Model.MarkedPaidPayments > 0)
21 + {
22 + <div class="col-md-4"><div class="alert alert-info mb-0"><i class="bi bi-info-circle me-1"></i><strong>@Model.MarkedPaidPayments</strong> @Localizer["payments awaiting confirmation"]</div></div>
23 + }
24 + @if (Model.PendingInvitations > 0)
25 + {
26 + <div class="col-md-4"><div class="alert alert-info mb-0"><i class="bi bi-envelope me-1"></i><strong>@Model.PendingInvitations</strong> @Localizer["pending invitations"]</div></div>
27 + }
28 + </div>
29 +}
30 +
31 +<!-- Primary metric cards -->
32 +<div class="row g-3 mb-4">
33 + <div class="col-6 col-md-4 col-xl">
34 + <div class="admin-metric">
35 + <div class="admin-metric-icon bg-primary"><i class="bi bi-people-fill"></i></div>
36 + <div>
37 + <div class="admin-metric-value">@Model.UserCount</div>
38 + <div class="admin-metric-label">@Localizer["Total Users"]</div>
39 + </div>
40 + </div>
41 + </div>
42 + <div class="col-6 col-md-4 col-xl">
43 + <div class="admin-metric">
44 + <div class="admin-metric-icon bg-success"><i class="bi bi-suitcase-lg-fill"></i></div>
45 + <div>
46 + <div class="admin-metric-value">@Model.TripCount</div>
47 + <div class="admin-metric-label">@Localizer["Trips"]</div>
48 + </div>
49 + </div>
50 + </div>
51 + <div class="col-6 col-md-4 col-xl">
52 + <div class="admin-metric">
53 + <div class="admin-metric-icon bg-warning"><i class="bi bi-cash-coin"></i></div>
54 + <div>
55 + <div class="admin-metric-value">@Model.ExpenseCount</div>
56 + <div class="admin-metric-label">@Localizer["Expenses"]</div>
57 + </div>
58 + </div>
59 + </div>
60 + <div class="col-6 col-md-4 col-xl">
61 + <div class="admin-metric">
62 + <div class="admin-metric-icon bg-info"><i class="bi bi-currency-euro"></i></div>
63 + <div>
64 + <div class="admin-metric-value">@Model.TotalExpenseAmount.ToString("N2")</div>
65 + <div class="admin-metric-label">@Localizer["Total amount"]</div>
66 + </div>
67 + </div>
68 + </div>
69 + <div class="col-6 col-md-4 col-xl">
70 + <div class="admin-metric">
71 + <div class="admin-metric-icon bg-purple"><i class="bi bi-diagram-3-fill"></i></div>
72 + <div>
73 + <div class="admin-metric-value">@Model.SettlementCount</div>
74 + <div class="admin-metric-label">@Localizer["Settlement plans"]</div>
75 + </div>
76 + </div>
77 + </div>
78 + <div class="col-6 col-md-4 col-xl">
79 + <div class="admin-metric">
80 + <div class="admin-metric-icon bg-danger"><i class="bi bi-hourglass-split"></i></div>
81 + <div>
82 + <div class="admin-metric-value">@Model.PendingPayments</div>
83 + <div class="admin-metric-label">@Localizer["Pending payments"]</div>
84 + </div>
85 + </div>
86 + </div>
87 +</div>
88 +
89 +<!-- Status breakdowns row -->
90 +<div class="row g-3 mb-4">
91 + <div class="col-lg-6">
92 + <div class="admin-card h-100">
93 + <div class="admin-card-header">
94 + <span><i class="bi bi-pie-chart-fill"></i>@Localizer["Trip status breakdown"]</span>
95 + <span class="text-muted small">@Model.TripCount @Localizer["total"]</span>
96 + </div>
97 + <div class="admin-card-body">
98 + <div class="d-flex flex-wrap gap-2 mb-3">
99 + <span class="badge status-active fs-6 px-3 py-2"><i class="bi bi-circle-fill me-1"></i>@Model.ActiveTrips @Localizer["Active"]</span>
100 + <span class="badge status-settled fs-6 px-3 py-2"><i class="bi bi-check2-circle me-1"></i>@Model.SettledTrips @Localizer["Settled"]</span>
101 + <span class="badge status-archived fs-6 px-3 py-2"><i class="bi bi-archive me-1"></i>@Model.ArchivedTrips @Localizer["Archived"]</span>
102 + </div>
103 + @if (Model.TripCount > 0)
104 + {
105 + <div class="progress" style="height: 10px;">
106 + <div class="progress-bar bg-success" style="width: @(Model.ActiveTrips * 100 / Model.TripCount)%"></div>
107 + <div class="progress-bar bg-primary" style="width: @(Model.SettledTrips * 100 / Model.TripCount)%"></div>
108 + <div class="progress-bar bg-secondary" style="width: @(Model.ArchivedTrips * 100 / Model.TripCount)%"></div>
109 + </div>
110 + }
111 + </div>
112 + </div>
113 + </div>
114 + <div class="col-lg-6">
115 + <div class="admin-card h-100">
116 + <div class="admin-card-header">
117 + <span><i class="bi bi-diagram-3"></i>@Localizer["Settlement status"]</span>
118 + <span class="text-muted small">@Model.SettlementCount @Localizer["total"]</span>
119 + </div>
120 + <div class="admin-card-body">
121 + <div class="d-flex flex-wrap gap-2 mb-3">
122 + <span class="badge status-pending fs-6 px-3 py-2"><i class="bi bi-hourglass me-1"></i>@Model.PendingSettlements @Localizer["Pending"]</span>
123 + <span class="badge status-inprogress fs-6 px-3 py-2"><i class="bi bi-arrow-repeat me-1"></i>@Model.InProgressSettlements @Localizer["In progress"]</span>
124 + <span class="badge status-completed fs-6 px-3 py-2"><i class="bi bi-check-circle me-1"></i>@Model.CompletedSettlements @Localizer["Completed"]</span>
125 + </div>
126 + @if (Model.SettlementCount > 0)
127 + {
128 + <div class="progress" style="height: 10px;">
129 + <div class="progress-bar bg-warning" style="width: @(Model.PendingSettlements * 100 / Model.SettlementCount)%"></div>
130 + <div class="progress-bar bg-info" style="width: @(Model.InProgressSettlements * 100 / Model.SettlementCount)%"></div>
131 + <div class="progress-bar bg-success" style="width: @(Model.CompletedSettlements * 100 / Model.SettlementCount)%"></div>
132 + </div>
133 + }
134 + </div>
135 + </div>
136 + </div>
137 +</div>
138 +
139 +<!-- Top Active Trips + Biggest Expenses -->
140 +<div class="row g-3 mb-4">
141 + <div class="col-lg-6">
142 + <div class="admin-card h-100">
143 + <div class="admin-card-header">
144 + <span><i class="bi bi-trophy-fill"></i>@Localizer["Top active trips"]</span>
145 + <a asp-area="Admin" asp-controller="Trips" asp-action="Index" class="btn btn-sm btn-outline-primary">@Localizer["View all"]</a>
146 + </div>
147 + <div class="admin-card-body p-0">
148 + @if (Model.TopActiveTrips.Any())
149 + {
150 + <table class="table admin-table mb-0">
151 + <thead>
152 + <tr>
153 + <th>@Localizer["Trip"]</th>
154 + <th class="text-center">@Localizer["Participants"]</th>
155 + <th class="text-center">@Localizer["Expenses"]</th>
156 + <th class="text-end">@Localizer["Total"]</th>
157 + </tr>
158 + </thead>
159 + <tbody>
160 + @foreach (var row in Model.TopActiveTrips)
161 + {
162 + <tr>
163 + <td>
164 + <a asp-area="Admin" asp-controller="Trips" asp-action="Details" asp-route-id="@row.Trip.Id" class="text-decoration-none fw-semibold">@row.Trip.Name</a>
165 + @if (!string.IsNullOrEmpty(row.Trip.Destination))
166 + {
167 + <br /><small class="text-muted">@row.Trip.Destination</small>
168 + }
169 + </td>
170 + <td class="text-center"><span class="badge bg-light text-dark border">@row.ParticipantCount</span></td>
171 + <td class="text-center"><span class="badge bg-light text-dark border">@row.ExpenseCount</span></td>
172 + <td class="text-end fw-semibold">@row.ExpenseSum.ToString("N2")</td>
173 + </tr>
174 + }
175 + </tbody>
176 + </table>
177 + }
178 + else
179 + {
180 + <div class="admin-empty"><i class="bi bi-inbox"></i>@Localizer["No active trips yet"]</div>
181 + }
182 + </div>
183 + </div>
184 + </div>
185 + <div class="col-lg-6">
186 + <div class="admin-card h-100">
187 + <div class="admin-card-header">
188 + <span><i class="bi bi-graph-up-arrow"></i>@Localizer["Biggest expenses"]</span>
189 + <a asp-area="Admin" asp-controller="Expenses" asp-action="Index" class="btn btn-sm btn-outline-primary">@Localizer["View all"]</a>
190 + </div>
191 + <div class="admin-card-body p-0">
192 + @if (Model.BiggestExpenses.Any())
193 + {
194 + <table class="table admin-table mb-0">
195 + <thead>
196 + <tr>
197 + <th>@Localizer["Description"]</th>
198 + <th>@Localizer["Trip"]</th>
199 + <th class="text-end">@Localizer["Amount"]</th>
200 + </tr>
201 + </thead>
202 + <tbody>
203 + @foreach (var e in Model.BiggestExpenses)
204 + {
205 + <tr>
206 + <td>
207 + <a asp-area="Admin" asp-controller="Expenses" asp-action="Details" asp-route-id="@e.Id" class="text-decoration-none">
208 + @(string.IsNullOrWhiteSpace(e.Description) ? Localizer["No description"].Value : e.Description)
209 + </a>
210 + @if (e.PaidByUser != null)
211 + {
212 + <br /><small class="text-muted">@Localizer["by"] @e.PaidByUser.FirstName @e.PaidByUser.LastName</small>
213 + }
214 + </td>
215 + <td class="text-muted small">@(e.Trip?.Name ?? "—")</td>
216 + <td class="text-end fw-bold">@e.Amount.ToString("N2")</td>
217 + </tr>
218 + }
219 + </tbody>
220 + </table>
221 + }
222 + else
223 + {
224 + <div class="admin-empty"><i class="bi bi-inbox"></i>@Localizer["No expenses yet"]</div>
225 + }
226 + </div>
227 + </div>
228 + </div>
229 +</div>
230 +
231 +<!-- User stats + Activity Feed -->
232 +<div class="row g-3 mb-4">
233 + <div class="col-lg-5">
234 + <div class="admin-card h-100">
235 + <div class="admin-card-header">
236 + <span><i class="bi bi-person-lines-fill"></i>@Localizer["User activity"]</span>
237 + </div>
238 + <div class="admin-card-body">
239 + <div class="row g-2 mb-3">
240 + <div class="col-4 text-center">
241 + <div class="fs-4 fw-bold text-primary">@Model.UserCount</div>
242 + <div class="small text-muted">@Localizer["Total"]</div>
243 + </div>
244 + <div class="col-4 text-center">
245 + <div class="fs-4 fw-bold text-success">@Model.NewUsersLast7Days</div>
246 + <div class="small text-muted">@Localizer["Active 7d"]</div>
247 + </div>
248 + <div class="col-4 text-center">
249 + <div class="fs-4 fw-bold text-info">@Model.NewUsersLast30Days</div>
250 + <div class="small text-muted">@Localizer["Active 30d"]</div>
251 + </div>
252 + </div>
253 + <hr />
254 + <div class="small fw-semibold text-muted mb-2 text-uppercase">@Localizer["Most active users"]</div>
255 + @if (Model.TopActiveUsers.Any())
256 + {
257 + <ul class="list-unstyled mb-0">
258 + @foreach (var u in Model.TopActiveUsers)
259 + {
260 + <li class="d-flex justify-content-between align-items-center py-1 border-bottom border-light">
261 + <div>
262 + <div class="fw-semibold small">@u.FullName</div>
263 + <div class="text-muted" style="font-size: 0.78rem;">@u.Email</div>
264 + </div>
265 + <div class="text-end">
266 + <span class="badge bg-primary">@u.ExpenseCount @Localizer["expenses"]</span>
267 + </div>
268 + </li>
269 + }
270 + </ul>
271 + }
272 + else
273 + {
274 + <div class="admin-empty"><i class="bi bi-person-slash"></i>@Localizer["No activity yet"]</div>
275 + }
276 + </div>
277 + </div>
278 + </div>
279 + <div class="col-lg-7">
280 + <div class="admin-card h-100">
281 + <div class="admin-card-header">
282 + <span><i class="bi bi-activity"></i>@Localizer["Recent activity"]</span>
283 + <span class="text-muted small">@Model.ActivityFeed.Count @Localizer["events"]</span>
284 + </div>
285 + <div class="admin-card-body">
286 + @if (Model.ActivityFeed.Any())
287 + {
288 + <ul class="admin-feed">
289 + @foreach (var item in Model.ActivityFeed)
290 + {
291 + <li class="admin-feed-item">
292 + <div class="admin-feed-icon @item.BadgeCssClass">
293 + <i class="bi @item.IconCssClass"></i>
294 + </div>
295 + <div class="admin-feed-body">
296 + <div class="admin-feed-message">@item.Message</div>
297 + <div class="admin-feed-date">@item.Date.ToString("dd MMM yyyy, HH:mm") UTC</div>
298 + </div>
299 + </li>
300 + }
301 + </ul>
302 + }
303 + else
304 + {
305 + <div class="admin-empty"><i class="bi bi-clock-history"></i>@Localizer["No recent activity"]</div>
306 + }
307 + </div>
308 + </div>
309 + </div>
310 +</div>
311 +
312 +<!-- Secondary counts -->
313 +<div class="row g-3 mb-4">
314 + <div class="col-6 col-md-4 col-lg-2">
315 + <div class="admin-metric">
316 + <div class="admin-metric-icon bg-info"><i class="bi bi-people"></i></div>
317 + <div><div class="admin-metric-value">@Model.ParticipantCount</div><div class="admin-metric-label">@Localizer["Participants"]</div></div>
318 + </div>
319 + </div>
320 + <div class="col-6 col-md-4 col-lg-2">
321 + <div class="admin-metric">
322 + <div class="admin-metric-icon bg-warning"><i class="bi bi-tags"></i></div>
323 + <div><div class="admin-metric-value">@Model.CategoryCount</div><div class="admin-metric-label">@Localizer["Categories"]</div></div>
324 + </div>
325 + </div>
326 + <div class="col-6 col-md-4 col-lg-2">
327 + <div class="admin-metric">
328 + <div class="admin-metric-icon bg-success"><i class="bi bi-currency-exchange"></i></div>
329 + <div><div class="admin-metric-value">@Model.CurrencyCount</div><div class="admin-metric-label">@Localizer["Currencies"]</div></div>
330 + </div>
331 + </div>
332 + <div class="col-6 col-md-4 col-lg-2">
333 + <div class="admin-metric">
334 + <div class="admin-metric-icon bg-purple"><i class="bi bi-stars"></i></div>
335 + <div><div class="admin-metric-value">@Model.WishlistCount</div><div class="admin-metric-label">@Localizer["Wishlist"]</div></div>
336 + </div>
337 + </div>
338 + <div class="col-6 col-md-4 col-lg-2">
339 + <div class="admin-metric">
340 + <div class="admin-metric-icon bg-primary"><i class="bi bi-bar-chart"></i></div>
341 + <div><div class="admin-metric-value">@Model.PollCount</div><div class="admin-metric-label">@Localizer["Polls"]</div></div>
342 + </div>
343 + </div>
344 + <div class="col-6 col-md-4 col-lg-2">
345 + <div class="admin-metric">
346 + <div class="admin-metric-icon bg-danger"><i class="bi bi-envelope"></i></div>
347 + <div><div class="admin-metric-value">@Model.InvitationCount</div><div class="admin-metric-label">@Localizer["Invitations"]</div></div>
348 + </div>
349 + </div>
350 +</div>
351 +
352 +<!-- Quick actions -->
353 +<div class="admin-card">
354 + <div class="admin-card-header">
355 + <span><i class="bi bi-plus-circle"></i>@Localizer["Quick actions"]</span>
356 + </div>
357 + <div class="admin-card-body">
358 + <div class="row g-2">
359 + <div class="col-md-3 col-6">
360 + <a asp-area="Admin" asp-controller="Trips" asp-action="Create" class="btn btn-outline-primary w-100"><i class="bi bi-plus-lg me-1"></i>@Localizer["New Trip"]</a>
361 + </div>
362 + <div class="col-md-3 col-6">
363 + <a asp-area="Admin" asp-controller="Expenses" asp-action="Create" class="btn btn-outline-primary w-100"><i class="bi bi-plus-lg me-1"></i>@Localizer["New Expense"]</a>
364 + </div>
365 + <div class="col-md-3 col-6">
366 + <a asp-area="Admin" asp-controller="Currencies" asp-action="Create" class="btn btn-outline-primary w-100"><i class="bi bi-plus-lg me-1"></i>@Localizer["New Currency"]</a>
367 + </div>
368 + <div class="col-md-3 col-6">
369 + <a asp-area="Admin" asp-controller="BudgetCategories" asp-action="Create" class="btn btn-outline-primary w-100"><i class="bi bi-plus-lg me-1"></i>@Localizer["New Category"]</a>
370 + </div>
371 + </div>
372 + </div>
373 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Create.cshtml +71 −0
@@ -0,0 +1,71 @@
1 +@model AdminExpenseFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-receipt"></i> @Localizer["Create"] @Localizer["Expense"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Expense"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="Expense.TripId" class="control-label"></label>
19 + <select asp-for="Expense.TripId" class="form-control" asp-items="Model.TripList"></select>
20 + <span asp-validation-for="Expense.TripId" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label asp-for="Expense.PaidByUserId" class="control-label"></label>
24 + <select asp-for="Expense.PaidByUserId" class="form-control" asp-items="Model.UserList"></select>
25 + <span asp-validation-for="Expense.PaidByUserId" class="text-danger"></span>
26 + </div>
27 + <div class="form-group mb-3">
28 + <label asp-for="Expense.Amount" class="control-label"></label>
29 + <input asp-for="Expense.Amount" class="form-control" />
30 + <span asp-validation-for="Expense.Amount" class="text-danger"></span>
31 + </div>
32 + <div class="form-group mb-3">
33 + <label asp-for="Expense.Description" class="control-label"></label>
34 + <input asp-for="Expense.Description" class="form-control" />
35 + <span asp-validation-for="Expense.Description" class="text-danger"></span>
36 + </div>
37 + <div class="form-group mb-3">
38 + <label asp-for="Expense.ExpenseDate" class="control-label"></label>
39 + <input asp-for="Expense.ExpenseDate" class="form-control" type="date" />
40 + <span asp-validation-for="Expense.ExpenseDate" class="text-danger"></span>
41 + </div>
42 + <div class="form-group mb-3">
43 + <label asp-for="Expense.SplitMethod" class="control-label"></label>
44 + <select asp-for="Expense.SplitMethod" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESplitMethod>()"></select>
45 + <span asp-validation-for="Expense.SplitMethod" class="text-danger"></span>
46 + </div>
47 + <div class="form-group mb-3">
48 + <label asp-for="Expense.CurrencyId" class="control-label"></label>
49 + <select asp-for="Expense.CurrencyId" class="form-control" asp-items="Model.CurrencyList">
50 + <option value="">-- @Localizer["Select"] --</option>
51 + </select>
52 + <span asp-validation-for="Expense.CurrencyId" class="text-danger"></span>
53 + </div>
54 + <div class="form-group mb-3">
55 + <label asp-for="Expense.BudgetCategoryId" class="control-label"></label>
56 + <select asp-for="Expense.BudgetCategoryId" class="form-control" asp-items="Model.BudgetCategoryList">
57 + <option value="">-- @Localizer["Select"] --</option>
58 + </select>
59 + <span asp-validation-for="Expense.BudgetCategoryId" class="text-danger"></span>
60 + </div>
61 + <div class="form-group mb-3 d-flex gap-2">
62 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
63 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
64 + </div>
65 + </form>
66 + </div>
67 +</div>
68 +
69 +@section Scripts {
70 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
71 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Delete.cshtml +31 −0
@@ -0,0 +1,31 @@
1 +@model AdminDeleteViewModel<ExpenseBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["Expense"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Description"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Description</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["Amount"]</dt>
14 + <dd class="col-sm-10">@Model.Item.Amount</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["ExpenseDate"]</dt>
17 + <dd class="col-sm-10">@Model.Item.ExpenseDate.ToString("d")</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
20 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
21 +
22 + <dt class="col-sm-2">@Localizer["PaidByUser"]</dt>
23 + <dd class="col-sm-10">@Model.Item.PaidByUser?.Email</dd>
24 + </dl>
25 +
26 + <form asp-action="Delete">
27 + <input type="hidden" asp-for="Item.Id" />
28 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
29 + <a asp-action="Index">@Localizer["Back"]</a>
30 + </form>
31 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Details.cshtml +37 −0
@@ -0,0 +1,37 @@
1 +@model AdminDetailsViewModel<ExpenseBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["Expense"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Description"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Description</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["Amount"]</dt>
13 + <dd class="col-sm-10">@Model.Item.Amount</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["ExpenseDate"]</dt>
16 + <dd class="col-sm-10">@Model.Item.ExpenseDate.ToString("d")</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["SplitMethod"]</dt>
19 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.SplitMethod)</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
22 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
23 +
24 + <dt class="col-sm-2">@Localizer["PaidByUser"]</dt>
25 + <dd class="col-sm-10">@Model.Item.PaidByUser?.Email</dd>
26 +
27 + <dt class="col-sm-2">@Localizer["Currency"]</dt>
28 + <dd class="col-sm-10">@Model.Item.Currency?.Code</dd>
29 +
30 + <dt class="col-sm-2">@Localizer["BudgetCategory"]</dt>
31 + <dd class="col-sm-10">@Model.Item.BudgetCategory?.Name</dd>
32 + </dl>
33 +</div>
34 +<div>
35 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
36 + <a asp-action="Index">@Localizer["Back"]</a>
37 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Edit.cshtml +72 −0
@@ -0,0 +1,72 @@
1 +@model AdminExpenseFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-receipt"></i> @Localizer["Edit"] @Localizer["Expense"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Expense"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="Expense.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="Expense.TripId" class="control-label"></label>
20 + <select asp-for="Expense.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="Expense.TripId" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label asp-for="Expense.PaidByUserId" class="control-label"></label>
25 + <select asp-for="Expense.PaidByUserId" class="form-control" asp-items="Model.UserList"></select>
26 + <span asp-validation-for="Expense.PaidByUserId" class="text-danger"></span>
27 + </div>
28 + <div class="form-group mb-3">
29 + <label asp-for="Expense.Amount" class="control-label"></label>
30 + <input asp-for="Expense.Amount" class="form-control" />
31 + <span asp-validation-for="Expense.Amount" class="text-danger"></span>
32 + </div>
33 + <div class="form-group mb-3">
34 + <label asp-for="Expense.Description" class="control-label"></label>
35 + <input asp-for="Expense.Description" class="form-control" />
36 + <span asp-validation-for="Expense.Description" class="text-danger"></span>
37 + </div>
38 + <div class="form-group mb-3">
39 + <label asp-for="Expense.ExpenseDate" class="control-label"></label>
40 + <input asp-for="Expense.ExpenseDate" class="form-control" type="date" />
41 + <span asp-validation-for="Expense.ExpenseDate" class="text-danger"></span>
42 + </div>
43 + <div class="form-group mb-3">
44 + <label asp-for="Expense.SplitMethod" class="control-label"></label>
45 + <select asp-for="Expense.SplitMethod" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESplitMethod>()"></select>
46 + <span asp-validation-for="Expense.SplitMethod" class="text-danger"></span>
47 + </div>
48 + <div class="form-group mb-3">
49 + <label asp-for="Expense.CurrencyId" class="control-label"></label>
50 + <select asp-for="Expense.CurrencyId" class="form-control" asp-items="Model.CurrencyList">
51 + <option value="">-- @Localizer["Select"] --</option>
52 + </select>
53 + <span asp-validation-for="Expense.CurrencyId" class="text-danger"></span>
54 + </div>
55 + <div class="form-group mb-3">
56 + <label asp-for="Expense.BudgetCategoryId" class="control-label"></label>
57 + <select asp-for="Expense.BudgetCategoryId" class="form-control" asp-items="Model.BudgetCategoryList">
58 + <option value="">-- @Localizer["Select"] --</option>
59 + </select>
60 + <span asp-validation-for="Expense.BudgetCategoryId" class="text-danger"></span>
61 + </div>
62 + <div class="form-group mb-3 d-flex gap-2">
63 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
64 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
65 + </div>
66 + </form>
67 + </div>
68 +</div>
69 +
70 +@section Scripts {
71 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
72 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Index.cshtml +80 −0
@@ -0,0 +1,80 @@
1 +@model AdminExpenseIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-cash-coin me-2"></i>@Localizer["Expenses"]</h1>
5 + <p class="lead">@Localizer["Manage all expenses across trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <select name="tripId" class="form-select">
17 + <option value="">— @Localizer["All"] —</option>
18 + @foreach (var t in Model.Trips)
19 + {
20 + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option>
21 + }
22 + </select>
23 + </div>
24 + <div class="col-auto">
25 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
26 + </div>
27 + <div class="col-auto">
28 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
29 + </div>
30 + </form>
31 + </div>
32 +</div>
33 +
34 +<div class="admin-card">
35 + <div class="admin-card-body p-0">
36 + @if (Model.Items.Any())
37 + {
38 + <table class="table admin-table mb-0">
39 + <thead>
40 + <tr>
41 + <th>@Localizer["Description"]</th>
42 + <th>@Localizer["Trip"]</th>
43 + <th>@Localizer["PaidByUser"]</th>
44 + <th>@Localizer["SplitMethod"]</th>
45 + <th class="text-end">@Localizer["Amount"]</th>
46 + <th>@Localizer["ExpenseDate"]</th>
47 + <th class="text-end">@Localizer["Actions"]</th>
48 + </tr>
49 + </thead>
50 + <tbody>
51 + @foreach (var item in Model.Items)
52 + {
53 + <tr>
54 + <td>@item.Description</td>
55 + <td>@item.Trip?.Name</td>
56 + <td>@item.PaidByUser?.Email</td>
57 + <td>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.SplitMethod)</td>
58 + <td class="text-end fw-bold">@item.Amount</td>
59 + <td>@item.ExpenseDate.ToString("d")</td>
60 + <td class="text-end">
61 + <div class="admin-action-group">
62 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
63 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
64 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
65 + </div>
66 + </td>
67 + </tr>
68 + }
69 + </tbody>
70 + </table>
71 + }
72 + else
73 + {
74 + <div class="admin-empty">
75 + <i class="bi bi-inbox"></i>
76 + <div>@Localizer["No items yet"]</div>
77 + </div>
78 + }
79 + </div>
80 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Create.cshtml +58 −0
@@ -0,0 +1,58 @@
1 +@model AdminInvitationFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-envelope-plus"></i> @Localizer["Create"] @Localizer["Invitation"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Invitation"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 +
18 + <div class="form-group mb-3">
19 + <label asp-for="Invitation.TripId" class="control-label">@Localizer["Trip"]</label>
20 + <select asp-for="Invitation.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="Invitation.TripId" class="text-danger"></span>
22 + </div>
23 +
24 + <div class="form-group mb-3">
25 + <label asp-for="Invitation.InvitedByUserId" class="control-label">@Localizer["InvitedBy"]</label>
26 + <select asp-for="Invitation.InvitedByUserId" class="form-control" asp-items="Model.UserList"></select>
27 + <span asp-validation-for="Invitation.InvitedByUserId" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="form-group mb-3">
31 + <label asp-for="Invitation.Token" class="control-label"></label>
32 + <input asp-for="Invitation.Token" class="form-control" placeholder="@Localizer["Auto-generated if blank"]" />
33 + <span asp-validation-for="Invitation.Token" class="text-danger"></span>
34 + </div>
35 +
36 + <div class="form-group mb-3">
37 + <label asp-for="Invitation.Status" class="control-label"></label>
38 + <select asp-for="Invitation.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EInvitationStatus>()"></select>
39 + <span asp-validation-for="Invitation.Status" class="text-danger"></span>
40 + </div>
41 +
42 + <div class="form-group mb-3">
43 + <label asp-for="Invitation.ExpiresAt" class="control-label"></label>
44 + <input asp-for="Invitation.ExpiresAt" class="form-control" type="datetime-local" />
45 + <span asp-validation-for="Invitation.ExpiresAt" class="text-danger"></span>
46 + </div>
47 +
48 + <div class="form-group mb-3 d-flex gap-2">
49 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
50 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
51 + </div>
52 + </form>
53 + </div>
54 +</div>
55 +
56 +@section Scripts {
57 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
58 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Delete.cshtml +39 −0
@@ -0,0 +1,39 @@
1 +@model AdminDeleteViewModel<TripInvitationBllDto>
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-envelope-x text-danger"></i> @Localizer["Delete invitation"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card border-danger">
11 + <div class="admin-card-body">
12 + <div class="alert alert-danger">
13 + <i class="bi bi-exclamation-triangle me-2"></i>
14 + @Localizer["Are you sure you want to delete this invitation?"]
15 + </div>
16 +
17 + <dl class="row">
18 + <dt class="col-sm-3">@Localizer["Trip"]</dt>
19 + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd>
20 +
21 + <dt class="col-sm-3">@Localizer["InvitedBy"]</dt>
22 + <dd class="col-sm-9">@Model.Item.InvitedByUser?.Email</dd>
23 +
24 + <dt class="col-sm-3">@Localizer["Token"]</dt>
25 + <dd class="col-sm-9"><code>@Model.Item.Token</code></dd>
26 +
27 + <dt class="col-sm-3">@Localizer["Status"]</dt>
28 + <dd class="col-sm-9">@Model.Item.Status</dd>
29 + </dl>
30 +
31 + <form asp-action="Delete">
32 + <input type="hidden" name="id" value="@Model.Item.Id" />
33 + <div class="d-flex gap-2">
34 + <button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</button>
35 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
36 + </div>
37 + </form>
38 + </div>
39 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Details.cshtml +40 −0
@@ -0,0 +1,40 @@
1 +@model AdminDetailsViewModel<TripInvitationBllDto>
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-envelope-paper"></i> @Localizer["Invitation details"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <dl class="row">
13 + <dt class="col-sm-3">@Localizer["Trip"]</dt>
14 + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd>
15 +
16 + <dt class="col-sm-3">@Localizer["InvitedBy"]</dt>
17 + <dd class="col-sm-9">@Model.Item.InvitedByUser?.Email</dd>
18 +
19 + <dt class="col-sm-3">@Localizer["Token"]</dt>
20 + <dd class="col-sm-9"><code>@Model.Item.Token</code></dd>
21 +
22 + <dt class="col-sm-3">@Localizer["Status"]</dt>
23 + <dd class="col-sm-9"><span class="badge bg-secondary">@Model.Item.Status</span></dd>
24 +
25 + <dt class="col-sm-3">@Localizer["ExpiresAt"]</dt>
26 + <dd class="col-sm-9">@Model.Item.ExpiresAt.ToString("yyyy-MM-dd HH:mm")</dd>
27 +
28 + @if (Model.Item.RespondedAt.HasValue)
29 + {
30 + <dt class="col-sm-3">@Localizer["RespondedAt"]</dt>
31 + <dd class="col-sm-9">@Model.Item.RespondedAt.Value.ToString("yyyy-MM-dd HH:mm")</dd>
32 + }
33 + </dl>
34 +
35 + <div class="d-flex gap-2">
36 + <a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary"><i class="bi bi-pencil me-1"></i>@Localizer["Edit"]</a>
37 + <a asp-action="Delete" asp-route-id="@Model.Item.Id" class="btn btn-outline-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</a>
38 + </div>
39 + </div>
40 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Edit.cshtml +59 −0
@@ -0,0 +1,59 @@
1 +@model AdminInvitationFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-envelope-paper"></i> @Localizer["Edit"] @Localizer["Invitation"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Invitation"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <input type="hidden" asp-for="Invitation.Id" />
17 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
18 +
19 + <div class="form-group mb-3">
20 + <label asp-for="Invitation.TripId" class="control-label">@Localizer["Trip"]</label>
21 + <select asp-for="Invitation.TripId" class="form-control" asp-items="Model.TripList"></select>
22 + <span asp-validation-for="Invitation.TripId" class="text-danger"></span>
23 + </div>
24 +
25 + <div class="form-group mb-3">
26 + <label asp-for="Invitation.InvitedByUserId" class="control-label">@Localizer["InvitedBy"]</label>
27 + <select asp-for="Invitation.InvitedByUserId" class="form-control" asp-items="Model.UserList"></select>
28 + <span asp-validation-for="Invitation.InvitedByUserId" class="text-danger"></span>
29 + </div>
30 +
31 + <div class="form-group mb-3">
32 + <label asp-for="Invitation.Token" class="control-label"></label>
33 + <input asp-for="Invitation.Token" class="form-control" />
34 + <span asp-validation-for="Invitation.Token" class="text-danger"></span>
35 + </div>
36 +
37 + <div class="form-group mb-3">
38 + <label asp-for="Invitation.Status" class="control-label"></label>
39 + <select asp-for="Invitation.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EInvitationStatus>()"></select>
40 + <span asp-validation-for="Invitation.Status" class="text-danger"></span>
41 + </div>
42 +
43 + <div class="form-group mb-3">
44 + <label asp-for="Invitation.ExpiresAt" class="control-label"></label>
45 + <input asp-for="Invitation.ExpiresAt" class="form-control" type="datetime-local" />
46 + <span asp-validation-for="Invitation.ExpiresAt" class="text-danger"></span>
47 + </div>
48 +
49 + <div class="form-group mb-3 d-flex gap-2">
50 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
51 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
52 + </div>
53 + </form>
54 + </div>
55 +</div>
56 +
57 +@section Scripts {
58 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
59 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Index.cshtml +65 −0
@@ -0,0 +1,65 @@
1 +@model AdminInvitationIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-envelope me-2"></i>@Localizer["Invitations"]</h1>
5 + <p class="lead">@Localizer["Review invitation tokens and statuses"]</p>
6 + </div>
7 +</div>
8 +
9 +<div class="admin-card mb-3">
10 + <div class="admin-card-body">
11 + <form method="get" class="row g-2 align-items-end">
12 + <div class="col-auto">
13 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
14 + </div>
15 + <div class="col-auto">
16 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
17 + </div>
18 + </form>
19 + </div>
20 +</div>
21 +
22 +<div class="admin-card">
23 + <div class="admin-card-body p-0">
24 + @if (Model.Items.Any())
25 + {
26 + <table class="table admin-table mb-0">
27 + <thead>
28 + <tr>
29 + <th>@Localizer["Token"]</th>
30 + <th>@Localizer["Trip"]</th>
31 + <th>@Localizer["InvitedBy"]</th>
32 + <th>@Localizer["ExpiresAt"]</th>
33 + <th>@Localizer["Status"]</th>
34 + <th class="text-end">@Localizer["Actions"]</th>
35 + </tr>
36 + </thead>
37 + <tbody>
38 + @foreach (var item in Model.Items)
39 + {
40 + <tr>
41 + <td><code>@(item.Token.Length > 16 ? item.Token[..16] + "..." : item.Token)</code></td>
42 + <td>@item.Trip?.Name</td>
43 + <td>@item.InvitedByUser?.Email</td>
44 + <td>@item.ExpiresAt.ToString("g")</td>
45 + <td><span class="badge status-@item.Status.ToString().ToLower()">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td>
46 + <td class="text-end">
47 + <div class="admin-action-group">
48 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
49 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
50 + </div>
51 + </td>
52 + </tr>
53 + }
54 + </tbody>
55 + </table>
56 + }
57 + else
58 + {
59 + <div class="admin-empty">
60 + <i class="bi bi-inbox"></i>
61 + <div>@Localizer["No items yet"]</div>
62 + </div>
63 + }
64 + </div>
65 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Create.cshtml +48 −0
@@ -0,0 +1,48 @@
1 +@model AdminPollFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-bar-chart-fill"></i> @Localizer["Create"] @Localizer["Poll"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Poll"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create" method="post">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="mb-3">
18 + <label asp-for="Poll.TripId" class="form-label"></label>
19 + <select asp-for="Poll.TripId" asp-items="Model.TripList" class="form-select"></select>
20 + </div>
21 + <div class="mb-3">
22 + <label asp-for="Poll.CreatedByUserId" class="form-label"></label>
23 + <select asp-for="Poll.CreatedByUserId" asp-items="Model.UserList" class="form-select"></select>
24 + </div>
25 + <div class="mb-3">
26 + <label asp-for="Poll.Question" class="form-label"></label>
27 + <input asp-for="Poll.Question" class="form-control" />
28 + <span asp-validation-for="Poll.Question" class="text-danger"></span>
29 + </div>
30 + <div class="mb-3 form-check">
31 + <input asp-for="Poll.AllowMultipleVotes" class="form-check-input" />
32 + <label asp-for="Poll.AllowMultipleVotes" class="form-check-label"></label>
33 + </div>
34 + <div class="mb-3 form-check">
35 + <input asp-for="Poll.IsAnonymous" class="form-check-input" />
36 + <label asp-for="Poll.IsAnonymous" class="form-check-label"></label>
37 + </div>
38 + <div class="d-flex gap-2">
39 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Create"]</button>
40 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
41 + </div>
42 + </form>
43 + </div>
44 +</div>
45 +
46 +@section Scripts {
47 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
48 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Delete.cshtml +13 −0
@@ -0,0 +1,13 @@
1 +@model AdminDeleteViewModel<TripPollBllDto>
2 +
3 +<h1>@Localizer["Delete"] @Localizer["Poll"]</h1>
4 +
5 +<div class="alert alert-danger">
6 + @Localizer["AreYouSure"] - "<strong>@Model.Item.Question</strong>" (@Model.Item.Trip?.Name)?
7 +</div>
8 +
9 +<form asp-action="Delete" method="post">
10 + <input type="hidden" asp-for="Item.Id" />
11 + <button type="submit" class="btn btn-danger">@Localizer["Delete"]</button>
12 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
13 +</form>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Details.cshtml +37 −0
@@ -0,0 +1,37 @@
1 +@model AdminDetailsViewModel<TripPollBllDto>
2 +
3 +<h1>@Localizer["Poll"] @Localizer["Details"]</h1>
4 +
5 +<div class="card shadow-sm">
6 + <div class="card-body">
7 + <h5>@Model.Item.Question</h5>
8 + <p class="text-muted">@Localizer["Trip"]: @Model.Item.Trip?.Name | @Localizer["Created By"]: @(Model.Item.CreatedByUser != null ? $"{Model.Item.CreatedByUser.FirstName} {Model.Item.CreatedByUser.LastName}" : "")</p>
9 + <p>@Localizer["Multi-Vote"]: @(Model.Item.AllowMultipleVotes ? Localizer["Yes"] : Localizer["No"]) | @Localizer["Anonymous"]: @(Model.Item.IsAnonymous ? Localizer["Yes"] : Localizer["No"])</p>
10 + @if (Model.Item.ClosedAt.HasValue)
11 + {
12 + <p class="text-danger">@Localizer["Closed at"]: @Model.Item.ClosedAt.Value.ToString("g")</p>
13 + }
14 +
15 + @if (Model.Item.Options != null)
16 + {
17 + <h6 class="mt-3">@Localizer["Options"]</h6>
18 + <table class="table">
19 + <thead>
20 + <tr><th>@Localizer["Text"]</th><th>@Localizer["Votes"]</th></tr>
21 + </thead>
22 + <tbody>
23 + @foreach (var option in Model.Item.Options.OrderBy(o => o.DisplayOrder))
24 + {
25 + <tr>
26 + <td>@option.Text</td>
27 + <td>@option.VoteCount</td>
28 + </tr>
29 + }
30 + </tbody>
31 + </table>
32 + }
33 + </div>
34 +</div>
35 +
36 +<a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary mt-3">@Localizer["Edit"]</a>
37 +<a asp-action="Index" class="btn btn-outline-secondary mt-3">@Localizer["Back to List"]</a>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Edit.cshtml +45 −0
@@ -0,0 +1,45 @@
1 +@model AdminPollFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-bar-chart-fill"></i> @Localizer["Edit"] @Localizer["Poll"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Poll"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit" method="post">
16 + <input type="hidden" asp-for="Poll.Id" />
17 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
18 + <div class="mb-3">
19 + <label asp-for="Poll.TripId" class="form-label"></label>
20 + <select asp-for="Poll.TripId" asp-items="Model.TripList" class="form-select"></select>
21 + </div>
22 + <div class="mb-3">
23 + <label asp-for="Poll.Question" class="form-label"></label>
24 + <input asp-for="Poll.Question" class="form-control" />
25 + <span asp-validation-for="Poll.Question" class="text-danger"></span>
26 + </div>
27 + <div class="mb-3 form-check">
28 + <input asp-for="Poll.AllowMultipleVotes" class="form-check-input" />
29 + <label asp-for="Poll.AllowMultipleVotes" class="form-check-label"></label>
30 + </div>
31 + <div class="mb-3 form-check">
32 + <input asp-for="Poll.IsAnonymous" class="form-check-input" />
33 + <label asp-for="Poll.IsAnonymous" class="form-check-label"></label>
34 + </div>
35 + <div class="d-flex gap-2">
36 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
37 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
38 + </div>
39 + </form>
40 + </div>
41 +</div>
42 +
43 +@section Scripts {
44 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
45 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Index.cshtml +73 −0
@@ -0,0 +1,73 @@
1 +@model AdminPollIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-bar-chart me-2"></i>@Localizer["Polls"]</h1>
5 + <p class="lead">@Localizer["Manage polls across trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
17 + </div>
18 + <div class="col-auto">
19 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
20 + </div>
21 + </form>
22 + </div>
23 +</div>
24 +
25 +<div class="admin-card">
26 + <div class="admin-card-body p-0">
27 + @if (Model.Items.Any())
28 + {
29 + <table class="table admin-table mb-0">
30 + <thead>
31 + <tr>
32 + <th>@Localizer["Question"]</th>
33 + <th>@Localizer["Trip"]</th>
34 + <th>@Localizer["Created By"]</th>
35 + <th>@Localizer["Options"]</th>
36 + <th>@Localizer["Multi-Vote"]</th>
37 + <th>@Localizer["Anonymous"]</th>
38 + <th>@Localizer["Closed"]</th>
39 + <th class="text-end">@Localizer["Actions"]</th>
40 + </tr>
41 + </thead>
42 + <tbody>
43 + @foreach (var poll in Model.Items)
44 + {
45 + <tr>
46 + <td>@poll.Question</td>
47 + <td>@poll.Trip?.Name</td>
48 + <td>@(poll.CreatedByUser != null ? $"{poll.CreatedByUser.FirstName} {poll.CreatedByUser.LastName}" : "")</td>
49 + <td>@(poll.Options?.Count ?? 0)</td>
50 + <td>@(poll.AllowMultipleVotes ? Localizer["Yes"] : Localizer["No"])</td>
51 + <td>@(poll.IsAnonymous ? Localizer["Yes"] : Localizer["No"])</td>
52 + <td>@(poll.ClosedAt.HasValue ? Localizer["Yes"] : Localizer["No"])</td>
53 + <td class="text-end">
54 + <div class="admin-action-group">
55 + <a asp-action="Details" asp-route-id="@poll.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
56 + <a asp-action="Edit" asp-route-id="@poll.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
57 + <a asp-action="Delete" asp-route-id="@poll.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
58 + </div>
59 + </td>
60 + </tr>
61 + }
62 + </tbody>
63 + </table>
64 + }
65 + else
66 + {
67 + <div class="admin-empty">
68 + <i class="bi bi-inbox"></i>
69 + <div>@Localizer["No items yet"]</div>
70 + </div>
71 + }
72 + </div>
73 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Create.cshtml +55 −0
@@ -0,0 +1,55 @@
1 +@model AdminSettlementPaymentFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-coin"></i> @Localizer["Create"] @Localizer["Settlement payment"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <form asp-action="Create">
13 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
14 +
15 + <div class="form-group mb-3">
16 + <label asp-for="Payment.SettlementPlanId" class="control-label"></label>
17 + <select asp-for="Payment.SettlementPlanId" class="form-control" asp-items="Model.SettlementPlanList"></select>
18 + <span asp-validation-for="Payment.SettlementPlanId" class="text-danger"></span>
19 + </div>
20 +
21 + <div class="form-group mb-3">
22 + <label asp-for="Payment.FromUserId" class="control-label">@Localizer["From user"]</label>
23 + <select asp-for="Payment.FromUserId" class="form-control" asp-items="Model.UserList"></select>
24 + <span asp-validation-for="Payment.FromUserId" class="text-danger"></span>
25 + </div>
26 +
27 + <div class="form-group mb-3">
28 + <label asp-for="Payment.ToUserId" class="control-label">@Localizer["To user"]</label>
29 + <select asp-for="Payment.ToUserId" class="form-control" asp-items="Model.UserList"></select>
30 + <span asp-validation-for="Payment.ToUserId" class="text-danger"></span>
31 + </div>
32 +
33 + <div class="form-group mb-3">
34 + <label asp-for="Payment.Amount" class="control-label"></label>
35 + <input asp-for="Payment.Amount" class="form-control" />
36 + <span asp-validation-for="Payment.Amount" class="text-danger"></span>
37 + </div>
38 +
39 + <div class="form-group mb-3">
40 + <label asp-for="Payment.Status" class="control-label"></label>
41 + <select asp-for="Payment.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.EPaymentStatus>()"></select>
42 + <span asp-validation-for="Payment.Status" class="text-danger"></span>
43 + </div>
44 +
45 + <div class="form-group mb-3 d-flex gap-2">
46 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
47 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
48 + </div>
49 + </form>
50 + </div>
51 +</div>
52 +
53 +@section Scripts {
54 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
55 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Delete.cshtml +39 −0
@@ -0,0 +1,39 @@
1 +@model AdminDeleteViewModel<SettlementPaymentBllDto>
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-stack text-danger"></i> @Localizer["Delete settlement payment"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card border-danger">
11 + <div class="admin-card-body">
12 + <div class="alert alert-danger">
13 + <i class="bi bi-exclamation-triangle me-2"></i>
14 + @Localizer["Are you sure you want to delete this settlement payment?"]
15 + </div>
16 +
17 + <dl class="row">
18 + <dt class="col-sm-3">@Localizer["From user"]</dt>
19 + <dd class="col-sm-9">@Model.Item.FromUserFullName</dd>
20 +
21 + <dt class="col-sm-3">@Localizer["To user"]</dt>
22 + <dd class="col-sm-9">@Model.Item.ToUserFullName</dd>
23 +
24 + <dt class="col-sm-3">@Localizer["Amount"]</dt>
25 + <dd class="col-sm-9">@Model.Item.Amount.ToString("0.00")</dd>
26 +
27 + <dt class="col-sm-3">@Localizer["Status"]</dt>
28 + <dd class="col-sm-9">@Model.Item.Status</dd>
29 + </dl>
30 +
31 + <form asp-action="Delete">
32 + <input type="hidden" name="id" value="@Model.Item.Id" />
33 + <div class="d-flex gap-2">
34 + <button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</button>
35 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
36 + </div>
37 + </form>
38 + </div>
39 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Details.cshtml +43 −0
@@ -0,0 +1,43 @@
1 +@model AdminDetailsViewModel<SettlementPaymentBllDto>
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-coin"></i> @Localizer["Settlement payment details"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <dl class="row">
13 + <dt class="col-sm-3">@Localizer["From user"]</dt>
14 + <dd class="col-sm-9">@Model.Item.FromUserFullName <small class="text-muted">@Model.Item.FromUser?.Email</small></dd>
15 +
16 + <dt class="col-sm-3">@Localizer["To user"]</dt>
17 + <dd class="col-sm-9">@Model.Item.ToUserFullName <small class="text-muted">@Model.Item.ToUser?.Email</small></dd>
18 +
19 + <dt class="col-sm-3">@Localizer["Amount"]</dt>
20 + <dd class="col-sm-9">@Model.Item.Amount.ToString("0.00")</dd>
21 +
22 + <dt class="col-sm-3">@Localizer["Status"]</dt>
23 + <dd class="col-sm-9"><span class="badge bg-secondary">@Model.Item.Status</span></dd>
24 +
25 + @if (Model.Item.MarkedPaidAt.HasValue)
26 + {
27 + <dt class="col-sm-3">@Localizer["MarkedPaidAt"]</dt>
28 + <dd class="col-sm-9">@Model.Item.MarkedPaidAt.Value.ToString("yyyy-MM-dd HH:mm")</dd>
29 + }
30 +
31 + @if (Model.Item.ConfirmedAt.HasValue)
32 + {
33 + <dt class="col-sm-3">@Localizer["ConfirmedAt"]</dt>
34 + <dd class="col-sm-9">@Model.Item.ConfirmedAt.Value.ToString("yyyy-MM-dd HH:mm")</dd>
35 + }
36 + </dl>
37 +
38 + <div class="d-flex gap-2">
39 + <a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary"><i class="bi bi-pencil me-1"></i>@Localizer["Edit"]</a>
40 + <a asp-action="Delete" asp-route-id="@Model.Item.Id" class="btn btn-outline-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</a>
41 + </div>
42 + </div>
43 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Edit.cshtml +56 −0
@@ -0,0 +1,56 @@
1 +@model AdminSettlementPaymentFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-stack"></i> @Localizer["Edit"] @Localizer["Settlement payment"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <form asp-action="Edit">
13 + <input type="hidden" asp-for="Payment.Id" />
14 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
15 +
16 + <div class="form-group mb-3">
17 + <label asp-for="Payment.SettlementPlanId" class="control-label"></label>
18 + <select asp-for="Payment.SettlementPlanId" class="form-control" asp-items="Model.SettlementPlanList"></select>
19 + <span asp-validation-for="Payment.SettlementPlanId" class="text-danger"></span>
20 + </div>
21 +
22 + <div class="form-group mb-3">
23 + <label asp-for="Payment.FromUserId" class="control-label">@Localizer["From user"]</label>
24 + <select asp-for="Payment.FromUserId" class="form-control" asp-items="Model.UserList"></select>
25 + <span asp-validation-for="Payment.FromUserId" class="text-danger"></span>
26 + </div>
27 +
28 + <div class="form-group mb-3">
29 + <label asp-for="Payment.ToUserId" class="control-label">@Localizer["To user"]</label>
30 + <select asp-for="Payment.ToUserId" class="form-control" asp-items="Model.UserList"></select>
31 + <span asp-validation-for="Payment.ToUserId" class="text-danger"></span>
32 + </div>
33 +
34 + <div class="form-group mb-3">
35 + <label asp-for="Payment.Amount" class="control-label"></label>
36 + <input asp-for="Payment.Amount" class="form-control" />
37 + <span asp-validation-for="Payment.Amount" class="text-danger"></span>
38 + </div>
39 +
40 + <div class="form-group mb-3">
41 + <label asp-for="Payment.Status" class="control-label"></label>
42 + <select asp-for="Payment.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.EPaymentStatus>()"></select>
43 + <span asp-validation-for="Payment.Status" class="text-danger"></span>
44 + </div>
45 +
46 + <div class="form-group mb-3 d-flex gap-2">
47 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
48 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
49 + </div>
50 + </form>
51 + </div>
52 +</div>
53 +
54 +@section Scripts {
55 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
56 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Index.cshtml +67 −0
@@ -0,0 +1,67 @@
1 +@model AdminSettlementPaymentIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-credit-card-2-front me-2"></i>@Localizer["SettlementPayments"]</h1>
5 + <p class="lead">@Localizer["Review settlement payments across trips"]</p>
6 + </div>
7 +</div>
8 +
9 +<div class="admin-card mb-3">
10 + <div class="admin-card-body">
11 + <form method="get" class="row g-2 align-items-end">
12 + <div class="col-auto">
13 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
14 + </div>
15 + <div class="col-auto">
16 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
17 + </div>
18 + </form>
19 + </div>
20 +</div>
21 +
22 +<div class="admin-card">
23 + <div class="admin-card-body p-0">
24 + @if (Model.Items.Any())
25 + {
26 + <table class="table admin-table mb-0">
27 + <thead>
28 + <tr>
29 + <th>@Localizer["FromUser"]</th>
30 + <th>@Localizer["ToUser"]</th>
31 + <th class="text-end">@Localizer["Amount"]</th>
32 + <th>@Localizer["MarkedPaidAt"]</th>
33 + <th>@Localizer["ConfirmedAt"]</th>
34 + <th>@Localizer["Status"]</th>
35 + <th class="text-end">@Localizer["Actions"]</th>
36 + </tr>
37 + </thead>
38 + <tbody>
39 + @foreach (var item in Model.Items)
40 + {
41 + <tr>
42 + <td>@item.FromUser?.Email</td>
43 + <td>@item.ToUser?.Email</td>
44 + <td class="text-end fw-bold">@item.Amount.ToString("F2")</td>
45 + <td>@item.MarkedPaidAt?.ToString("g")</td>
46 + <td>@item.ConfirmedAt?.ToString("g")</td>
47 + <td><span class="badge status-@item.Status.ToString().ToLower()">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td>
48 + <td class="text-end">
49 + <div class="admin-action-group">
50 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
51 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
52 + </div>
53 + </td>
54 + </tr>
55 + }
56 + </tbody>
57 + </table>
58 + }
59 + else
60 + {
61 + <div class="admin-empty">
62 + <i class="bi bi-inbox"></i>
63 + <div>@Localizer["No items yet"]</div>
64 + </div>
65 + }
66 + </div>
67 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Create.cshtml +52 −0
@@ -0,0 +1,52 @@
1 +@model AdminSettlementPlanFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-coin"></i> @Localizer["Create"] @Localizer["SettlementPlan"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["SettlementPlan"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="SettlementPlan.TripId" class="control-label"></label>
19 + <select asp-for="SettlementPlan.TripId" class="form-control" asp-items="Model.TripList"></select>
20 + <span asp-validation-for="SettlementPlan.TripId" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label asp-for="SettlementPlan.CreatedByUserId" class="control-label"></label>
24 + <select asp-for="SettlementPlan.CreatedByUserId" class="form-control" asp-items="Model.UserList"></select>
25 + <span asp-validation-for="SettlementPlan.CreatedByUserId" class="text-danger"></span>
26 + </div>
27 + <div class="form-group mb-3">
28 + <label asp-for="SettlementPlan.TotalAmount" class="control-label"></label>
29 + <input asp-for="SettlementPlan.TotalAmount" class="form-control" />
30 + <span asp-validation-for="SettlementPlan.TotalAmount" class="text-danger"></span>
31 + </div>
32 + <div class="form-group mb-3">
33 + <label asp-for="SettlementPlan.Status" class="control-label"></label>
34 + <select asp-for="SettlementPlan.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESettlementStatus>()"></select>
35 + <span asp-validation-for="SettlementPlan.Status" class="text-danger"></span>
36 + </div>
37 + <div class="form-group mb-3">
38 + <label asp-for="SettlementPlan.CompletedAt" class="control-label"></label>
39 + <input asp-for="SettlementPlan.CompletedAt" class="form-control" type="date" />
40 + <span asp-validation-for="SettlementPlan.CompletedAt" class="text-danger"></span>
41 + </div>
42 + <div class="form-group mb-3 d-flex gap-2">
43 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
44 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
45 + </div>
46 + </form>
47 + </div>
48 +</div>
49 +
50 +@section Scripts {
51 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
52 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Delete.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDeleteViewModel<SettlementPlanBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["SettlementPlan"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["TotalAmount"]</dt>
14 + <dd class="col-sm-10">@Model.Item.TotalAmount</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Status"]</dt>
17 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["CreatedByUser"]</dt>
20 + <dd class="col-sm-10">@Model.Item.CreatedByUser?.Email</dd>
21 + </dl>
22 +
23 + <form asp-action="Delete">
24 + <input type="hidden" asp-for="Item.Id" />
25 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
26 + <a asp-action="Index">@Localizer["Back"]</a>
27 + </form>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Details.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDetailsViewModel<SettlementPlanBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["SettlementPlan"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["CreatedByUser"]</dt>
13 + <dd class="col-sm-10">@Model.Item.CreatedByUser?.Email</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["TotalAmount"]</dt>
16 + <dd class="col-sm-10">@Model.Item.TotalAmount</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["Status"]</dt>
19 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["CompletedAt"]</dt>
22 + <dd class="col-sm-10">@Model.Item.CompletedAt?.ToString("d")</dd>
23 + </dl>
24 +</div>
25 +<div>
26 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
27 + <a asp-action="Index">@Localizer["Back"]</a>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Edit.cshtml +53 −0
@@ -0,0 +1,53 @@
1 +@model AdminSettlementPlanFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-cash-coin"></i> @Localizer["Edit"] @Localizer["SettlementPlan"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["SettlementPlan"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="SettlementPlan.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="SettlementPlan.TripId" class="control-label"></label>
20 + <select asp-for="SettlementPlan.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="SettlementPlan.TripId" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label asp-for="SettlementPlan.CreatedByUserId" class="control-label"></label>
25 + <select asp-for="SettlementPlan.CreatedByUserId" class="form-control" asp-items="Model.UserList"></select>
26 + <span asp-validation-for="SettlementPlan.CreatedByUserId" class="text-danger"></span>
27 + </div>
28 + <div class="form-group mb-3">
29 + <label asp-for="SettlementPlan.TotalAmount" class="control-label"></label>
30 + <input asp-for="SettlementPlan.TotalAmount" class="form-control" />
31 + <span asp-validation-for="SettlementPlan.TotalAmount" class="text-danger"></span>
32 + </div>
33 + <div class="form-group mb-3">
34 + <label asp-for="SettlementPlan.Status" class="control-label"></label>
35 + <select asp-for="SettlementPlan.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESettlementStatus>()"></select>
36 + <span asp-validation-for="SettlementPlan.Status" class="text-danger"></span>
37 + </div>
38 + <div class="form-group mb-3">
39 + <label asp-for="SettlementPlan.CompletedAt" class="control-label"></label>
40 + <input asp-for="SettlementPlan.CompletedAt" class="form-control" type="date" />
41 + <span asp-validation-for="SettlementPlan.CompletedAt" class="text-danger"></span>
42 + </div>
43 + <div class="form-group mb-3 d-flex gap-2">
44 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
45 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
46 + </div>
47 + </form>
48 + </div>
49 +</div>
50 +
51 +@section Scripts {
52 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
53 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Index.cshtml +73 −0
@@ -0,0 +1,73 @@
1 +@model AdminSettlementPlanIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-diagram-3 me-2"></i>@Localizer["SettlementPlans"]</h1>
5 + <p class="lead">@Localizer["Manage settlement plans for trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <select name="tripId" class="form-select">
17 + <option value="">— @Localizer["All"] —</option>
18 + @foreach (var t in Model.Trips)
19 + {
20 + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option>
21 + }
22 + </select>
23 + </div>
24 + <div class="col-auto">
25 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
26 + </div>
27 + </form>
28 + </div>
29 +</div>
30 +
31 +<div class="admin-card">
32 + <div class="admin-card-body p-0">
33 + @if (Model.Items.Any())
34 + {
35 + <table class="table admin-table mb-0">
36 + <thead>
37 + <tr>
38 + <th>@Localizer["Trip"]</th>
39 + <th class="text-end">@Localizer["TotalAmount"]</th>
40 + <th>@Localizer["CreatedByUser"]</th>
41 + <th>@Localizer["Status"]</th>
42 + <th class="text-end">@Localizer["Actions"]</th>
43 + </tr>
44 + </thead>
45 + <tbody>
46 + @foreach (var item in Model.Items)
47 + {
48 + <tr>
49 + <td>@item.Trip?.Name</td>
50 + <td class="text-end fw-bold">@item.TotalAmount</td>
51 + <td>@item.CreatedByUser?.Email</td>
52 + <td><span class="badge status-@item.Status.ToString().ToLower()">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td>
53 + <td class="text-end">
54 + <div class="admin-action-group">
55 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
56 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
57 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
58 + </div>
59 + </td>
60 + </tr>
61 + }
62 + </tbody>
63 + </table>
64 + }
65 + else
66 + {
67 + <div class="admin-empty">
68 + <i class="bi bi-inbox"></i>
69 + <div>@Localizer["No items yet"]</div>
70 + </div>
71 + }
72 + </div>
73 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Shared/_Layout.cshtml +124 −0
@@ -0,0 +1,124 @@
1 +@{
2 + var pageTitle = (Model as ITitledViewModel)?.Title;
3 + if (string.IsNullOrWhiteSpace(pageTitle)) pageTitle = "Admin";
4 + var ctx = ViewContext.RouteData.Values;
5 + var currentController = (ctx["controller"] as string ?? "").ToLowerInvariant();
6 + bool IsActive(string name) => currentController == name.ToLowerInvariant();
7 +}
8 +
9 +<!DOCTYPE html>
10 +<html lang="@Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName">
11 +<head>
12 + <meta charset="utf-8" />
13 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
14 + <meta name="theme-color" content="#e8604c" />
15 + <title>@pageTitle - SplitApp Admin</title>
16 +
17 + <link rel="preconnect" href="https://fonts.googleapis.com" />
18 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
19 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
20 +
21 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
22 + <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
23 + <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
24 + <link rel="stylesheet" href="~/css/splitapp-design.css" asp-append-version="true" />
25 + <link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" />
26 +</head>
27 +<body class="admin-body">
28 + <div id="sa-toast-container" class="sa-toast-container"></div>
29 + <div id="sa-tempdata-messages" style="display:none"
30 + data-success="@TempData["Success"]"
31 + data-error="@TempData["Error"]"
32 + data-warning="@TempData["Warning"]"></div>
33 +
34 + <div class="admin-shell">
35 + <aside class="admin-sidebar">
36 + <div class="admin-sidebar-brand">
37 + <i class="bi bi-airplane-fill"></i>
38 + <span>SplitApp</span>
39 + <small class="admin-sidebar-brand-sub">Admin</small>
40 + </div>
41 + <nav class="admin-sidebar-nav">
42 + <a class="admin-nav-link @(IsActive("Dashboard") ? "active" : "")" asp-area="Admin" asp-controller="Dashboard" asp-action="Index">
43 + <i class="bi bi-speedometer2"></i><span>@Localizer["Dashboard"]</span>
44 + </a>
45 + <div class="admin-nav-section">@Localizer["Core"]</div>
46 + <a class="admin-nav-link @(IsActive("Trips") ? "active" : "")" asp-area="Admin" asp-controller="Trips" asp-action="Index">
47 + <i class="bi bi-suitcase-lg"></i><span>@Localizer["Trips"]</span>
48 + </a>
49 + <a class="admin-nav-link @(IsActive("Expenses") ? "active" : "")" asp-area="Admin" asp-controller="Expenses" asp-action="Index">
50 + <i class="bi bi-cash-coin"></i><span>@Localizer["Expenses"]</span>
51 + </a>
52 + <a class="admin-nav-link @(IsActive("BudgetCategories") ? "active" : "")" asp-area="Admin" asp-controller="BudgetCategories" asp-action="Index">
53 + <i class="bi bi-tags"></i><span>@Localizer["Budget Categories"]</span>
54 + </a>
55 + <a class="admin-nav-link @(IsActive("Currencies") ? "active" : "")" asp-area="Admin" asp-controller="Currencies" asp-action="Index">
56 + <i class="bi bi-currency-exchange"></i><span>@Localizer["Currencies"]</span>
57 + </a>
58 + <div class="admin-nav-section">@Localizer["Activity"]</div>
59 + <a class="admin-nav-link @(IsActive("Polls") ? "active" : "")" asp-area="Admin" asp-controller="Polls" asp-action="Index">
60 + <i class="bi bi-bar-chart"></i><span>@Localizer["Polls"]</span>
61 + </a>
62 + <a class="admin-nav-link @(IsActive("Wishlist") ? "active" : "")" asp-area="Admin" asp-controller="Wishlist" asp-action="Index">
63 + <i class="bi bi-stars"></i><span>@Localizer["Wishlist"]</span>
64 + </a>
65 + <a class="admin-nav-link @(IsActive("Invitations") ? "active" : "")" asp-area="Admin" asp-controller="Invitations" asp-action="Index">
66 + <i class="bi bi-envelope"></i><span>@Localizer["Invitations"]</span>
67 + </a>
68 + <div class="admin-nav-section">@Localizer["Settlements"]</div>
69 + <a class="admin-nav-link @(IsActive("SettlementPlans") ? "active" : "")" asp-area="Admin" asp-controller="SettlementPlans" asp-action="Index">
70 + <i class="bi bi-diagram-3"></i><span>@Localizer["Settlement Plans"]</span>
71 + </a>
72 + <a class="admin-nav-link @(IsActive("SettlementPayments") ? "active" : "")" asp-area="Admin" asp-controller="SettlementPayments" asp-action="Index">
73 + <i class="bi bi-credit-card-2-front"></i><span>@Localizer["Settlement Payments"]</span>
74 + </a>
75 + <a class="admin-nav-link @(IsActive("SplitPresets") ? "active" : "")" asp-area="Admin" asp-controller="SplitPresets" asp-action="Index">
76 + <i class="bi bi-pie-chart"></i><span>@Localizer["Split Presets"]</span>
77 + </a>
78 + <a class="admin-nav-link @(IsActive("TripParticipants") ? "active" : "")" asp-area="Admin" asp-controller="TripParticipants" asp-action="Index">
79 + <i class="bi bi-people"></i><span>@Localizer["Trip Participants"]</span>
80 + </a>
81 + <div class="admin-nav-section">@Localizer["System"]</div>
82 + <a class="admin-nav-link @(IsActive("Users") ? "active" : "")" asp-area="Admin" asp-controller="Users" asp-action="Index">
83 + <i class="bi bi-person-gear"></i><span>@Localizer["Users"]</span>
84 + </a>
85 + </nav>
86 + <div class="admin-sidebar-footer">
87 + <a asp-area="" asp-controller="Home" asp-action="Index" class="admin-back-link">
88 + <i class="bi bi-arrow-left"></i>@Localizer["Back to site"]
89 + </a>
90 + </div>
91 + </aside>
92 +
93 + <div class="admin-main">
94 + <header class="admin-topbar">
95 + <button class="admin-topbar-toggle d-md-none" type="button" onclick="document.body.classList.toggle('admin-sidebar-open')" aria-label="Toggle sidebar">
96 + <i class="bi bi-list"></i>
97 + </button>
98 + <div class="admin-topbar-title">
99 + <i class="bi bi-shield-lock"></i> @pageTitle
100 + </div>
101 + <div class="admin-topbar-actions">
102 + <partial name="_LanguageSelection" />
103 + <partial name="_LoginPartial" />
104 + </div>
105 + </header>
106 +
107 + <main role="main" class="admin-content sa-animate-fade-in">
108 + @RenderBody()
109 + </main>
110 +
111 + <footer class="admin-footer">
112 + <span><i class="bi bi-airplane-fill me-1"></i> SplitApp Admin &copy; 2026 SplitApp</span>
113 + <span>@Thread.CurrentThread.CurrentUICulture.Name</span>
114 + </footer>
115 + </div>
116 + </div>
117 +
118 + <script src="~/lib/jquery/dist/jquery.min.js"></script>
119 + <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
120 + <script src="~/js/splitapp.js" asp-append-version="true"></script>
121 + <script src="~/js/site.js" asp-append-version="true"></script>
122 + @await RenderSectionAsync("Scripts", required: false)
123 +</body>
124 +</html>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Create.cshtml +52 −0
@@ -0,0 +1,52 @@
1 +@model AdminSplitPresetFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-pie-chart"></i> @Localizer["Create"] @Localizer["SplitPreset"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["SplitPreset"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 +
18 + <div class="form-group mb-3">
19 + <label asp-for="SplitPreset.Name" class="control-label"></label>
20 + <input asp-for="SplitPreset.Name" class="form-control" />
21 + <span asp-validation-for="SplitPreset.Name" class="text-danger"></span>
22 + </div>
23 +
24 + <div class="form-group mb-3">
25 + <label asp-for="SplitPreset.SplitMethod" class="control-label"></label>
26 + <select asp-for="SplitPreset.SplitMethod" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Expenses.Domain.Enums.ESplitMethod>()"></select>
27 + <span asp-validation-for="SplitPreset.SplitMethod" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="form-group mb-3">
31 + <label asp-for="SplitPreset.TripId" class="control-label">@Localizer["Trip"]</label>
32 + <select asp-for="SplitPreset.TripId" class="form-control" asp-items="Model.TripList"></select>
33 + <span asp-validation-for="SplitPreset.TripId" class="text-danger"></span>
34 + </div>
35 +
36 + <div class="form-group mb-3">
37 + <label asp-for="SplitPreset.CreatedById" class="control-label">@Localizer["CreatedBy"]</label>
38 + <select asp-for="SplitPreset.CreatedById" class="form-control" asp-items="Model.UserList"></select>
39 + <span asp-validation-for="SplitPreset.CreatedById" class="text-danger"></span>
40 + </div>
41 +
42 + <div class="form-group mb-3 d-flex gap-2">
43 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
44 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
45 + </div>
46 + </form>
47 + </div>
48 +</div>
49 +
50 +@section Scripts {
51 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
52 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Delete.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDeleteViewModel<SplitPresetBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["SplitPreset"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Name"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["SplitMethod"]</dt>
14 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.SplitMethod)</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
17 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt>
20 + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd>
21 + </dl>
22 +
23 + <form asp-action="Delete">
24 + <input type="hidden" asp-for="Item.Id" />
25 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
26 + <a asp-action="Index">@Localizer["Back"]</a>
27 + </form>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Details.cshtml +50 −0
@@ -0,0 +1,50 @@
1 +@model AdminDetailsViewModel<SplitPresetBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["SplitPreset"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Name"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["SplitMethod"]</dt>
13 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.SplitMethod)</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
16 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt>
19 + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd>
20 + </dl>
21 +</div>
22 +
23 +@if (Model.Item.Members != null && Model.Item.Members.Any())
24 +{
25 + <h5>@Localizer["Members"]</h5>
26 + <table class="table">
27 + <thead>
28 + <tr>
29 + <th>@Localizer["User"]</th>
30 + <th>@Localizer["ShareWeight"]</th>
31 + <th>@Localizer["Percentage"]</th>
32 + </tr>
33 + </thead>
34 + <tbody>
35 + @foreach (var member in Model.Item.Members)
36 + {
37 + <tr>
38 + <td>@member.UserFullName</td>
39 + <td>@member.ShareWeight</td>
40 + <td>@(member.Percentage != null ? $"{member.Percentage}%" : "")</td>
41 + </tr>
42 + }
43 + </tbody>
44 + </table>
45 +}
46 +
47 +<div>
48 + <a asp-action="Delete" asp-route-id="@Model.Item.Id">@Localizer["Delete"]</a> |
49 + <a asp-action="Index">@Localizer["Back"]</a>
50 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Index.cshtml +64 −0
@@ -0,0 +1,64 @@
1 +@model AdminSplitPresetIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-pie-chart me-2"></i>@Localizer["SplitPresets"]</h1>
5 + <p class="lead">@Localizer["Review split presets used on trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary"><i class="bi bi-plus-circle me-1"></i>@Localizer["Create"]</a>
8 +</div>
9 +
10 +<div class="admin-card mb-3">
11 + <div class="admin-card-body">
12 + <form method="get" class="row g-2 align-items-end">
13 + <div class="col-auto">
14 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
15 + </div>
16 + <div class="col-auto">
17 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
18 + </div>
19 + </form>
20 + </div>
21 +</div>
22 +
23 +<div class="admin-card">
24 + <div class="admin-card-body p-0">
25 + @if (Model.Items.Any())
26 + {
27 + <table class="table admin-table mb-0">
28 + <thead>
29 + <tr>
30 + <th>@Localizer["Name"]</th>
31 + <th>@Localizer["SplitMethod"]</th>
32 + <th>@Localizer["Trip"]</th>
33 + <th>@Localizer["CreatedBy"]</th>
34 + <th class="text-end">@Localizer["Actions"]</th>
35 + </tr>
36 + </thead>
37 + <tbody>
38 + @foreach (var item in Model.Items)
39 + {
40 + <tr>
41 + <td>@item.Name</td>
42 + <td>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.SplitMethod)</td>
43 + <td>@item.Trip?.Name</td>
44 + <td>@item.CreatedBy?.Email</td>
45 + <td class="text-end">
46 + <div class="admin-action-group">
47 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
48 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
49 + </div>
50 + </td>
51 + </tr>
52 + }
53 + </tbody>
54 + </table>
55 + }
56 + else
57 + {
58 + <div class="admin-empty">
59 + <i class="bi bi-inbox"></i>
60 + <div>@Localizer["No items yet"]</div>
61 + </div>
62 + }
63 + </div>
64 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Create.cshtml +53 −0
@@ -0,0 +1,53 @@
1 +@model AdminTripParticipantFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-people-fill"></i> @Localizer["Create"] @Localizer["TripParticipant"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["TripParticipant"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="TripParticipant.TripId" class="control-label"></label>
19 + <select asp-for="TripParticipant.TripId" class="form-control" asp-items="Model.TripList"></select>
20 + <span asp-validation-for="TripParticipant.TripId" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label asp-for="TripParticipant.UserId" class="control-label"></label>
24 + <select asp-for="TripParticipant.UserId" class="form-control" asp-items="Model.UserList"></select>
25 + <span asp-validation-for="TripParticipant.UserId" class="text-danger"></span>
26 + </div>
27 + <div class="form-group mb-3">
28 + <label asp-for="TripParticipant.Role" class="control-label"></label>
29 + <select asp-for="TripParticipant.Role" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EParticipantRole>()"></select>
30 + <span asp-validation-for="TripParticipant.Role" class="text-danger"></span>
31 + </div>
32 + <div class="form-group mb-3">
33 + <label asp-for="TripParticipant.Nickname" class="control-label"></label>
34 + <input asp-for="TripParticipant.Nickname" class="form-control" />
35 + <span asp-validation-for="TripParticipant.Nickname" class="text-danger"></span>
36 + </div>
37 + <div class="form-group mb-3">
38 + <div class="form-check">
39 + <input asp-for="TripParticipant.IsActive" class="form-check-input" />
40 + <label asp-for="TripParticipant.IsActive" class="form-check-label"></label>
41 + </div>
42 + </div>
43 + <div class="form-group mb-3 d-flex gap-2">
44 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
45 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
46 + </div>
47 + </form>
48 + </div>
49 +</div>
50 +
51 +@section Scripts {
52 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
53 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Delete.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDeleteViewModel<TripParticipantBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["TripParticipant"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["User"]</dt>
14 + <dd class="col-sm-10">@Model.Item.User?.Email</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Role"]</dt>
17 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Role)</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["IsActive"]</dt>
20 + <dd class="col-sm-10">@(Model.Item.IsActive ? Localizer["Yes"] : Localizer["No"])</dd>
21 + </dl>
22 +
23 + <form asp-action="Delete">
24 + <input type="hidden" asp-for="Item.Id" />
25 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
26 + <a asp-action="Index">@Localizer["Back"]</a>
27 + </form>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Details.cshtml +34 −0
@@ -0,0 +1,34 @@
1 +@model AdminDetailsViewModel<TripParticipantBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["TripParticipant"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Trip"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["User"]</dt>
13 + <dd class="col-sm-10">@Model.Item.User?.Email</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["Role"]</dt>
16 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Role)</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["Nickname"]</dt>
19 + <dd class="col-sm-10">@Model.Item.Nickname</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["JoinedAt"]</dt>
22 + <dd class="col-sm-10">@Model.Item.JoinedAt.ToString("d")</dd>
23 +
24 + <dt class="col-sm-2">@Localizer["LeftAt"]</dt>
25 + <dd class="col-sm-10">@Model.Item.LeftAt?.ToString("d")</dd>
26 +
27 + <dt class="col-sm-2">@Localizer["IsActive"]</dt>
28 + <dd class="col-sm-10">@(Model.Item.IsActive ? Localizer["Yes"] : Localizer["No"])</dd>
29 + </dl>
30 +</div>
31 +<div>
32 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
33 + <a asp-action="Index">@Localizer["Back"]</a>
34 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Edit.cshtml +54 −0
@@ -0,0 +1,54 @@
1 +@model AdminTripParticipantFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-people-fill"></i> @Localizer["Edit"] @Localizer["TripParticipant"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["TripParticipant"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="TripParticipant.Id" />
18 + <div class="form-group mb-3">
19 + <label asp-for="TripParticipant.TripId" class="control-label"></label>
20 + <select asp-for="TripParticipant.TripId" class="form-control" asp-items="Model.TripList"></select>
21 + <span asp-validation-for="TripParticipant.TripId" class="text-danger"></span>
22 + </div>
23 + <div class="form-group mb-3">
24 + <label asp-for="TripParticipant.UserId" class="control-label"></label>
25 + <select asp-for="TripParticipant.UserId" class="form-control" asp-items="Model.UserList"></select>
26 + <span asp-validation-for="TripParticipant.UserId" class="text-danger"></span>
27 + </div>
28 + <div class="form-group mb-3">
29 + <label asp-for="TripParticipant.Role" class="control-label"></label>
30 + <select asp-for="TripParticipant.Role" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EParticipantRole>()"></select>
31 + <span asp-validation-for="TripParticipant.Role" class="text-danger"></span>
32 + </div>
33 + <div class="form-group mb-3">
34 + <label asp-for="TripParticipant.Nickname" class="control-label"></label>
35 + <input asp-for="TripParticipant.Nickname" class="form-control" />
36 + <span asp-validation-for="TripParticipant.Nickname" class="text-danger"></span>
37 + </div>
38 + <div class="form-group mb-3">
39 + <div class="form-check">
40 + <input asp-for="TripParticipant.IsActive" class="form-check-input" />
41 + <label asp-for="TripParticipant.IsActive" class="form-check-label"></label>
42 + </div>
43 + </div>
44 + <div class="form-group mb-3 d-flex gap-2">
45 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
46 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
47 + </div>
48 + </form>
49 + </div>
50 +</div>
51 +
52 +@section Scripts {
53 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
54 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Index.cshtml +78 −0
@@ -0,0 +1,78 @@
1 +@model AdminTripParticipantIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-people me-2"></i>@Localizer["TripParticipants"]</h1>
5 + <p class="lead">@Localizer["Manage trip participants and roles"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <select name="tripId" class="form-select">
17 + <option value="">— @Localizer["All"] —</option>
18 + @foreach (var t in Model.Trips)
19 + {
20 + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option>
21 + }
22 + </select>
23 + </div>
24 + <div class="col-auto">
25 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
26 + </div>
27 + <div class="col-auto">
28 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
29 + </div>
30 + </form>
31 + </div>
32 +</div>
33 +
34 +<div class="admin-card">
35 + <div class="admin-card-body p-0">
36 + @if (Model.Items.Any())
37 + {
38 + <table class="table admin-table mb-0">
39 + <thead>
40 + <tr>
41 + <th>@Localizer["User"]</th>
42 + <th>@Localizer["Trip"]</th>
43 + <th>@Localizer["Role"]</th>
44 + <th>@Localizer["JoinedAt"]</th>
45 + <th>@Localizer["IsActive"]</th>
46 + <th class="text-end">@Localizer["Actions"]</th>
47 + </tr>
48 + </thead>
49 + <tbody>
50 + @foreach (var item in Model.Items)
51 + {
52 + <tr>
53 + <td>@item.User?.Email</td>
54 + <td>@item.Trip?.Name</td>
55 + <td>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Role)</td>
56 + <td>@item.JoinedAt.ToString("d")</td>
57 + <td>@(item.IsActive ? Localizer["Yes"] : Localizer["No"])</td>
58 + <td class="text-end">
59 + <div class="admin-action-group">
60 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
61 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
62 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
63 + </div>
64 + </td>
65 + </tr>
66 + }
67 + </tbody>
68 + </table>
69 + }
70 + else
71 + {
72 + <div class="admin-empty">
73 + <i class="bi bi-inbox"></i>
74 + <div>@Localizer["No items yet"]</div>
75 + </div>
76 + }
77 + </div>
78 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Create.cshtml +62 −0
@@ -0,0 +1,62 @@
1 +@model AdminTripFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-geo-alt-fill"></i> @Localizer["Create"] @Localizer["Trip"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Trip"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="form-group mb-3">
18 + <label asp-for="Trip.Name" class="control-label"></label>
19 + <input asp-for="Trip.Name" class="form-control" />
20 + <span asp-validation-for="Trip.Name" class="text-danger"></span>
21 + </div>
22 + <div class="form-group mb-3">
23 + <label asp-for="Trip.Description" class="control-label"></label>
24 + <textarea asp-for="Trip.Description" class="form-control"></textarea>
25 + <span asp-validation-for="Trip.Description" class="text-danger"></span>
26 + </div>
27 + <div class="form-group mb-3">
28 + <label asp-for="Trip.Destination" class="control-label"></label>
29 + <input asp-for="Trip.Destination" class="form-control" />
30 + <span asp-validation-for="Trip.Destination" class="text-danger"></span>
31 + </div>
32 + <div class="form-group mb-3">
33 + <label asp-for="Trip.StartDate" class="control-label"></label>
34 + <input asp-for="Trip.StartDate" class="form-control" type="date" />
35 + <span asp-validation-for="Trip.StartDate" class="text-danger"></span>
36 + </div>
37 + <div class="form-group mb-3">
38 + <label asp-for="Trip.EndDate" class="control-label"></label>
39 + <input asp-for="Trip.EndDate" class="form-control" type="date" />
40 + <span asp-validation-for="Trip.EndDate" class="text-danger"></span>
41 + </div>
42 + <div class="form-group mb-3">
43 + <label asp-for="Trip.Status" class="control-label"></label>
44 + <select asp-for="Trip.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.ETripStatus>()"></select>
45 + <span asp-validation-for="Trip.Status" class="text-danger"></span>
46 + </div>
47 + <div class="form-group mb-3">
48 + <label asp-for="Trip.DefaultCurrencyId" class="control-label"></label>
49 + <select asp-for="Trip.DefaultCurrencyId" class="form-control" asp-items="Model.CurrencyList"></select>
50 + <span asp-validation-for="Trip.DefaultCurrencyId" class="text-danger"></span>
51 + </div>
52 + <div class="form-group mb-3 d-flex gap-2">
53 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
54 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
55 + </div>
56 + </form>
57 + </div>
58 +</div>
59 +
60 +@section Scripts {
61 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
62 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Delete.cshtml +28 −0
@@ -0,0 +1,28 @@
1 +@model AdminDeleteViewModel<TripBllDto>
2 +
3 +<h1>@Localizer["Delete"]</h1>
4 +
5 +<h3>@Localizer["AreYouSure"]</h3>
6 +<div>
7 + <h4>@Localizer["Trip"]</h4>
8 + <hr />
9 + <dl class="row">
10 + <dt class="col-sm-2">@Localizer["Name"]</dt>
11 + <dd class="col-sm-10">@Model.Item.Name</dd>
12 +
13 + <dt class="col-sm-2">@Localizer["Destination"]</dt>
14 + <dd class="col-sm-10">@Model.Item.Destination</dd>
15 +
16 + <dt class="col-sm-2">@Localizer["Status"]</dt>
17 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd>
18 +
19 + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt>
20 + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd>
21 + </dl>
22 +
23 + <form asp-action="Delete">
24 + <input type="hidden" asp-for="Item.Id" />
25 + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> |
26 + <a asp-action="Index">@Localizer["Back"]</a>
27 + </form>
28 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Details.cshtml +37 −0
@@ -0,0 +1,37 @@
1 +@model AdminDetailsViewModel<TripBllDto>
2 +
3 +<h1>@Localizer["Details"]</h1>
4 +
5 +<div>
6 + <h4>@Localizer["Trip"]</h4>
7 + <hr />
8 + <dl class="row">
9 + <dt class="col-sm-2">@Localizer["Name"]</dt>
10 + <dd class="col-sm-10">@Model.Item.Name</dd>
11 +
12 + <dt class="col-sm-2">@Localizer["Description"]</dt>
13 + <dd class="col-sm-10">@Model.Item.Description</dd>
14 +
15 + <dt class="col-sm-2">@Localizer["Destination"]</dt>
16 + <dd class="col-sm-10">@Model.Item.Destination</dd>
17 +
18 + <dt class="col-sm-2">@Localizer["StartDate"]</dt>
19 + <dd class="col-sm-10">@Model.Item.StartDate?.ToString("d")</dd>
20 +
21 + <dt class="col-sm-2">@Localizer["EndDate"]</dt>
22 + <dd class="col-sm-10">@Model.Item.EndDate?.ToString("d")</dd>
23 +
24 + <dt class="col-sm-2">@Localizer["Status"]</dt>
25 + <dd class="col-sm-10">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd>
26 +
27 + <dt class="col-sm-2">@Localizer["DefaultCurrency"]</dt>
28 + <dd class="col-sm-10">@Model.Item.DefaultCurrency?.Code</dd>
29 +
30 + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt>
31 + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd>
32 + </dl>
33 +</div>
34 +<div>
35 + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> |
36 + <a asp-action="Index">@Localizer["Back"]</a>
37 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Edit.cshtml +64 −0
@@ -0,0 +1,64 @@
1 +@model AdminTripFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-geo-alt-fill"></i> @Localizer["Edit"] @Localizer["Trip"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Trip"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <input type="hidden" asp-for="Trip.Id" />
18 + <input type="hidden" asp-for="Trip.CreatedById" />
19 + <div class="form-group mb-3">
20 + <label asp-for="Trip.Name" class="control-label"></label>
21 + <input asp-for="Trip.Name" class="form-control" />
22 + <span asp-validation-for="Trip.Name" class="text-danger"></span>
23 + </div>
24 + <div class="form-group mb-3">
25 + <label asp-for="Trip.Description" class="control-label"></label>
26 + <textarea asp-for="Trip.Description" class="form-control"></textarea>
27 + <span asp-validation-for="Trip.Description" class="text-danger"></span>
28 + </div>
29 + <div class="form-group mb-3">
30 + <label asp-for="Trip.Destination" class="control-label"></label>
31 + <input asp-for="Trip.Destination" class="form-control" />
32 + <span asp-validation-for="Trip.Destination" class="text-danger"></span>
33 + </div>
34 + <div class="form-group mb-3">
35 + <label asp-for="Trip.StartDate" class="control-label"></label>
36 + <input asp-for="Trip.StartDate" class="form-control" type="date" />
37 + <span asp-validation-for="Trip.StartDate" class="text-danger"></span>
38 + </div>
39 + <div class="form-group mb-3">
40 + <label asp-for="Trip.EndDate" class="control-label"></label>
41 + <input asp-for="Trip.EndDate" class="form-control" type="date" />
42 + <span asp-validation-for="Trip.EndDate" class="text-danger"></span>
43 + </div>
44 + <div class="form-group mb-3">
45 + <label asp-for="Trip.Status" class="control-label"></label>
46 + <select asp-for="Trip.Status" class="form-control" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.ETripStatus>()"></select>
47 + <span asp-validation-for="Trip.Status" class="text-danger"></span>
48 + </div>
49 + <div class="form-group mb-3">
50 + <label asp-for="Trip.DefaultCurrencyId" class="control-label"></label>
51 + <select asp-for="Trip.DefaultCurrencyId" class="form-control" asp-items="Model.CurrencyList"></select>
52 + <span asp-validation-for="Trip.DefaultCurrencyId" class="text-danger"></span>
53 + </div>
54 + <div class="form-group mb-3 d-flex gap-2">
55 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
56 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
57 + </div>
58 + </form>
59 + </div>
60 +</div>
61 +
62 +@section Scripts {
63 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
64 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Index.cshtml +69 −0
@@ -0,0 +1,69 @@
1 +@model AdminTripIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-suitcase-lg me-2"></i>@Localizer["Trips"]</h1>
5 + <p class="lead">@Localizer["Manage all trips across the platform"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
17 + </div>
18 + <div class="col-auto">
19 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
20 + </div>
21 + </form>
22 + </div>
23 +</div>
24 +
25 +<div class="admin-card">
26 + <div class="admin-card-body p-0">
27 + @if (Model.Items.Any())
28 + {
29 + <table class="table admin-table mb-0">
30 + <thead>
31 + <tr>
32 + <th>@Localizer["Name"]</th>
33 + <th>@Localizer["Destination"]</th>
34 + <th>@Localizer["StartDate"]</th>
35 + <th>@Localizer["CreatedBy"]</th>
36 + <th>@Localizer["Status"]</th>
37 + <th class="text-end">@Localizer["Actions"]</th>
38 + </tr>
39 + </thead>
40 + <tbody>
41 + @foreach (var item in Model.Items)
42 + {
43 + <tr>
44 + <td>@item.Name</td>
45 + <td>@item.Destination</td>
46 + <td>@item.StartDate?.ToString("d")</td>
47 + <td>@item.CreatedBy?.Email</td>
48 + <td><span class="badge status-@item.Status.ToString().ToLower()">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td>
49 + <td class="text-end">
50 + <div class="admin-action-group">
51 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
52 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
53 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
54 + </div>
55 + </td>
56 + </tr>
57 + }
58 + </tbody>
59 + </table>
60 + }
61 + else
62 + {
63 + <div class="admin-empty">
64 + <i class="bi bi-inbox"></i>
65 + <div>@Localizer["No items yet"]</div>
66 + </div>
67 + }
68 + </div>
69 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Delete.cshtml +44 −0
@@ -0,0 +1,44 @@
1 +@model AdminUserDetailsViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-person-x text-danger"></i> @Localizer["Delete user"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card border-danger">
11 + <div class="admin-card-body">
12 + <div class="alert alert-danger">
13 + <i class="bi bi-exclamation-triangle me-2"></i>
14 + @Localizer["Are you sure you want to delete this user? This action cannot be undone."]
15 + </div>
16 +
17 + <dl class="row">
18 + <dt class="col-sm-3">@Localizer["Email"]</dt>
19 + <dd class="col-sm-9">@Model.Email</dd>
20 +
21 + <dt class="col-sm-3">@Localizer["FirstName"]</dt>
22 + <dd class="col-sm-9">@Model.FirstName</dd>
23 +
24 + <dt class="col-sm-3">@Localizer["LastName"]</dt>
25 + <dd class="col-sm-9">@Model.LastName</dd>
26 +
27 + <dt class="col-sm-3">@Localizer["Roles"]</dt>
28 + <dd class="col-sm-9">
29 + @foreach (var role in Model.Roles)
30 + {
31 + <span class="badge bg-secondary me-1">@role</span>
32 + }
33 + </dd>
34 + </dl>
35 +
36 + <form asp-action="Delete">
37 + <input type="hidden" name="id" value="@Model.Id" />
38 + <div class="d-flex gap-2">
39 + <button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</button>
40 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
41 + </div>
42 + </form>
43 + </div>
44 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Details.cshtml +44 −0
@@ -0,0 +1,44 @@
1 +@model AdminUserDetailsViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-person-circle"></i> @Localizer["User details"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <dl class="row">
13 + <dt class="col-sm-3">@Localizer["Email"]</dt>
14 + <dd class="col-sm-9">@Model.Email</dd>
15 +
16 + <dt class="col-sm-3">@Localizer["FirstName"]</dt>
17 + <dd class="col-sm-9">@Model.FirstName</dd>
18 +
19 + <dt class="col-sm-3">@Localizer["LastName"]</dt>
20 + <dd class="col-sm-9">@Model.LastName</dd>
21 +
22 + <dt class="col-sm-3">@Localizer["Roles"]</dt>
23 + <dd class="col-sm-9">
24 + @if (Model.Roles.Any())
25 + {
26 + foreach (var role in Model.Roles)
27 + {
28 + <span class="badge bg-secondary me-1">@role</span>
29 + }
30 + }
31 + else
32 + {
33 + <span class="text-muted">@Localizer["No roles"]</span>
34 + }
35 + </dd>
36 + </dl>
37 +
38 + <div class="d-flex gap-2">
39 + <a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-primary"><i class="bi bi-pencil me-1"></i>@Localizer["Edit"]</a>
40 + <a asp-action="EditRoles" asp-route-id="@Model.Id" class="btn btn-outline-primary"><i class="bi bi-shield me-1"></i>@Localizer["Edit roles"]</a>
41 + <a asp-action="Delete" asp-route-id="@Model.Id" class="btn btn-outline-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</a>
42 + </div>
43 + </div>
44 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Edit.cshtml +44 −0
@@ -0,0 +1,44 @@
1 +@model AdminUserEditViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-person-gear"></i> @Localizer["Edit user"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-body">
12 + <form asp-action="Edit">
13 + <input type="hidden" asp-for="Id" />
14 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
15 +
16 + <div class="form-group mb-3">
17 + <label asp-for="Email" class="control-label"></label>
18 + <input asp-for="Email" class="form-control" readonly />
19 + <small class="text-muted">@Localizer["Email is read-only"]</small>
20 + </div>
21 +
22 + <div class="form-group mb-3">
23 + <label asp-for="FirstName" class="control-label"></label>
24 + <input asp-for="FirstName" class="form-control" />
25 + <span asp-validation-for="FirstName" class="text-danger"></span>
26 + </div>
27 +
28 + <div class="form-group mb-3">
29 + <label asp-for="LastName" class="control-label"></label>
30 + <input asp-for="LastName" class="form-control" />
31 + <span asp-validation-for="LastName" class="text-danger"></span>
32 + </div>
33 +
34 + <div class="form-group mb-3 d-flex gap-2">
35 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
36 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
37 + </div>
38 + </form>
39 + </div>
40 +</div>
41 +
42 +@section Scripts {
43 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
44 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/EditRoles.cshtml +29 −0
@@ -0,0 +1,29 @@
1 +@model AdminEditRolesViewModel
2 +
3 +<h1>@Localizer["Edit Roles"]</h1>
4 +<h4>@Model.UserName (@Model.UserEmail)</h4>
5 +<hr />
6 +
7 +<div class="row">
8 + <div class="col-md-4">
9 + <form asp-action="EditRoles">
10 + @for (var i = 0; i < Model.Roles.Count; i++)
11 + {
12 + <input type="hidden" name="Roles[@i].RoleName" value="@Model.Roles[i].RoleName" />
13 + <div class="form-check mb-2">
14 + <input type="checkbox" class="form-check-input"
15 + name="Roles[@i].IsAssigned" value="true"
16 + @(Model.Roles[i].IsAssigned ? "checked" : "") />
17 + <label class="form-check-label">@Model.Roles[i].RoleName</label>
18 + </div>
19 + }
20 + <div class="form-group mt-3">
21 + <input type="submit" value="@Localizer["Save"]" class="btn btn-primary" />
22 + </div>
23 + </form>
24 + </div>
25 +</div>
26 +
27 +<div class="mt-3">
28 + <a asp-action="Index">@Localizer["Back"]</a>
29 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Index.cshtml +46 −0
@@ -0,0 +1,46 @@
1 +@model AdminUserIndexViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-person-gear me-2"></i>@Localizer["User Management"]</h1>
6 + <p class="lead">@Localizer["Users"]</p>
7 + </div>
8 +</div>
9 +
10 +<table class="table table-striped">
11 + <thead>
12 + <tr>
13 + <th>@Localizer["Email"]</th>
14 + <th>@Localizer["Name"]</th>
15 + <th>@Localizer["Roles"]</th>
16 + <th>@Localizer["Actions"]</th>
17 + </tr>
18 + </thead>
19 + <tbody>
20 + @foreach (var user in Model.Users)
21 + {
22 + <tr>
23 + <td>@user.Email</td>
24 + <td>@user.FirstName @user.LastName</td>
25 + <td>
26 + @foreach (var role in user.Roles)
27 + {
28 + <span class="badge bg-primary">@role</span>
29 + }
30 + </td>
31 + <td>
32 + <div class="admin-action-group">
33 + <a asp-action="Details" asp-route-id="@user.Id" class="btn btn-sm btn-outline-secondary" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
34 + <a asp-action="Edit" asp-route-id="@user.Id" class="btn btn-sm btn-outline-primary" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
35 + <a asp-action="EditRoles" asp-route-id="@user.Id" class="btn btn-sm btn-outline-primary" title="@Localizer["Edit Roles"]"><i class="bi bi-shield"></i></a>
36 + <a asp-action="Delete" asp-route-id="@user.Id" class="btn btn-sm btn-outline-danger" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
37 + </div>
38 + </td>
39 + </tr>
40 + }
41 + </tbody>
42 +</table>
43 +
44 +<div>
45 + <a asp-area="Admin" asp-controller="Dashboard" asp-action="Index">@Localizer["Back"]</a>
46 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Create.cshtml +64 −0
@@ -0,0 +1,64 @@
1 +@model AdminWishlistFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-heart-fill"></i> @Localizer["Create"] @Localizer["Wishlist Item"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Wishlist Item"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Create" method="post">
16 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
17 + <div class="mb-3">
18 + <label asp-for="Item.TripId" class="form-label"></label>
19 + <select asp-for="Item.TripId" asp-items="Model.TripList" class="form-select"></select>
20 + </div>
21 + <div class="mb-3">
22 + <label asp-for="Item.AddedByUserId" class="form-label"></label>
23 + <select asp-for="Item.AddedByUserId" asp-items="Model.UserList" class="form-select"></select>
24 + </div>
25 + <div class="mb-3">
26 + <label asp-for="Item.Title" class="form-label"></label>
27 + <input asp-for="Item.Title" class="form-control" />
28 + <span asp-validation-for="Item.Title" class="text-danger"></span>
29 + </div>
30 + <div class="mb-3">
31 + <label asp-for="Item.Description" class="form-label"></label>
32 + <textarea asp-for="Item.Description" class="form-control"></textarea>
33 + </div>
34 + <div class="mb-3">
35 + <label asp-for="Item.Category" class="form-label"></label>
36 + <select asp-for="Item.Category" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EWishlistCategory>()" class="form-select"></select>
37 + </div>
38 + <div class="mb-3">
39 + <label asp-for="Item.Priority" class="form-label"></label>
40 + <select asp-for="Item.Priority" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EWishlistPriority>()" class="form-select"></select>
41 + </div>
42 + <div class="mb-3">
43 + <label asp-for="Item.EstimatedCost" class="form-label"></label>
44 + <input asp-for="Item.EstimatedCost" class="form-control" />
45 + </div>
46 + <div class="mb-3">
47 + <label asp-for="Item.Url" class="form-label"></label>
48 + <input asp-for="Item.Url" class="form-control" />
49 + </div>
50 + <div class="mb-3">
51 + <label asp-for="Item.Location" class="form-label"></label>
52 + <input asp-for="Item.Location" class="form-control" />
53 + </div>
54 + <div class="d-flex gap-2">
55 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Create"]</button>
56 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
57 + </div>
58 + </form>
59 + </div>
60 +</div>
61 +
62 +@section Scripts {
63 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
64 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Delete.cshtml +13 −0
@@ -0,0 +1,13 @@
1 +@model AdminDeleteViewModel<TripWishlistItemBllDto>
2 +
3 +<h1>@Localizer["Delete"] @Localizer["Wishlist Item"]</h1>
4 +
5 +<div class="alert alert-danger">
6 + @Localizer["AreYouSure"] - <strong>@Model.Item.Title</strong> (@Model.Item.Trip?.Name)?
7 +</div>
8 +
9 +<form asp-action="Delete" method="post">
10 + <input type="hidden" asp-for="Item.Id" />
11 + <button type="submit" class="btn btn-danger">@Localizer["Delete"]</button>
12 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
13 +</form>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Details.cshtml +35 −0
@@ -0,0 +1,35 @@
1 +@model AdminDetailsViewModel<TripWishlistItemBllDto>
2 +
3 +<h1>@Localizer["Wishlist Item"] @Localizer["Details"]</h1>
4 +
5 +<div class="card shadow-sm">
6 + <div class="card-body">
7 + <dl class="row">
8 + <dt class="col-sm-3">@Localizer["Title"]</dt>
9 + <dd class="col-sm-9">@Model.Item.Title</dd>
10 + <dt class="col-sm-3">@Localizer["Category"]</dt>
11 + <dd class="col-sm-9">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Category)</dd>
12 + <dt class="col-sm-3">@Localizer["Priority"]</dt>
13 + <dd class="col-sm-9">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Item.Priority)</dd>
14 + <dt class="col-sm-3">@Localizer["Trip"]</dt>
15 + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd>
16 + <dt class="col-sm-3">@Localizer["Added By"]</dt>
17 + <dd class="col-sm-9">@(Model.Item.AddedByUser != null ? $"{Model.Item.AddedByUser.FirstName} {Model.Item.AddedByUser.LastName}" : "")</dd>
18 + <dt class="col-sm-3">@Localizer["Description"]</dt>
19 + <dd class="col-sm-9">@Model.Item.Description</dd>
20 + <dt class="col-sm-3">@Localizer["Estimated Cost"]</dt>
21 + <dd class="col-sm-9">@Model.Item.EstimatedCost?.ToString("N2")</dd>
22 + <dt class="col-sm-3">@Localizer["URL"]</dt>
23 + <dd class="col-sm-9">@Model.Item.Url</dd>
24 + <dt class="col-sm-3">@Localizer["Location"]</dt>
25 + <dd class="col-sm-9">@Model.Item.Location</dd>
26 + <dt class="col-sm-3">@Localizer["Completed"]</dt>
27 + <dd class="col-sm-9">@(Model.Item.IsCompleted ? Localizer["Yes"] : Localizer["No"])</dd>
28 + <dt class="col-sm-3">@Localizer["Votes"]</dt>
29 + <dd class="col-sm-9">@Model.Item.VoteCount</dd>
30 + </dl>
31 + </div>
32 +</div>
33 +
34 +<a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary mt-3">@Localizer["Edit"]</a>
35 +<a asp-action="Index" class="btn btn-outline-secondary mt-3">@Localizer["Back to List"]</a>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Edit.cshtml +57 −0
@@ -0,0 +1,57 @@
1 +@model AdminWishlistFormViewModel
2 +
3 +<div class="admin-page-header">
4 + <div>
5 + <h1><i class="bi bi-heart-fill"></i> @Localizer["Edit"] @Localizer["Wishlist Item"]</h1>
6 + </div>
7 + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a>
8 +</div>
9 +
10 +<div class="admin-card">
11 + <div class="admin-card-header">
12 + <span><i class="bi bi-pencil-square"></i>@Localizer["Wishlist Item"]</span>
13 + </div>
14 + <div class="admin-card-body">
15 + <form asp-action="Edit" method="post">
16 + <input type="hidden" asp-for="Item.Id" />
17 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
18 + <div class="mb-3">
19 + <label asp-for="Item.TripId" class="form-label"></label>
20 + <select asp-for="Item.TripId" asp-items="Model.TripList" class="form-select"></select>
21 + </div>
22 + <div class="mb-3">
23 + <label asp-for="Item.Title" class="form-label"></label>
24 + <input asp-for="Item.Title" class="form-control" />
25 + <span asp-validation-for="Item.Title" class="text-danger"></span>
26 + </div>
27 + <div class="mb-3">
28 + <label asp-for="Item.Description" class="form-label"></label>
29 + <textarea asp-for="Item.Description" class="form-control"></textarea>
30 + </div>
31 + <div class="mb-3">
32 + <label asp-for="Item.Category" class="form-label"></label>
33 + <select asp-for="Item.Category" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EWishlistCategory>()" class="form-select"></select>
34 + </div>
35 + <div class="mb-3">
36 + <label asp-for="Item.Priority" class="form-label"></label>
37 + <select asp-for="Item.Priority" asp-items="Html.GetEnumSelectList<SplitApp.Modules.Trips.Domain.Enums.EWishlistPriority>()" class="form-select"></select>
38 + </div>
39 + <div class="mb-3">
40 + <label asp-for="Item.EstimatedCost" class="form-label"></label>
41 + <input asp-for="Item.EstimatedCost" class="form-control" />
42 + </div>
43 + <div class="mb-3 form-check">
44 + <input asp-for="Item.IsCompleted" class="form-check-input" />
45 + <label asp-for="Item.IsCompleted" class="form-check-label"></label>
46 + </div>
47 + <div class="d-flex gap-2">
48 + <button type="submit" class="btn btn-primary"><i class="bi bi-check-lg me-1"></i>@Localizer["Save"]</button>
49 + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a>
50 + </div>
51 + </form>
52 + </div>
53 +</div>
54 +
55 +@section Scripts {
56 + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
57 +}
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Index.cshtml +71 −0
@@ -0,0 +1,71 @@
1 +@model AdminWishlistIndexViewModel
2 +<div class="admin-page-header">
3 + <div>
4 + <h1><i class="bi bi-stars me-2"></i>@Localizer["Wishlist Items"]</h1>
5 + <p class="lead">@Localizer["Manage wishlist items across trips"]</p>
6 + </div>
7 + <a asp-action="Create" class="btn btn-primary">
8 + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"]
9 + </a>
10 +</div>
11 +
12 +<div class="admin-card mb-3">
13 + <div class="admin-card-body">
14 + <form method="get" class="row g-2 align-items-end">
15 + <div class="col-auto">
16 + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" />
17 + </div>
18 + <div class="col-auto">
19 + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button>
20 + </div>
21 + </form>
22 + </div>
23 +</div>
24 +
25 +<div class="admin-card">
26 + <div class="admin-card-body p-0">
27 + @if (Model.Items.Any())
28 + {
29 + <table class="table admin-table mb-0">
30 + <thead>
31 + <tr>
32 + <th>@Localizer["Title"]</th>
33 + <th>@Localizer["Category"]</th>
34 + <th>@Localizer["Priority"]</th>
35 + <th>@Localizer["Trip"]</th>
36 + <th>@Localizer["Added By"]</th>
37 + <th>@Localizer["Completed"]</th>
38 + <th class="text-end">@Localizer["Actions"]</th>
39 + </tr>
40 + </thead>
41 + <tbody>
42 + @foreach (var item in Model.Items)
43 + {
44 + <tr>
45 + <td>@item.Title</td>
46 + <td><span class="badge bg-info">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Category)</span></td>
47 + <td>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Priority)</td>
48 + <td>@item.Trip?.Name</td>
49 + <td>@(item.AddedByUser != null ? $"{item.AddedByUser.FirstName} {item.AddedByUser.LastName}" : "")</td>
50 + <td>@(item.IsCompleted ? Localizer["Yes"] : Localizer["No"])</td>
51 + <td class="text-end">
52 + <div class="admin-action-group">
53 + <a asp-action="Details" asp-route-id="@item.Id" class="btn btn-outline-secondary btn-sm" title="@Localizer["Details"]"><i class="bi bi-eye"></i></a>
54 + <a asp-action="Edit" asp-route-id="@item.Id" class="btn btn-outline-primary btn-sm" title="@Localizer["Edit"]"><i class="bi bi-pencil"></i></a>
55 + <a asp-action="Delete" asp-route-id="@item.Id" class="btn btn-outline-danger btn-sm" title="@Localizer["Delete"]"><i class="bi bi-trash"></i></a>
56 + </div>
57 + </td>
58 + </tr>
59 + }
60 + </tbody>
61 + </table>
62 + }
63 + else
64 + {
65 + <div class="admin-empty">
66 + <i class="bi bi-inbox"></i>
67 + <div>@Localizer["No items yet"]</div>
68 + </div>
69 + }
70 + </div>
71 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewImports.cshtml +15 −0
@@ -0,0 +1,15 @@
1 +@using SplitApp.WebApp
2 +@using SplitApp.WebApp.Areas.Admin.Models
3 +@using SplitApp.WebApp.Hosting.Helpers
4 +@using SplitApp.WebApp.Application.DTO
5 +@using SplitApp.WebApp.Controllers
6 +@using SplitApp.Modules.Trips.Domain.Entities
7 +@using SplitApp.Modules.Trips.Domain.Enums
8 +@using SplitApp.Modules.Expenses.Domain.Entities
9 +@using SplitApp.Modules.Expenses.Domain.Enums
10 +@using SplitApp.Shared.Kernel.Localization
11 +@using Microsoft.Extensions.Localization
12 +@using Microsoft.AspNetCore.Mvc.Localization
13 +@using Microsoft.AspNetCore.Mvc.Rendering
14 +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
15 +@inject IStringLocalizer<App.Resources.Views.Shared> Localizer
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewStart.cshtml +3 −0
@@ -0,0 +1,3 @@
1 +@{
2 + Layout = "/Areas/Admin/Views/Shared/_Layout.cshtml";
3 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/AccountController.cs +124 −0
@@ -0,0 +1,124 @@
1 +using System.Security.Claims;
2 +using Microsoft.AspNetCore.Authorization;
3 +using Microsoft.AspNetCore.Mvc;
4 +using SplitApp.WebApp.Application.UsersService;
5 +using SplitApp.WebApp.Models.Account;
6 +
7 +namespace SplitApp.WebApp.Controllers;
8 +
9 +/// <summary>
10 +/// MVC entry-point for login / register / logout / profile. Bridges the browser to the
11 +/// Users service over HTTP and turns the JWT into an HttpOnly cookie so MVC views work
12 +/// without manual Authorization headers. SameSite=Lax so the post→redirect→get login flow
13 +/// works (Strict would drop the cookie on the redirect).
14 +/// </summary>
15 +public class AccountController : Controller
16 +{
17 + private readonly IUsersServiceClient _users;
18 +
19 + public AccountController(IUsersServiceClient users) => _users = users;
20 +
21 + [HttpGet]
22 + public IActionResult Login(string? returnUrl = null)
23 + {
24 + return View(new LoginViewModel { ReturnUrl = returnUrl });
25 + }
26 +
27 + [HttpPost]
28 + [ValidateAntiForgeryToken]
29 + public async Task<IActionResult> Login(LoginViewModel vm, CancellationToken ct)
30 + {
31 + if (!ModelState.IsValid) return View(vm);
32 +
33 + var result = await _users.LoginAsync(vm.Email, vm.Password, ct);
34 + if (!result.Success || result.Value is null)
35 + {
36 + ModelState.AddModelError(string.Empty, result.Error ?? "Login failed.");
37 + return View(vm);
38 + }
39 +
40 + SetAuthCookies(result.Value.Jwt, result.Value.RefreshToken);
41 + return SafeRedirect(vm.ReturnUrl);
42 + }
43 +
44 + [HttpGet]
45 + public IActionResult Register(string? returnUrl = null)
46 + {
47 + return View(new RegisterViewModel { ReturnUrl = returnUrl });
48 + }
49 +
50 + [HttpPost]
51 + [ValidateAntiForgeryToken]
52 + public async Task<IActionResult> Register(RegisterViewModel vm, CancellationToken ct)
53 + {
54 + if (!ModelState.IsValid) return View(vm);
55 +
56 + var result = await _users.RegisterAsync(vm.Email, vm.Password, vm.FirstName, vm.LastName, ct);
57 + if (!result.Success || result.Value is null)
58 + {
59 + ModelState.AddModelError(string.Empty, result.Error ?? "Registration failed.");
60 + return View(vm);
61 + }
62 +
63 + SetAuthCookies(result.Value.Jwt, result.Value.RefreshToken);
64 + return SafeRedirect(vm.ReturnUrl);
65 + }
66 +
67 + [HttpPost]
68 + [ValidateAntiForgeryToken]
69 + [Authorize]
70 + public async Task<IActionResult> Logout(CancellationToken ct)
71 + {
72 + var refresh = Request.Cookies["refresh"];
73 + if (!string.IsNullOrEmpty(refresh))
74 + {
75 + await _users.LogoutAsync(refresh, ct);
76 + }
77 + Response.Cookies.Delete("jwt");
78 + Response.Cookies.Delete("refresh");
79 + return RedirectToAction("Index", "Home");
80 + }
81 +
82 + [HttpGet]
83 + [Authorize]
84 + public IActionResult Manage()
85 + {
86 + var vm = new ManageViewModel
87 + {
88 + Email = User.FindFirstValue(ClaimTypes.Email) ?? "",
89 + FirstName = User.FindFirstValue(ClaimTypes.GivenName) ?? "",
90 + LastName = User.FindFirstValue(ClaimTypes.Surname) ?? "",
91 + };
92 + return View(vm);
93 + }
94 +
95 + private void SetAuthCookies(string jwt, string refreshToken)
96 + {
97 + var jwtOpts = new CookieOptions
98 + {
99 + HttpOnly = true,
100 + Secure = Request.IsHttps,
101 + SameSite = SameSiteMode.Lax,
102 + Expires = DateTimeOffset.UtcNow.AddHours(1),
103 + };
104 + Response.Cookies.Append("jwt", jwt, jwtOpts);
105 +
106 + var refreshOpts = new CookieOptions
107 + {
108 + HttpOnly = true,
109 + Secure = Request.IsHttps,
110 + SameSite = SameSiteMode.Lax,
111 + Expires = DateTimeOffset.UtcNow.AddDays(7),
112 + };
113 + Response.Cookies.Append("refresh", refreshToken, refreshOpts);
114 + }
115 +
116 + private IActionResult SafeRedirect(string? returnUrl)
117 + {
118 + if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
119 + {
120 + return Redirect(returnUrl);
121 + }
122 + return RedirectToAction("Index", "Home");
123 + }
124 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/BudgetController.cs +209 −0
@@ -0,0 +1,209 @@
1 +using System.Security.Claims;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Services;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Domain.Entities;
7 +using SplitApp.Modules.Expenses.Domain.Enums;
8 +using SplitApp.Shared.Kernel.Domain;
9 +using SplitApp.Shared.Kernel.Localization;
10 +using Microsoft.AspNetCore.Authorization;
11 +using Microsoft.AspNetCore.Identity;
12 +using Microsoft.AspNetCore.Mvc;
13 +
14 +namespace SplitApp.WebApp.Controllers;
15 +
16 +[Authorize]
17 +public class BudgetController : Controller
18 +{
19 + private readonly ITripService _tripService;
20 + private readonly IBudgetCategoryService _budgetCategoryService;
21 +
22 + public BudgetController(
23 + ITripService tripService,
24 + IBudgetCategoryService budgetCategoryService)
25 + {
26 + _tripService = tripService;
27 + _budgetCategoryService = budgetCategoryService;
28 + }
29 +
30 + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
31 +
32 + // GET: Budget?tripId=xxx
33 + public async Task<IActionResult> Index(Guid tripId)
34 + {
35 + var userId = GetUserId();
36 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
37 +
38 + var trip = await _tripService.GetByIdAsync(tripId, userId);
39 + if (trip == null) return NotFound();
40 +
41 + var categories = await _budgetCategoryService.GetByTripIdAsync(tripId, userId);
42 +
43 + var model = categories.Select(c => new BudgetCategoryViewModel
44 + {
45 + Id = c.Id,
46 + Name = c.Name,
47 + IconName = c.IconName,
48 + PlannedAmount = c.PlannedAmount ?? 0,
49 + SpentAmount = c.SpentAmount,
50 + DisplayOrder = c.DisplayOrder
51 + }).ToList();
52 +
53 + ViewData["TripId"] = tripId;
54 + ViewData["TripName"] = trip.Name;
55 + ViewData["TotalPlanned"] = model.Sum(m => m.PlannedAmount);
56 + ViewData["TotalSpent"] = model.Sum(m => m.SpentAmount);
57 + ViewData["IsOrganizer"] = await _tripService.IsOrganizerAsync(tripId, userId);
58 +
59 + return View(model);
60 + }
61 +
62 + // GET: Budget/CreateCategory?tripId=xxx
63 + public async Task<IActionResult> CreateCategory(Guid tripId)
64 + {
65 + var userId = GetUserId();
66 + if (!await _tripService.IsOrganizerAsync(tripId, userId)) return Forbid();
67 +
68 + ViewData["TripId"] = tripId;
69 + return View(new BudgetCategoryBllDto { TripId = tripId });
70 + }
71 +
72 + // POST: Budget/CreateCategory
73 + [HttpPost]
74 + [ValidateAntiForgeryToken]
75 + public async Task<IActionResult> CreateCategory(BudgetCategoryBllDto category, string? name)
76 + {
77 + var userId = GetUserId();
78 +
79 + category.Name = new LangStr(name ?? "", "en");
80 + ModelState.Remove(nameof(BudgetCategory.Name));
81 +
82 + if (string.IsNullOrWhiteSpace(name))
83 + ModelState.AddModelError(nameof(BudgetCategory.Name), "Name is required.");
84 +
85 + if (ModelState.IsValid)
86 + {
87 + var (created, errorCode) = await _budgetCategoryService.CreateAsync(category, userId);
88 + if (created == null)
89 + {
90 + if (errorCode == "forbidden") return Forbid();
91 + return NotFound();
92 + }
93 + return RedirectToAction(nameof(Index), new { tripId = category.TripId });
94 + }
95 +
96 + ViewData["TripId"] = category.TripId;
97 + return View(category);
98 + }
99 +
100 + // GET: Budget/EditCategory/5
101 + public async Task<IActionResult> EditCategory(Guid id)
102 + {
103 + var userId = GetUserId();
104 +
105 + var category = await _budgetCategoryService.GetByIdAsync(id);
106 + if (category == null) return NotFound();
107 +
108 + if (!await _tripService.IsOrganizerAsync(category.TripId, userId)) return Forbid();
109 +
110 + ViewData["TripId"] = category.TripId;
111 + return View(category);
112 + }
113 +
114 + // POST: Budget/EditCategory/5
115 + [HttpPost]
116 + [ValidateAntiForgeryToken]
117 + public async Task<IActionResult> EditCategory(Guid id, BudgetCategoryBllDto category, string? name)
118 + {
119 + if (id != category.Id) return NotFound();
120 +
121 + var userId = GetUserId();
122 +
123 + var existing = await _budgetCategoryService.GetByIdAsync(id);
124 + if (existing == null) return NotFound();
125 +
126 + if (!await _tripService.IsOrganizerAsync(existing.TripId, userId)) return Forbid();
127 +
128 + category.Name = new LangStr(name ?? "", "en");
129 + ModelState.Remove(nameof(BudgetCategory.Name));
130 +
131 + if (string.IsNullOrWhiteSpace(name))
132 + ModelState.AddModelError(nameof(BudgetCategory.Name), "Name is required.");
133 +
134 + if (ModelState.IsValid)
135 + {
136 + var (ok, errorCode) = await _budgetCategoryService.UpdateAsync(id, category, userId);
137 + if (!ok)
138 + {
139 + return errorCode switch
140 + {
141 + "forbidden" => Forbid(),
142 + _ => NotFound()
143 + };
144 + }
145 + return RedirectToAction(nameof(Index), new { tripId = existing.TripId });
146 + }
147 +
148 + ViewData["TripId"] = existing.TripId;
149 + return View(category);
150 + }
151 +
152 + // GET: Budget/DeleteCategory/5
153 + public async Task<IActionResult> DeleteCategory(Guid id)
154 + {
155 + var userId = GetUserId();
156 +
157 + var category = await _budgetCategoryService.GetByIdAsync(id);
158 + if (category == null) return NotFound();
159 +
160 + if (!await _tripService.IsOrganizerAsync(category.TripId, userId)) return Forbid();
161 +
162 + ViewData["TripId"] = category.TripId;
163 + return View(category);
164 + }
165 +
166 + // POST: Budget/DeleteCategory/5
167 + [HttpPost, ActionName("DeleteCategory")]
168 + [ValidateAntiForgeryToken]
169 + public async Task<IActionResult> DeleteCategoryConfirmed(Guid id)
170 + {
171 + var userId = GetUserId();
172 +
173 + var category = await _budgetCategoryService.GetByIdAsync(id);
174 + if (category == null) return NotFound();
175 +
176 + var tripId = category.TripId;
177 + var (ok, errorCode) = await _budgetCategoryService.DeleteAsync(id, userId);
178 + if (!ok)
179 + {
180 + return errorCode switch
181 + {
182 + "forbidden" => Forbid(),
183 + _ => NotFound()
184 + };
185 + }
186 + return RedirectToAction(nameof(Index), new { tripId });
187 + }
188 +}
189 +
190 +public class BudgetCategoryViewModel
191 +{
192 + public Guid Id { get; set; }
193 + public string Name { get; set; } = default!;
194 + public string? IconName { get; set; }
195 + public decimal PlannedAmount { get; set; }
196 + public decimal SpentAmount { get; set; }
197 + public int DisplayOrder { get; set; }
198 +
199 + public int ProgressPercentage =>
200 + PlannedAmount > 0 ? (int)(SpentAmount / PlannedAmount * 100) : 0;
201 +
202 + public string ProgressBarClass =>
203 + ProgressPercentage switch
204 + {
205 + > 90 => "bg-danger",
206 + > 70 => "bg-warning",
207 + _ => "bg-success"
208 + };
209 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/ExpensesController.cs +266 −0
@@ -0,0 +1,266 @@
1 +using System.Security.Claims;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Services;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Domain.Entities;
7 +using SplitApp.Modules.Expenses.Domain.Enums;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Identity;
10 +using Microsoft.AspNetCore.Mvc;
11 +using Microsoft.AspNetCore.Mvc.Rendering;
12 +
13 +namespace SplitApp.WebApp.Controllers;
14 +
15 +[Authorize]
16 +public class ExpensesController : Controller
17 +{
18 + private readonly IExpenseService _expenseService;
19 + private readonly ITripService _tripService;
20 + private readonly IBudgetCategoryService _budgetCategoryService;
21 +
22 + public ExpensesController(
23 + IExpenseService expenseService,
24 + ITripService tripService,
25 + IBudgetCategoryService budgetCategoryService)
26 + {
27 + _expenseService = expenseService;
28 + _tripService = tripService;
29 + _budgetCategoryService = budgetCategoryService;
30 + }
31 +
32 + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
33 +
34 + // GET: Expenses?tripId=xxx
35 + public async Task<IActionResult> Index(Guid tripId)
36 + {
37 + var userId = GetUserId();
38 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
39 +
40 + var trip = await _tripService.GetByIdWithDetailsAsync(tripId, userId);
41 + if (trip == null) return NotFound();
42 +
43 + var expenses = await _expenseService.GetByTripIdAsync(tripId, userId);
44 +
45 + var model = new ExpensesIndexViewModel
46 + {
47 + TripId = tripId,
48 + TripName = trip.Name,
49 + DefaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR",
50 + CurrencySymbol = trip.DefaultCurrency?.Symbol ?? "\u20ac",
51 + TripStatus = trip.Status.ToString(),
52 + Expenses = expenses
53 + };
54 +
55 + return View(model);
56 + }
57 +
58 + // GET: Expenses/Create?tripId=xxx
59 + public async Task<IActionResult> Create(Guid tripId)
60 + {
61 + var userId = GetUserId();
62 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
63 +
64 + var trip = await _tripService.GetByIdAsync(tripId, userId);
65 + if (trip != null && trip.Status != ETripStatus.Active)
66 + return RedirectToAction(nameof(Index), new { tripId });
67 +
68 + await PopulateDropdowns(tripId);
69 + ViewData["TripId"] = tripId;
70 +
71 + var expense = new ExpenseBllDto
72 + {
73 + TripId = tripId,
74 + PaidByUserId = userId,
75 + ExpenseDate = DateTime.UtcNow,
76 + SplitMethod = ESplitMethod.EqualAll
77 + };
78 +
79 + return View(expense);
80 + }
81 +
82 + // POST: Expenses/Create
83 + [HttpPost]
84 + [ValidateAntiForgeryToken]
85 + public async Task<IActionResult> Create(ExpenseBllDto expense, Guid[] selectedParticipants, decimal[] splitAmounts, decimal[] splitPercentages)
86 + {
87 + var userId = GetUserId();
88 + if (!await _tripService.IsParticipantAsync(expense.TripId, userId)) return Forbid();
89 +
90 + if (ModelState.IsValid)
91 + {
92 + await _expenseService.CreateExpenseWithSplitsAsync(expense, selectedParticipants, splitAmounts, splitPercentages);
93 + return RedirectToAction(nameof(Index), new { tripId = expense.TripId });
94 + }
95 +
96 + await PopulateDropdowns(expense.TripId, expense.BudgetCategoryId, expense.CurrencyId);
97 + ViewData["TripId"] = expense.TripId;
98 + return View(expense);
99 + }
100 +
101 + // GET: Expenses/Edit/5
102 + public async Task<IActionResult> Edit(Guid id)
103 + {
104 + var userId = GetUserId();
105 +
106 + var expense = await _expenseService.GetRawByIdAsync(id);
107 + if (expense == null) return NotFound();
108 +
109 + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid();
110 +
111 + var trip = await _tripService.GetRawByIdAsync(expense.TripId);
112 + if (trip != null && trip.Status != ETripStatus.Active)
113 + return RedirectToAction(nameof(Index), new { tripId = expense.TripId });
114 +
115 + await PopulateDropdowns(expense.TripId, expense.BudgetCategoryId, expense.CurrencyId);
116 + ViewData["TripId"] = expense.TripId;
117 +
118 + return View(expense);
119 + }
120 +
121 + // POST: Expenses/Edit/5
122 + [HttpPost]
123 + [ValidateAntiForgeryToken]
124 + public async Task<IActionResult> Edit(Guid id, ExpenseBllDto expense)
125 + {
126 + if (id != expense.Id) return NotFound();
127 +
128 + var userId = GetUserId();
129 +
130 + if (ModelState.IsValid)
131 + {
132 + var result = await _expenseService.UpdateExpenseAsync(id, expense, userId);
133 + if (!result.success)
134 + {
135 + return result.errorCode switch
136 + {
137 + "notfound" => NotFound(),
138 + "forbidden" => Forbid(),
139 + "badstatus" => RedirectToAction(nameof(Index), new { tripId = expense.TripId }),
140 + _ => NotFound()
141 + };
142 + }
143 + // Need to get tripId from existing since it's not in incoming after success
144 + var updated = await _expenseService.GetRawByIdAsync(id);
145 + return RedirectToAction(nameof(Index), new { tripId = updated?.TripId ?? expense.TripId });
146 + }
147 +
148 + var existingEntity = await _expenseService.GetRawByIdAsync(id);
149 + if (existingEntity == null) return NotFound();
150 +
151 + await PopulateDropdowns(existingEntity.TripId, expense.BudgetCategoryId, expense.CurrencyId);
152 + ViewData["TripId"] = existingEntity.TripId;
153 + return View(expense);
154 + }
155 +
156 + // GET: Expenses/Delete/5
157 + public async Task<IActionResult> Delete(Guid id)
158 + {
159 + var userId = GetUserId();
160 +
161 + var expense = await _expenseService.GetByIdWithDetailsAsync(id, userId);
162 + if (expense == null)
163 + {
164 + // Either NotFound or not a participant
165 + var raw = await _expenseService.GetRawByIdAsync(id);
166 + if (raw == null) return NotFound();
167 + return Forbid();
168 + }
169 +
170 + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid();
171 +
172 + var trip = await _tripService.GetRawByIdAsync(expense.TripId);
173 + if (trip != null && trip.Status != ETripStatus.Active)
174 + return RedirectToAction(nameof(Index), new { tripId = expense.TripId });
175 +
176 + ViewData["TripId"] = expense.TripId;
177 + return View(expense);
178 + }
179 +
180 + // POST: Expenses/Delete/5
181 + [HttpPost, ActionName("Delete")]
182 + [ValidateAntiForgeryToken]
183 + public async Task<IActionResult> DeleteConfirmed(Guid id)
184 + {
185 + var userId = GetUserId();
186 +
187 + var expense = await _expenseService.GetRawByIdAsync(id);
188 + if (expense == null) return NotFound();
189 +
190 + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid();
191 +
192 + var trip = await _tripService.GetRawByIdAsync(expense.TripId);
193 + if (trip != null && trip.Status != ETripStatus.Active)
194 + return RedirectToAction(nameof(Index), new { tripId = expense.TripId });
195 +
196 + var tripId = expense.TripId;
197 +
198 + await _expenseService.DeleteExpenseWithSplitsAsync(id);
199 +
200 + return RedirectToAction(nameof(Index), new { tripId });
201 + }
202 +
203 + private async Task PopulateDropdowns(Guid tripId, Guid? selectedCategoryId = null, Guid? selectedCurrencyId = null)
204 + {
205 + var categories = await _budgetCategoryService.GetByTripIdRawAsync(tripId);
206 + ViewData["BudgetCategoryId"] = new SelectList(categories, "Id", "Name", selectedCategoryId);
207 +
208 + var currencies = await _tripService.GetAllCurrenciesAsync();
209 + ViewData["CurrencyId"] = new SelectList(currencies, "Id", "Code", selectedCurrencyId);
210 +
211 + ViewData["SplitMethods"] = new SelectList(
212 + Enum.GetValues<ESplitMethod>().Select(e => new { Value = (int)e, Text = e.ToString() }),
213 + "Value", "Text");
214 +
215 + var userId = GetUserId();
216 + var participants = await _tripService.GetParticipantsAsync(tripId, userId);
217 +
218 + ViewData["Participants"] = participants;
219 + ViewData["PaidByUserId"] = new SelectList(
220 + participants.Select(p => new
221 + {
222 + Value = p.UserId,
223 + Text = $"{p.User!.FirstName} {p.User.LastName}"
224 + }),
225 + "Value", "Text", userId);
226 +
227 + // Load split presets for this trip
228 + var presets = await _expenseService.GetSplitPresetsByTripAsync(tripId, userId);
229 + ViewData["SplitPresets"] = presets;
230 + }
231 +
232 + // POST: Expenses/SavePreset
233 + [HttpPost]
234 + [ValidateAntiForgeryToken]
235 + public async Task<IActionResult> SavePreset(Guid tripId, string presetName, int splitMethod, Guid[] selectedParticipants, decimal[] splitPercentages)
236 + {
237 + var userId = GetUserId();
238 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
239 +
240 + await _expenseService.SavePresetAsync(tripId, presetName, (ESplitMethod)splitMethod, selectedParticipants, splitPercentages, userId);
241 + return RedirectToAction(nameof(Create), new { tripId });
242 + }
243 +
244 + // POST: Expenses/DeletePreset
245 + [HttpPost]
246 + [ValidateAntiForgeryToken]
247 + public async Task<IActionResult> DeletePreset(Guid id, Guid tripId)
248 + {
249 + var userId = GetUserId();
250 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
251 +
252 + await _expenseService.DeletePresetAsync(id, tripId, userId);
253 +
254 + return RedirectToAction(nameof(Create), new { tripId });
255 + }
256 +}
257 +
258 +public class ExpensesIndexViewModel
259 +{
260 + public Guid TripId { get; set; }
261 + public string TripName { get; set; } = default!;
262 + public string DefaultCurrencyCode { get; set; } = default!;
263 + public string CurrencySymbol { get; set; } = default!;
264 + public string TripStatus { get; set; } = default!;
265 + public List<ExpenseBllDto> Expenses { get; set; } = new();
266 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/HomeController.cs +35 −0
@@ -0,0 +1,35 @@
1 +using System.Diagnostics;
2 +using Microsoft.AspNetCore.Localization;
3 +using Microsoft.AspNetCore.Mvc;
4 +using SplitApp.WebApp.Models;
5 +
6 +namespace SplitApp.WebApp.Controllers;
7 +
8 +public class HomeController : Controller
9 +{
10 + public IActionResult Index()
11 + {
12 + return View();
13 + }
14 +
15 + public IActionResult Privacy()
16 + {
17 + return View();
18 + }
19 +
20 + public IActionResult SetLanguage(string culture, string returnUrl)
21 + {
22 + Response.Cookies.Append(
23 + CookieRequestCultureProvider.DefaultCookieName,
24 + CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)),
25 + new CookieOptions { Expires = DateTimeOffset.UtcNow.AddYears(1) }
26 + );
27 + return LocalRedirect(returnUrl);
28 + }
29 +
30 + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
31 + public IActionResult Error()
32 + {
33 + return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
34 + }
35 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/MembersController.cs +177 −0
@@ -0,0 +1,177 @@
1 +using System.Security.Claims;
2 +using SplitApp.WebApp.Application.Services;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Identity;
9 +using Microsoft.AspNetCore.Mvc;
10 +
11 +namespace SplitApp.WebApp.Controllers;
12 +
13 +[Authorize]
14 +public class MembersController : Controller
15 +{
16 + private readonly ITripService _tripService;
17 + private readonly IInvitationService _invitationService;
18 +
19 + public MembersController(
20 + ITripService tripService,
21 + IInvitationService invitationService)
22 + {
23 + _tripService = tripService;
24 + _invitationService = invitationService;
25 + }
26 +
27 + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
28 +
29 + // GET: Members?tripId=xxx
30 + public async Task<IActionResult> Index(Guid tripId)
31 + {
32 + var userId = GetUserId();
33 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
34 +
35 + var trip = await _tripService.GetByIdAsync(tripId, userId);
36 + if (trip == null) return NotFound();
37 +
38 + var participants = await _tripService.GetParticipantsAsync(tripId, userId);
39 +
40 + var isOrganizer = participants.Any(p => p.UserId == userId && p.Role == EParticipantRole.Organizer);
41 +
42 + var pendingInvitations = await _invitationService.GetPendingByTripIdAsync(tripId, userId);
43 +
44 + ViewData["TripId"] = tripId;
45 + ViewData["TripName"] = trip.Name;
46 + ViewData["CurrentUserId"] = userId;
47 + ViewData["IsOrganizer"] = isOrganizer;
48 + ViewData["PendingInvitations"] = pendingInvitations;
49 +
50 + return View(participants);
51 + }
52 +
53 + // GET: Members/Invite?tripId=xxx
54 + public async Task<IActionResult> Invite(Guid tripId)
55 + {
56 + var userId = GetUserId();
57 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
58 +
59 + var trip = await _tripService.GetByIdAsync(tripId, userId);
60 + if (trip == null) return NotFound();
61 +
62 + ViewData["TripId"] = tripId;
63 + ViewData["TripName"] = trip.Name;
64 +
65 + return View();
66 + }
67 +
68 + // POST: Members/Invite
69 + [HttpPost]
70 + [ValidateAntiForgeryToken]
71 + public async Task<IActionResult> Invite(Guid tripId, int _)
72 + {
73 + var userId = GetUserId();
74 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
75 +
76 + var trip = await _tripService.GetByIdAsync(tripId, userId);
77 + if (trip == null) return NotFound();
78 +
79 + var invitation = await _invitationService.CreateInvitationAsync(tripId, userId);
80 +
81 + var inviteUrl = Url.Action("AcceptInvitation", "Members", new { token = invitation.Token }, Request.Scheme);
82 +
83 + ViewData["TripId"] = tripId;
84 + ViewData["TripName"] = trip.Name;
85 + ViewData["InviteUrl"] = inviteUrl;
86 + ViewData["Token"] = invitation.Token;
87 +
88 + return View("InviteGenerated");
89 + }
90 +
91 + // GET: Members/AcceptInvitation?token=xxx
92 + [AllowAnonymous]
93 + public async Task<IActionResult> AcceptInvitation(string token)
94 + {
95 + var invitation = await _invitationService.GetByTokenAsync(token);
96 +
97 + if (invitation == null) return NotFound();
98 +
99 + if (invitation.Status != EInvitationStatus.Pending || invitation.ExpiresAt < DateTime.UtcNow)
100 + {
101 + ViewData["Error"] = "This invitation has expired or is no longer valid.";
102 + return View("InvitationInvalid");
103 + }
104 +
105 + // Load trip for the view
106 + invitation.Trip = await _tripService.GetRawByIdAsync(invitation.TripId);
107 +
108 + ViewData["Token"] = token;
109 + return View(invitation);
110 + }
111 +
112 + // POST: Members/AcceptInvitation
113 + [HttpPost]
114 + [ValidateAntiForgeryToken]
115 + public async Task<IActionResult> AcceptInvitation(string token, int _)
116 + {
117 + var userId = GetUserId();
118 +
119 + var invitation = await _invitationService.GetByTokenAsync(token);
120 + if (invitation == null) return NotFound();
121 +
122 + var success = await _invitationService.AcceptInvitationAsync(token, userId);
123 +
124 + if (!success)
125 + {
126 + ViewData["Error"] = "This invitation has expired or is no longer valid.";
127 + return View("InvitationInvalid");
128 + }
129 +
130 + return RedirectToAction("Details", "Trips", new { id = invitation.TripId });
131 + }
132 +
133 + // POST: Members/Remove
134 + [HttpPost]
135 + [ValidateAntiForgeryToken]
136 + public async Task<IActionResult> Remove(Guid tripId, Guid participantId)
137 + {
138 + var userId = GetUserId();
139 +
140 + if (!await _tripService.IsOrganizerAsync(tripId, userId)) return Forbid();
141 +
142 + var participant = await _tripService.GetParticipantByIdAsync(participantId);
143 + if (participant == null || participant.TripId != tripId) return NotFound();
144 +
145 + // Cannot remove yourself
146 + if (participant.UserId == userId)
147 + {
148 + TempData["Error"] = "You cannot remove yourself from the trip.";
149 + return RedirectToAction(nameof(Index), new { tripId });
150 + }
151 +
152 + await _tripService.RemoveParticipantByIdAsync(tripId, participantId, userId);
153 +
154 + return RedirectToAction(nameof(Index), new { tripId });
155 + }
156 +
157 + // POST: Members/RevokeInvitation
158 + [HttpPost]
159 + [ValidateAntiForgeryToken]
160 + public async Task<IActionResult> RevokeInvitation(Guid id, Guid tripId)
161 + {
162 + var userId = GetUserId();
163 +
164 + var (ok, errorCode) = await _invitationService.RevokeInvitationAsync(id, tripId, userId);
165 + if (!ok)
166 + {
167 + return errorCode switch
168 + {
169 + "forbidden" => Forbid(),
170 + "notfound" => NotFound(),
171 + _ => NotFound()
172 + };
173 + }
174 +
175 + return RedirectToAction(nameof(Index), new { tripId });
176 + }
177 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/PollsClientController.cs +144 −0
@@ -0,0 +1,144 @@
1 +using System.Security.Claims;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Services;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Domain.Entities;
7 +using SplitApp.Modules.Expenses.Domain.Enums;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Identity;
10 +using Microsoft.AspNetCore.Mvc;
11 +
12 +namespace SplitApp.WebApp.Controllers;
13 +
14 +[Authorize]
15 +public class PollsClientController : Controller
16 +{
17 + private readonly ITripService _tripService;
18 + private readonly IPollService _pollService;
19 +
20 + public PollsClientController(
21 + ITripService tripService,
22 + IPollService pollService)
23 + {
24 + _tripService = tripService;
25 + _pollService = pollService;
26 + }
27 +
28 + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
29 +
30 + // GET: PollsClient?tripId=xxx
31 + public async Task<IActionResult> Index(Guid tripId)
32 + {
33 + var userId = GetUserId();
34 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
35 +
36 + var trip = await _tripService.GetByIdAsync(tripId, userId);
37 + if (trip == null) return NotFound();
38 +
39 + var polls = await _pollService.GetByTripIdAsync(tripId, userId);
40 +
41 + ViewData["TripId"] = tripId;
42 + ViewData["TripName"] = trip.Name;
43 +
44 + return View(polls);
45 + }
46 +
47 + // GET: PollsClient/Create?tripId=xxx
48 + public async Task<IActionResult> Create(Guid tripId)
49 + {
50 + var userId = GetUserId();
51 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
52 +
53 + ViewData["TripId"] = tripId;
54 + return View();
55 + }
56 +
57 + // POST: PollsClient/Create
58 + [HttpPost]
59 + [ValidateAntiForgeryToken]
60 + public async Task<IActionResult> Create(Guid tripId, string question, bool allowMultipleVotes,
61 + bool isAnonymous, string option1, string option2, string? option3, string? option4, string? option5)
62 + {
63 + var userId = GetUserId();
64 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
65 +
66 + if (string.IsNullOrWhiteSpace(question) || string.IsNullOrWhiteSpace(option1) ||
67 + string.IsNullOrWhiteSpace(option2))
68 + {
69 + ModelState.AddModelError("", "Question and at least 2 options are required.");
70 + ViewData["TripId"] = tripId;
71 + return View();
72 + }
73 +
74 + var poll = new TripPollBllDto
75 + {
76 + TripId = tripId,
77 + CreatedByUserId = userId,
78 + Question = question,
79 + AllowMultipleVotes = allowMultipleVotes,
80 + IsAnonymous = isAnonymous
81 + };
82 +
83 + var options = new[] { option1, option2, option3, option4, option5 }
84 + .Where(o => !string.IsNullOrWhiteSpace(o))
85 + .Select(o => o!)
86 + .ToList();
87 +
88 + var created = await _pollService.CreatePollWithOptionsAsync(poll, options);
89 +
90 + return RedirectToAction(nameof(Details), new { id = created.Id });
91 + }
92 +
93 + // GET: PollsClient/Details/5
94 + public async Task<IActionResult> Details(Guid id)
95 + {
96 + var userId = GetUserId();
97 + var poll = await _pollService.GetByIdWithDetailsAsync(id, userId);
98 +
99 + if (poll == null) return NotFound();
100 +
101 + ViewData["TripId"] = poll.TripId;
102 + ViewData["UserId"] = userId;
103 + ViewData["IsCreator"] = poll.CreatedByUserId == userId;
104 +
105 + return View(poll);
106 + }
107 +
108 + // POST: PollsClient/Vote
109 + [HttpPost]
110 + [ValidateAntiForgeryToken]
111 + public async Task<IActionResult> Vote(Guid pollId, Guid optionId)
112 + {
113 + var userId = GetUserId();
114 + var poll = await _pollService.GetByIdAsync(pollId, userId);
115 +
116 + if (poll == null) return NotFound();
117 +
118 + if (poll.ClosedAt != null) return RedirectToAction(nameof(Details), new { id = pollId });
119 +
120 + await _pollService.ToggleVoteAsync(pollId, optionId, userId);
121 +
122 + return RedirectToAction(nameof(Details), new { id = pollId });
123 + }
124 +
125 + // POST: PollsClient/Close/5
126 + [HttpPost]
127 + [ValidateAntiForgeryToken]
128 + public async Task<IActionResult> Close(Guid id)
129 + {
130 + var userId = GetUserId();
131 + var (ok, errorCode) = await _pollService.ClosePollAsync(id, userId, organizerAllowed: false);
132 + if (!ok)
133 + {
134 + return errorCode switch
135 + {
136 + "notfound" => NotFound(),
137 + "forbidden" => Forbid(),
138 + _ => NotFound()
139 + };
140 + }
141 +
142 + return RedirectToAction(nameof(Details), new { id });
143 + }
144 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/SettlementController.cs +190 −0
@@ -0,0 +1,190 @@
1 +using System.Security.Claims;
2 +using SplitApp.WebApp.Application.Services;
3 +using SplitApp.Modules.Trips.Domain.Entities;
4 +using SplitApp.Modules.Trips.Domain.Enums;
5 +using SplitApp.Modules.Expenses.Domain.Entities;
6 +using SplitApp.Modules.Expenses.Domain.Enums;
7 +using Microsoft.AspNetCore.Authorization;
8 +using Microsoft.AspNetCore.Identity;
9 +using Microsoft.AspNetCore.Mvc;
10 +
11 +namespace SplitApp.WebApp.Controllers;
12 +
13 +[Authorize]
14 +public class SettlementController : Controller
15 +{
16 + private readonly ITripService _tripService;
17 + private readonly ISettlementService _settlementService;
18 +
19 + public SettlementController(
20 + ITripService tripService,
21 + ISettlementService settlementService)
22 + {
23 + _tripService = tripService;
24 + _settlementService = settlementService;
25 + }
26 +
27 + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
28 +
29 + // GET: Settlement?tripId=xxx
30 + public async Task<IActionResult> Index(Guid tripId)
31 + {
32 + var userId = GetUserId();
33 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
34 +
35 + var trip = await _tripService.GetByIdWithDetailsAsync(tripId, userId);
36 + if (trip == null) return NotFound();
37 +
38 + // Calculate balances via service
39 + var balanceEntries = await _settlementService.CalculateBalancesAsync(tripId);
40 +
41 + // Map to view model
42 + var balances = balanceEntries.Select(b => new SettlementBalanceViewModel
43 + {
44 + UserId = b.UserId,
45 + UserName = b.UserName,
46 + TotalPaid = b.TotalPaid,
47 + TotalOwed = b.TotalOwed
48 + }).OrderByDescending(b => b.NetBalance).ToList();
49 +
50 + // Get existing settlement plan (only exists after trip is finalized)
51 + var latestPlan = await _settlementService.GetLatestPlanRawAsync(tripId);
52 +
53 + // Preview payments when trip is active (not saved to DB)
54 + var previewPayments = trip.Status == ETripStatus.Active
55 + ? _settlementService.PreviewSettlement(balanceEntries)
56 + : new List<PreviewPayment>();
57 +
58 + var model = new SettlementIndexViewModel
59 + {
60 + TripId = tripId,
61 + TripName = trip.Name,
62 + CurrencySymbol = trip.DefaultCurrency?.Symbol ?? "$",
63 + TripStatus = trip.Status.ToString(),
64 + IsOrganizer = await _tripService.IsOrganizerAsync(tripId, userId),
65 + CurrentUserId = userId,
66 + Balances = balances,
67 + LatestPlan = latestPlan,
68 + PreviewPayments = previewPayments
69 + };
70 +
71 + return View(model);
72 + }
73 +
74 + // POST: Settlement/Finalize
75 + [HttpPost]
76 + [ValidateAntiForgeryToken]
77 + public async Task<IActionResult> Finalize(Guid tripId)
78 + {
79 + var userId = GetUserId();
80 + var (ok, errorCode) = await _tripService.FinalizeTripAsync(tripId, userId);
81 + if (!ok)
82 + {
83 + return errorCode switch
84 + {
85 + "forbidden" => Forbid(),
86 + "notfound" => NotFound(),
87 + "badstatus" => BadRequest(),
88 + _ => NotFound()
89 + };
90 + }
91 +
92 + return RedirectToAction(nameof(Index), new { tripId });
93 + }
94 +
95 + // POST: Settlement/Reopen
96 + [HttpPost]
97 + [ValidateAntiForgeryToken]
98 + public async Task<IActionResult> Reopen(Guid tripId)
99 + {
100 + var userId = GetUserId();
101 + var (ok, errorCode) = await _tripService.ReopenTripAsync(tripId, userId);
102 + if (!ok)
103 + {
104 + return errorCode switch
105 + {
106 + "forbidden" => Forbid(),
107 + "notfound" => NotFound(),
108 + "badstatus" => RedirectToAction(nameof(Index), new { tripId }),
109 + "payments-confirmed" => RedirectToAction(nameof(Index), new { tripId }),
110 + _ => NotFound()
111 + };
112 + }
113 +
114 + return RedirectToAction(nameof(Index), new { tripId });
115 + }
116 +
117 + // POST: Settlement/MarkPaid/5
118 + [HttpPost]
119 + [ValidateAntiForgeryToken]
120 + public async Task<IActionResult> MarkPaid(Guid paymentId)
121 + {
122 + var userId = GetUserId();
123 + var payment = await _settlementService.GetPaymentByIdAsync(paymentId);
124 + if (payment == null) return NotFound();
125 +
126 + var plan = await _settlementService.GetPlanByIdAsync(payment.SettlementPlanId);
127 + if (plan == null) return NotFound();
128 +
129 + var (ok, errorCode) = await _settlementService.MarkPaidGuardedAsync(paymentId, userId);
130 + if (!ok)
131 + {
132 + return errorCode switch
133 + {
134 + "forbidden" => Forbid(),
135 + "notfound" => NotFound(),
136 + _ => NotFound()
137 + };
138 + }
139 +
140 + return RedirectToAction(nameof(Index), new { tripId = plan.TripId });
141 + }
142 +
143 + // POST: Settlement/ConfirmReceipt/5
144 + [HttpPost]
145 + [ValidateAntiForgeryToken]
146 + public async Task<IActionResult> ConfirmReceipt(Guid paymentId)
147 + {
148 + var userId = GetUserId();
149 + var payment = await _settlementService.GetPaymentByIdAsync(paymentId);
150 + if (payment == null) return NotFound();
151 +
152 + var plan = await _settlementService.GetPlanByIdAsync(payment.SettlementPlanId);
153 + if (plan == null) return NotFound();
154 +
155 + var (ok, errorCode) = await _settlementService.ConfirmPaymentGuardedAsync(paymentId, userId);
156 + if (!ok)
157 + {
158 + return errorCode switch
159 + {
160 + "forbidden" => Forbid(),
161 + "notfound" => NotFound(),
162 + _ => NotFound()
163 + };
164 + }
165 +
166 + return RedirectToAction(nameof(Index), new { tripId = plan.TripId });
167 + }
168 +}
169 +
170 +public class SettlementIndexViewModel
171 +{
172 + public Guid TripId { get; set; }
173 + public string TripName { get; set; } = default!;
174 + public string CurrencySymbol { get; set; } = default!;
175 + public string TripStatus { get; set; } = default!;
176 + public bool IsOrganizer { get; set; }
177 + public Guid CurrentUserId { get; set; }
178 + public List<SettlementBalanceViewModel> Balances { get; set; } = new();
179 + public SplitApp.WebApp.Application.DTO.SettlementPlanBllDto? LatestPlan { get; set; }
180 + public List<PreviewPayment> PreviewPayments { get; set; } = new();
181 +}
182 +
183 +public class SettlementBalanceViewModel
184 +{
185 + public Guid UserId { get; set; }
186 + public string UserName { get; set; } = default!;
187 + public decimal TotalPaid { get; set; }
188 + public decimal TotalOwed { get; set; }
189 + public decimal NetBalance => TotalPaid - TotalOwed;
190 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/TripsController.cs +260 −0
@@ -0,0 +1,260 @@
1 +using System.Security.Claims;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Services;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Domain.Entities;
7 +using SplitApp.Modules.Expenses.Domain.Enums;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Identity;
10 +using Microsoft.AspNetCore.Mvc;
11 +using Microsoft.AspNetCore.Mvc.Rendering;
12 +using SplitApp.WebApp.Hosting.Helpers;
13 +
14 +namespace SplitApp.WebApp.Controllers;
15 +
16 +[Authorize]
17 +public class TripsController : Controller
18 +{
19 + private readonly ITripService _tripService;
20 + private readonly IExpenseService _expenseService;
21 + private readonly IBudgetCategoryService _budgetCategoryService;
22 +
23 + public TripsController(
24 + ITripService tripService,
25 + IExpenseService expenseService,
26 + IBudgetCategoryService budgetCategoryService)
27 + {
28 + _tripService = tripService;
29 + _expenseService = expenseService;
30 + _budgetCategoryService = budgetCategoryService;
31 + }
32 +
33 + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
34 +
35 + // GET: Trips
36 + public async Task<IActionResult> Index()
37 + {
38 + var userId = GetUserId();
39 +
40 + var trips = await _tripService.GetUserTripsAsync(userId);
41 +
42 + var model = new List<TripIndexViewModel>();
43 + foreach (var trip in trips)
44 + {
45 + var participant = trip.Participants?.FirstOrDefault(p => p.UserId == userId && p.IsActive);
46 + if (participant == null) continue;
47 +
48 + model.Add(new TripIndexViewModel
49 + {
50 + Id = trip.Id,
51 + Name = trip.Name,
52 + Destination = trip.Destination,
53 + Status = trip.Status,
54 + StartDate = trip.StartDate,
55 + EndDate = trip.EndDate,
56 + Role = participant.Role,
57 + CurrencyCode = trip.DefaultCurrency?.Code ?? ""
58 + });
59 + }
60 +
61 + return View(model);
62 + }
63 +
64 + // GET: Trips/Details/5
65 + public async Task<IActionResult> Details(Guid id)
66 + {
67 + var userId = GetUserId();
68 +
69 + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId);
70 + if (trip == null) return NotFound();
71 +
72 + // Get participant role
73 + var participants = await _tripService.GetParticipantsAsync(id, userId);
74 + var participant = participants.FirstOrDefault(p => p.UserId == userId);
75 +
76 + var defaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR";
77 +
78 + // Get expenses with details for balance calculation
79 + var expensesAll = await _expenseService.GetByTripIdAsync(id, userId);
80 +
81 + var recentExpenses = expensesAll
82 + .OrderByDescending(e => e.ExpenseDate)
83 + .Take(5)
84 + .ToList();
85 +
86 + var totalExpenses = expensesAll.Sum(e =>
87 + CurrencyConverter.Convert(e.Amount, e.Currency?.Code ?? defaultCurrencyCode, defaultCurrencyCode));
88 +
89 + // Calculate balances for each participant
90 + var balances = new Dictionary<Guid, SettlementBalanceViewModel>();
91 + foreach (var p in participants)
92 + {
93 + balances[p.UserId] = new SettlementBalanceViewModel
94 + {
95 + UserId = p.UserId,
96 + UserName = !string.IsNullOrWhiteSpace(p.User?.FullName)
97 + ? p.User!.FullName
98 + : (p.User?.Email ?? "Unknown"),
99 + TotalPaid = 0,
100 + TotalOwed = 0
101 + };
102 + }
103 +
104 + foreach (var expense in expensesAll)
105 + {
106 + var expenseWithSplits = await _expenseService.GetByIdWithDetailsAsync(expense.Id, userId);
107 + if (expenseWithSplits == null) continue;
108 +
109 + var expCurrency = expenseWithSplits.Currency?.Code ?? defaultCurrencyCode;
110 + var convertedAmount = CurrencyConverter.Convert(expenseWithSplits.Amount, expCurrency, defaultCurrencyCode);
111 +
112 + if (balances.ContainsKey(expenseWithSplits.PaidByUserId))
113 + balances[expenseWithSplits.PaidByUserId].TotalPaid += convertedAmount;
114 +
115 + if (expenseWithSplits.Splits != null)
116 + {
117 + foreach (var split in expenseWithSplits.Splits)
118 + {
119 + var convertedSplit = CurrencyConverter.Convert(split.Amount, expCurrency, defaultCurrencyCode);
120 + if (balances.ContainsKey(split.UserId))
121 + balances[split.UserId].TotalOwed += convertedSplit;
122 + }
123 + }
124 + }
125 +
126 + // Calculate budget totals (only category-assigned expenses count against budget)
127 + var budgetCategories = await _budgetCategoryService.GetByTripIdAsync(id, userId);
128 + var totalPlanned = budgetCategories.Sum(c => c.PlannedAmount ?? 0);
129 + var totalBudgetSpent = budgetCategories.Sum(c => c.SpentAmount);
130 + var budgetUsedPct = totalPlanned > 0 ? (int)(totalBudgetSpent * 100 / totalPlanned) : 0;
131 +
132 + // Current user's balance
133 + var currentUserBalance = balances.ContainsKey(userId) ? balances[userId].NetBalance : 0;
134 +
135 + ViewData["TripId"] = id;
136 + ViewData["TripName"] = trip.Name;
137 + ViewData["ParticipantCount"] = participants.Count;
138 + ViewData["RecentExpenses"] = recentExpenses;
139 + ViewData["TotalExpenses"] = totalExpenses;
140 + ViewData["UserRole"] = participant?.Role ?? EParticipantRole.Participant;
141 + ViewData["Balances"] = balances.Values.OrderByDescending(b => b.NetBalance).ToList();
142 + ViewData["CurrentUserBalance"] = currentUserBalance;
143 + ViewData["BudgetUsedPct"] = budgetUsedPct;
144 + ViewData["TotalPlanned"] = totalPlanned;
145 + ViewData["CurrencySymbol"] = trip.DefaultCurrency?.Symbol ?? "\u20ac";
146 +
147 + return View(trip);
148 + }
149 +
150 + // GET: Trips/Create
151 + public async Task<IActionResult> Create()
152 + {
153 + await PopulateCurrencyDropdown();
154 + return View();
155 + }
156 +
157 + // POST: Trips/Create
158 + [HttpPost]
159 + [ValidateAntiForgeryToken]
160 + public async Task<IActionResult> Create(TripBllDto trip)
161 + {
162 + var userId = GetUserId();
163 +
164 + if (ModelState.IsValid)
165 + {
166 + var created = await _tripService.CreateTripAsync(trip, userId);
167 + return RedirectToAction(nameof(Details), new { id = created.Id });
168 + }
169 +
170 + await PopulateCurrencyDropdown(trip.DefaultCurrencyId);
171 + return View(trip);
172 + }
173 +
174 + // GET: Trips/Edit/5
175 + public async Task<IActionResult> Edit(Guid id)
176 + {
177 + var userId = GetUserId();
178 +
179 + var trip = await _tripService.GetByIdForOrganizerAsync(id, userId);
180 + if (trip == null)
181 + {
182 + // Distinguish not-organizer vs not-found
183 + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid();
184 + return NotFound();
185 + }
186 +
187 + await PopulateCurrencyDropdown(trip.DefaultCurrencyId);
188 + return View(trip);
189 + }
190 +
191 + // POST: Trips/Edit/5
192 + [HttpPost]
193 + [ValidateAntiForgeryToken]
194 + public async Task<IActionResult> Edit(Guid id, TripBllDto trip)
195 + {
196 + if (id != trip.Id) return NotFound();
197 +
198 + var userId = GetUserId();
199 +
200 + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid();
201 +
202 + if (ModelState.IsValid)
203 + {
204 + var updated = await _tripService.UpdateAsync(trip, userId);
205 + if (updated == null) return NotFound();
206 + return RedirectToAction(nameof(Details), new { id });
207 + }
208 +
209 + await PopulateCurrencyDropdown(trip.DefaultCurrencyId);
210 + return View(trip);
211 + }
212 +
213 + // GET: Trips/Delete/5
214 + public async Task<IActionResult> Delete(Guid id)
215 + {
216 + var userId = GetUserId();
217 +
218 + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid();
219 +
220 + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId);
221 + if (trip == null) return NotFound();
222 +
223 + return View(trip);
224 + }
225 +
226 + // POST: Trips/Delete/5
227 + [HttpPost, ActionName("Delete")]
228 + [ValidateAntiForgeryToken]
229 + public async Task<IActionResult> DeleteConfirmed(Guid id)
230 + {
231 + var userId = GetUserId();
232 +
233 + var success = await _tripService.DeleteAsync(id, userId);
234 + if (!success)
235 + {
236 + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid();
237 + return NotFound();
238 + }
239 +
240 + return RedirectToAction(nameof(Index));
241 + }
242 +
243 + private async Task PopulateCurrencyDropdown(Guid? selectedId = null)
244 + {
245 + var currencies = await _tripService.GetAllCurrenciesAsync();
246 + ViewData["DefaultCurrencyId"] = new SelectList(currencies, "Id", "Code", selectedId);
247 + }
248 +}
249 +
250 +public class TripIndexViewModel
251 +{
252 + public Guid Id { get; set; }
253 + public string Name { get; set; } = default!;
254 + public string? Destination { get; set; }
255 + public ETripStatus Status { get; set; }
256 + public DateTime? StartDate { get; set; }
257 + public DateTime? EndDate { get; set; }
258 + public EParticipantRole Role { get; set; }
259 + public string CurrencyCode { get; set; } = default!;
260 +}
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/WishlistClientController.cs +277 −0
@@ -0,0 +1,277 @@
1 +using System.Security.Claims;
2 +using SplitApp.WebApp.Application.DTO;
3 +using SplitApp.WebApp.Application.Services;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Domain.Enums;
6 +using SplitApp.Modules.Expenses.Domain.Entities;
7 +using SplitApp.Modules.Expenses.Domain.Enums;
8 +using Microsoft.AspNetCore.Authorization;
9 +using Microsoft.AspNetCore.Identity;
10 +using Microsoft.AspNetCore.Mvc;
11 +using Microsoft.AspNetCore.Mvc.Rendering;
12 +
13 +namespace SplitApp.WebApp.Controllers;
14 +
15 +[Authorize]
16 +public class WishlistClientController : Controller
17 +{
18 + private readonly ITripService _tripService;
19 + private readonly IWishlistService _wishlistService;
20 +
21 + public WishlistClientController(
22 + ITripService tripService,
23 + IWishlistService wishlistService)
24 + {
25 + _tripService = tripService;
26 + _wishlistService = wishlistService;
27 + }
28 +
29 + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
30 +
31 + // GET: WishlistClient?tripId=xxx
32 + public async Task<IActionResult> Index(Guid tripId)
33 + {
34 + var userId = GetUserId();
35 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
36 +
37 + var trip = await _tripService.GetByIdAsync(tripId, userId);
38 + if (trip == null) return NotFound();
39 +
40 + var items = await _wishlistService.GetByTripIdAsync(tripId, userId);
41 +
42 + ViewData["CurrentUserId"] = userId;
43 +
44 + var model = items.Select(item => new WishlistItemViewModel
45 + {
46 + Id = item.Id,
47 + AddedByUserId = item.AddedByUserId,
48 + Title = item.Title,
49 + Description = item.Description,
50 + Category = item.Category,
51 + Priority = item.Priority,
52 + EstimatedCost = item.EstimatedCost,
53 + Url = item.Url,
54 + Location = item.Location,
55 + IsCompleted = item.IsCompleted,
56 + AddedByName = item.AddedByUserFullName ?? "Unknown",
57 + VoteCount = item.VoteCount,
58 + UserHasVoted = item.VoterUserIds.Contains(userId)
59 + }).ToList();
60 +
61 + ViewData["TripId"] = tripId;
62 + ViewData["TripName"] = trip.Name;
63 +
64 + return View(model);
65 + }
66 +
67 + // GET: WishlistClient/Create?tripId=xxx
68 + public async Task<IActionResult> Create(Guid tripId)
69 + {
70 + var userId = GetUserId();
71 + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid();
72 +
73 + ViewData["TripId"] = tripId;
74 + PopulateEnumDropdowns();
75 + return View(new TripWishlistItemBllDto { TripId = tripId });
76 + }
77 +
78 + // POST: WishlistClient/Create
79 + [HttpPost]
80 + [ValidateAntiForgeryToken]
81 + public async Task<IActionResult> Create(TripWishlistItemBllDto item)
82 + {
83 + var userId = GetUserId();
84 +
85 + if (ModelState.IsValid)
86 + {
87 + var (created, errorCode) = await _wishlistService.CreateAsync(item, userId);
88 + if (created == null)
89 + {
90 + if (errorCode == "forbidden") return Forbid();
91 + return NotFound();
92 + }
93 + return RedirectToAction(nameof(Index), new { tripId = item.TripId });
94 + }
95 +
96 + ViewData["TripId"] = item.TripId;
97 + PopulateEnumDropdowns();
98 + return View(item);
99 + }
100 +
101 + // POST: WishlistClient/Vote/5
102 + [HttpPost]
103 + [ValidateAntiForgeryToken]
104 + public async Task<IActionResult> Vote(Guid id)
105 + {
106 + var userId = GetUserId();
107 +
108 + var item = await _wishlistService.GetByIdRawAsync(id);
109 + if (item == null) return NotFound();
110 +
111 + var (ok, errorCode) = await _wishlistService.ToggleVoteAsync(id, userId);
112 + if (!ok)
113 + {
114 + return errorCode switch
115 + {
116 + "notfound" => NotFound(),
117 + "forbidden" => Forbid(),
118 + _ => NotFound()
119 + };
120 + }
121 +
122 + return RedirectToAction(nameof(Index), new { tripId = item.TripId });
123 + }
124 +
125 + // POST: WishlistClient/Complete/5
126 + [HttpPost]
127 + [ValidateAntiForgeryToken]
128 + public async Task<IActionResult> Complete(Guid id)
129 + {
130 + var userId = GetUserId();
131 +
132 + var item = await _wishlistService.GetByIdRawAsync(id);
133 + if (item == null) return NotFound();
134 +
135 + var (ok, errorCode) = await _wishlistService.ToggleCompleteAsync(id, userId);
136 + if (!ok)
137 + {
138 + return errorCode switch
139 + {
140 + "notfound" => NotFound(),
141 + "forbidden" => Forbid(),
142 + _ => NotFound()
143 + };
144 + }
145 +
146 + return RedirectToAction(nameof(Index), new { tripId = item.TripId });
147 + }
148 +
149 + // GET: WishlistClient/Edit/5
150 + public async Task<IActionResult> Edit(Guid id)
151 + {
152 + var userId = GetUserId();
153 +
154 + var item = await _wishlistService.GetByIdAsync(id, userId);
155 + if (item == null)
156 + {
157 + var raw = await _wishlistService.GetByIdRawAsync(id);
158 + if (raw == null) return NotFound();
159 + return Forbid();
160 + }
161 +
162 + if (item.AddedByUserId != userId) return Forbid();
163 +
164 + ViewData["TripId"] = item.TripId;
165 + PopulateEnumDropdowns();
166 + return View(item);
167 + }
168 +
169 + // POST: WishlistClient/Edit/5
170 + [HttpPost]
171 + [ValidateAntiForgeryToken]
172 + public async Task<IActionResult> Edit(Guid id, TripWishlistItemBllDto item)
173 + {
174 + if (id != item.Id) return NotFound();
175 +
176 + var userId = GetUserId();
177 +
178 + if (ModelState.IsValid)
179 + {
180 + var preUpdate = await _wishlistService.GetByIdRawAsync(id);
181 + if (preUpdate == null) return NotFound();
182 +
183 + var (ok, errorCode) = await _wishlistService.UpdateAsync(id, item, userId);
184 + if (!ok)
185 + {
186 + return errorCode switch
187 + {
188 + "notfound" => NotFound(),
189 + "forbidden" => Forbid(),
190 + _ => NotFound()
191 + };
192 + }
193 +
194 + return RedirectToAction(nameof(Index), new { tripId = preUpdate.TripId });
195 + }
196 +
197 + var raw = await _wishlistService.GetByIdRawAsync(id);
198 + if (raw == null) return NotFound();
199 + ViewData["TripId"] = raw.TripId;
200 + PopulateEnumDropdowns();
201 + return View(item);
202 + }
203 +
204 + // GET: WishlistClient/Delete/5
205 + public async Task<IActionResult> Delete(Guid id)
206 + {
207 + var userId = GetUserId();
208 +
209 + var item = await _wishlistService.GetByIdAsync(id, userId);
210 + if (item == null)
211 + {
212 + var raw = await _wishlistService.GetByIdRawAsync(id);
213 + if (raw == null) return NotFound();
214 + return Forbid();
215 + }
216 + if (item.AddedByUserId != userId) return Forbid();
217 +
218 + // Re-fetch with includes via trip items list
219 + var tripItems = await _wishlistService.GetByTripIdAsync(item.TripId, userId);
220 + var itemWithDetails = tripItems.FirstOrDefault(w => w.Id == id);
221 +
222 + ViewData["TripId"] = item.TripId;
223 + return View(itemWithDetails ?? item);
224 + }
225 +
226 + // POST: WishlistClient/Delete/5
227 + [HttpPost, ActionName("Delete")]
228 + [ValidateAntiForgeryToken]
229 + public async Task<IActionResult> DeleteConfirmed(Guid id)
230 + {
231 + var userId = GetUserId();
232 +
233 + var existing = await _wishlistService.GetByIdRawAsync(id);
234 + if (existing == null) return NotFound();
235 + var tripId = existing.TripId;
236 +
237 + var (ok, errorCode) = await _wishlistService.DeleteAsync(id, userId);
238 + if (!ok)
239 + {
240 + return errorCode switch
241 + {
242 + "notfound" => NotFound(),
243 + "forbidden" => Forbid(),
244 + _ => NotFound()
245 + };
246 + }
247 +
248 + return RedirectToAction(nameof(Index), new { tripId });
249 + }
250 +
251 + private void PopulateEnumDropdowns()
252 + {
253 + ViewData["Categories"] = new SelectList(
254 + Enum.GetValues<EWishlistCategory>().Select(e => new { Value = (int)e, Text = e.ToString() }),
255 + "Value", "Text");
256 + ViewData["Priorities"] = new SelectList(
257 + Enum.GetValues<EWishlistPriority>().Select(e => new { Value = (int)e, Text = e.ToString() }),
258 + "Value", "Text");
259 + }
260 +}
261 +
262 +public class WishlistItemViewModel
263 +{
264 + public Guid Id { get; set; }
265 + public Guid AddedByUserId { get; set; }
266 + public string Title { get; set; } = default!;
267 + public string? Description { get; set; }
268 + public EWishlistCategory Category { get; set; }
269 + public EWishlistPriority Priority { get; set; }
270 + public decimal? EstimatedCost { get; set; }
271 + public string? Url { get; set; }
272 + public string? Location { get; set; }
273 + public bool IsCompleted { get; set; }
274 + public string AddedByName { get; set; } = default!;
275 + public int VoteCount { get; set; }
276 + public bool UserHasVoted { get; set; }
277 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/AppDataInit.cs +138 −0
@@ -0,0 +1,138 @@
1 +using SplitApp.Modules.Expenses.Domain.Entities;
2 +using SplitApp.Modules.Expenses.Domain.Enums;
3 +using SplitApp.Modules.Expenses.Infrastructure.Persistence;
4 +using SplitApp.Modules.Trips.Domain.Entities;
5 +using SplitApp.Modules.Trips.Domain.Enums;
6 +using SplitApp.Modules.Trips.Infrastructure.Persistence;
7 +using SplitApp.Shared.Kernel.Localization;
8 +using SplitApp.WebApp.Application.UsersService;
9 +
10 +namespace SplitApp.WebApp.Hosting;
11 +
12 +public static class AppDataInit
13 +{
14 + public static void SeedExampleData(IServiceProvider services)
15 + {
16 + using var scope = services.CreateScope();
17 + var usersClient = scope.ServiceProvider.GetRequiredService<IUsersServiceClient>();
18 + var trips = scope.ServiceProvider.GetRequiredService<TripsDbContext>();
19 + var expenses = scope.ServiceProvider.GetRequiredService<ExpensesDbContext>();
20 +
21 + if (trips.Trips.Any()) return;
22 +
23 + // Users live in the Users service now — fetch them via HTTP. The compose healthcheck
24 + // gates webapp on users-service:service_healthy, so the seed users should already be
25 + // present here. If the call times out, just skip example seeding (operator can rerun).
26 + List<SplitApp.WebApp.Application.UsersService.Dtos.AdminUserListItem> allUsers;
27 + try
28 + {
29 + allUsers = usersClient.ListUsersAsync().GetAwaiter().GetResult().ToList();
30 + }
31 + catch
32 + {
33 + return;
34 + }
35 +
36 + Guid IdOf(string email) => allUsers.FirstOrDefault(u =>
37 + string.Equals(u.Email, email, StringComparison.OrdinalIgnoreCase))?.Id ?? Guid.Empty;
38 +
39 + var admin = IdOf("admin@example.com");
40 + var testUser = IdOf("user@example.com");
41 + var alice = IdOf("alice@example.com");
42 + var bob = IdOf("bob@example.com");
43 + var charlie = IdOf("charlie@example.com");
44 + var diana = IdOf("diana@example.com");
45 +
46 + if (admin == Guid.Empty || testUser == Guid.Empty || alice == Guid.Empty
47 + || bob == Guid.Empty || charlie == Guid.Empty || diana == Guid.Empty)
48 + {
49 + return;
50 + }
51 +
52 + var eur = expenses.Currencies.First(c => c.Code == "EUR").Id;
53 + var usd = expenses.Currencies.First(c => c.Code == "USD").Id;
54 + var gbp = expenses.Currencies.First(c => c.Code == "GBP").Id;
55 +
56 + var now = DateTime.UtcNow;
57 +
58 + var trip1 = new Trip
59 + {
60 + Name = "Barcelona Weekend",
61 + Description = "A long weekend exploring Barcelona with friends",
62 + Destination = "Barcelona, Spain",
63 + StartDate = new DateTime(2026, 4, 10, 0, 0, 0, DateTimeKind.Utc),
64 + EndDate = new DateTime(2026, 4, 13, 0, 0, 0, DateTimeKind.Utc),
65 + Status = ETripStatus.Active,
66 + DefaultCurrencyId = eur,
67 + CreatedById = admin,
68 + };
69 + trips.Trips.Add(trip1);
70 +
71 + trips.TripParticipants.AddRange(
72 + new TripParticipant { TripId = trip1.Id, UserId = admin, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-10), IsActive = true },
73 + new TripParticipant { TripId = trip1.Id, UserId = alice, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-9), IsActive = true },
74 + new TripParticipant { TripId = trip1.Id, UserId = bob, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-8), IsActive = true },
75 + new TripParticipant { TripId = trip1.Id, UserId = charlie, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-7), IsActive = true });
76 +
77 + var cat1Food = new BudgetCategory { TripId = trip1.Id, Name = Lang("Food & Drinks", "Toit ja joogid"), IconName = "cup-hot", PlannedAmount = 400m, DisplayOrder = 0 };
78 + var cat1Transport = new BudgetCategory { TripId = trip1.Id, Name = Lang("Transport", "Transport"), IconName = "bus-front", PlannedAmount = 150m, DisplayOrder = 1 };
79 + var cat1Activities = new BudgetCategory { TripId = trip1.Id, Name = Lang("Activities", "Tegevused"), IconName = "binoculars", PlannedAmount = 200m, DisplayOrder = 2 };
80 + var cat1Accommodation = new BudgetCategory { TripId = trip1.Id, Name = Lang("Accommodation", "Majutus"), IconName = "house", PlannedAmount = 500m, DisplayOrder = 3 };
81 + trips.BudgetCategories.AddRange(cat1Food, cat1Transport, cat1Activities, cat1Accommodation);
82 +
83 + trips.SaveChanges();
84 +
85 + // Slimmed seed: keep one trip with example expenses so the UX has data on first boot.
86 + // The full phase-3 fixture is preserved in source history; truncated here to keep the
87 + // post-extraction monolith boot path simple.
88 + var trip1Members = new[] { admin, alice, bob, charlie };
89 + var trip1Expenses = new (string desc, decimal amount, Guid paidBy, Guid? catId, DateTime date, ESplitMethod split)[]
90 + {
91 + ("Airbnb apartment (3 nights)", 480.00m, admin, cat1Accommodation.Id, now.AddDays(-5), ESplitMethod.EqualAll),
92 + ("Airport taxi", 35.00m, alice, cat1Transport.Id, now.AddDays(-5), ESplitMethod.EqualAll),
93 + ("Grocery shopping", 62.50m, bob, cat1Food.Id, now.AddDays(-4), ESplitMethod.EqualAll),
94 + ("Sagrada Familia tickets", 104.00m, charlie, cat1Activities.Id, now.AddDays(-3), ESplitMethod.EqualAll),
95 + };
96 +
97 + foreach (var (desc, amount, paidBy, catId, date, split) in trip1Expenses)
98 + {
99 + var ex = new Expense
100 + {
101 + TripId = trip1.Id, PaidByUserId = paidBy, Amount = amount,
102 + Description = desc, ExpenseDate = date, BudgetCategoryId = catId,
103 + CurrencyId = eur, SplitMethod = split,
104 + };
105 + expenses.Expenses.Add(ex);
106 + AddEqualSplits(expenses, ex, trip1Members);
107 + }
108 +
109 + expenses.SaveChanges();
110 +
111 + _ = testUser; _ = diana; _ = usd; _ = gbp;
112 + }
113 +
114 + private static LangStr Lang(string en, string et)
115 + {
116 + var s = new LangStr(en, "en");
117 + s["et"] = et;
118 + return s;
119 + }
120 +
121 + private static void AddEqualSplits(ExpensesDbContext db, Expense expense, Guid[] memberIds)
122 + {
123 + var count = memberIds.Length;
124 + var baseAmt = Math.Floor(expense.Amount / count * 100m) / 100m;
125 + var remainderCents = (int)Math.Round((expense.Amount - baseAmt * count) * 100m);
126 +
127 + for (var i = 0; i < count; i++)
128 + {
129 + var splitAmt = baseAmt + (i < remainderCents ? 0.01m : 0m);
130 + db.ExpenseSplits.Add(new ExpenseSplit
131 + {
132 + ExpenseId = expense.Id,
133 + UserId = memberIds[i],
134 + Amount = splitAmt,
135 + });
136 + }
137 + }
138 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/ConfigureSwaggerOptions.cs +61 −0
@@ -0,0 +1,61 @@
1 +using Asp.Versioning.ApiExplorer;
2 +using Microsoft.Extensions.Options;
3 +using Microsoft.OpenApi;
4 +using Swashbuckle.AspNetCore.SwaggerGen;
5 +
6 +namespace SplitApp.WebApp.Hosting;
7 +
8 +public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
9 +{
10 + private readonly IApiVersionDescriptionProvider _descriptionProvider;
11 +
12 + public ConfigureSwaggerOptions(IApiVersionDescriptionProvider descriptionProvider)
13 + {
14 + _descriptionProvider = descriptionProvider;
15 + }
16 +
17 + public void Configure(SwaggerGenOptions options)
18 + {
19 + foreach (var description in _descriptionProvider.ApiVersionDescriptions)
20 + {
21 + options.SwaggerDoc(
22 + description.GroupName,
23 + new OpenApiInfo
24 + {
25 + Title = $"SplitApp API {description.ApiVersion}",
26 + Version = description.ApiVersion.ToString(),
27 + });
28 + }
29 +
30 + options.CustomSchemaIds(t => t.FullName);
31 +
32 + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
33 + {
34 + Description =
35 + "JWT Authorization header using the Bearer scheme.\r\n<br/>" +
36 + "Enter your token in the text box below.\r\n<br/>" +
37 + "You will get the bearer from the <i>account/login</i> or <i>account/register</i> endpoint.",
38 + Name = "Authorization",
39 + In = ParameterLocation.Header,
40 + Type = SecuritySchemeType.Http,
41 + Scheme = "Bearer",
42 + BearerFormat = "JWT",
43 + });
44 +
45 + options.DocumentFilter<BearerSecurityRequirementDocumentFilter>();
46 + }
47 +}
48 +
49 +public class BearerSecurityRequirementDocumentFilter : IDocumentFilter
50 +{
51 + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
52 + {
53 + swaggerDoc.Security = new List<OpenApiSecurityRequirement>
54 + {
55 + new()
56 + {
57 + [new OpenApiSecuritySchemeReference("Bearer", swaggerDoc)] = new List<string>(),
58 + },
59 + };
60 + }
61 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/CurrencyConverter.cs +30 −0
@@ -0,0 +1,30 @@
1 +namespace SplitApp.WebApp.Hosting.Helpers;
2 +
3 +public static class CurrencyConverter
4 +{
5 + // Rates: 1 unit of currency = X EUR (approximate, hardcoded)
6 + private static readonly Dictionary<string, decimal> RatesToEur = new()
7 + {
8 + { "EUR", 1.0m },
9 + { "USD", 0.92m },
10 + { "GBP", 1.16m },
11 + { "SEK", 0.087m },
12 + { "NOK", 0.086m },
13 + };
14 +
15 + /// <summary>
16 + /// Convert amount from one currency to another.
17 + /// Falls back to 1:1 if either currency code is unknown.
18 + /// </summary>
19 + public static decimal Convert(decimal amount, string fromCurrencyCode, string toCurrencyCode)
20 + {
21 + if (fromCurrencyCode == toCurrencyCode) return amount;
22 +
23 + if (!RatesToEur.TryGetValue(fromCurrencyCode, out var fromRate)) return amount;
24 + if (!RatesToEur.TryGetValue(toCurrencyCode, out var toRate)) return amount;
25 +
26 + // Convert: amount in "from" -> EUR -> "to"
27 + var amountInEur = amount * fromRate;
28 + return Math.Round(amountInEur / toRate, 2);
29 + }
30 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/EnumHelper.cs +18 −0
@@ -0,0 +1,18 @@
1 +using System.Resources;
2 +
3 +namespace SplitApp.WebApp.Hosting.Helpers;
4 +
5 +/// <summary>Returns the enum's localized display name from App.Resources.Domain.Enums
6 +/// (keys are "{EnumTypeName}_{Value}", e.g. "ETripStatus_Active"). Falls back to the
7 +/// enum's ToString() if the resource lookup fails.</summary>
8 +public static class EnumHelper
9 +{
10 + private static readonly ResourceManager ResManager =
11 + new("App.Resources.Domain.Enums", typeof(App.Resources.Domain.Enums).Assembly);
12 +
13 + public static string GetDisplayName<TEnum>(TEnum value) where TEnum : struct, Enum
14 + {
15 + var key = $"{typeof(TEnum).Name}_{value}";
16 + return ResManager.GetString(key, Thread.CurrentThread.CurrentUICulture) ?? value.ToString();
17 + }
18 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/InvariantDecimalModelBinderProvider.cs +55 −0
@@ -0,0 +1,55 @@
1 +using System.Globalization;
2 +using Microsoft.AspNetCore.Mvc.ModelBinding;
3 +
4 +namespace SplitApp.WebApp.Hosting;
5 +
6 +/// <summary>
7 +/// Ensures decimal values from HTML number inputs (which always send dot-separated values)
8 +/// bind correctly regardless of the server's request culture (e.g. et-EE uses comma).
9 +/// </summary>
10 +public class InvariantDecimalModelBinderProvider : IModelBinderProvider
11 +{
12 + public IModelBinder? GetBinder(ModelBinderProviderContext context)
13 + {
14 + if (context.Metadata.ModelType == typeof(decimal) || context.Metadata.ModelType == typeof(decimal?))
15 + {
16 + return new InvariantDecimalModelBinder();
17 + }
18 +
19 + return null;
20 + }
21 +}
22 +
23 +public class InvariantDecimalModelBinder : IModelBinder
24 +{
25 + public Task BindModelAsync(ModelBindingContext bindingContext)
26 + {
27 + var valueResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
28 + if (valueResult == ValueProviderResult.None)
29 + return Task.CompletedTask;
30 +
31 + var value = valueResult.FirstValue;
32 + if (string.IsNullOrWhiteSpace(value))
33 + {
34 + if (bindingContext.ModelType == typeof(decimal?))
35 + {
36 + bindingContext.Result = ModelBindingResult.Success(null);
37 + }
38 +
39 + return Task.CompletedTask;
40 + }
41 +
42 + value = value.Replace(',', '.');
43 +
44 + if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
45 + {
46 + bindingContext.Result = ModelBindingResult.Success(result);
47 + }
48 + else
49 + {
50 + bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, "Invalid number format.");
51 + }
52 +
53 + return Task.CompletedTask;
54 + }
55 +}
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/PassthroughStringLocalizer.cs +28 −0
@@ -0,0 +1,28 @@
1 +using System.Globalization;
2 +using Microsoft.Extensions.Localization;
3 +
4 +namespace SplitApp.WebApp.Hosting;
5 +
6 +/// <summary>
7 +/// No-op localizer factory: every requested key is returned unchanged. Phase 2 used
8 +/// resource files; in modular monolith we simply pass the key through so all the
9 +/// `IStringLocalizer<App.Resources.Views.Shared>` references continue to compile and
10 +/// produce sensible English strings.
11 +/// </summary>
12 +public class PassthroughStringLocalizerFactory : IStringLocalizerFactory
13 +{
14 + public IStringLocalizer Create(Type resourceSource) => new PassthroughStringLocalizer();
15 + public IStringLocalizer Create(string baseName, string location) => new PassthroughStringLocalizer();
16 +}
17 +
18 +public class PassthroughStringLocalizer : IStringLocalizer
19 +{
20 + public LocalizedString this[string name] => new(name, name, resourceNotFound: false);
21 +
22 + public LocalizedString this[string name, params object[] arguments]
23 + => new(name, string.Format(CultureInfo.CurrentCulture, name, arguments), resourceNotFound: false);
24 +
25 + public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) => Array.Empty<LocalizedString>();
26 +}
27 +
28 +public class PassthroughStringLocalizer<T> : PassthroughStringLocalizer, IStringLocalizer<T> { }
added SplitApp.Modular/src/SplitApp.WebApp/Models/Account/AccountViewModels.cs +47 −0
@@ -0,0 +1,47 @@
1 +using System.ComponentModel.DataAnnotations;
2 +
3 +namespace SplitApp.WebApp.Models.Account;
4 +
5 +public class LoginViewModel
6 +{
7 + [Required, EmailAddress, Display(Name = "Email")]
8 + public string Email { get; set; } = "";
9 +
10 + [Required, DataType(DataType.Password), Display(Name = "Password")]
11 + public string Password { get; set; } = "";
12 +
13 + public string? ReturnUrl { get; set; }
14 +}
15 +
16 +public class RegisterViewModel
17 +{
18 + [Required, StringLength(128), Display(Name = "First Name")]
19 + public string FirstName { get; set; } = "";
20 +
21 + [Required, StringLength(128), Display(Name = "Last Name")]
22 + public string LastName { get; set; } = "";
23 +
24 + [Required, EmailAddress, Display(Name = "Email")]
25 + public string Email { get; set; } = "";
26 +
27 + [Required, StringLength(100, MinimumLength = 6), DataType(DataType.Password), Display(Name = "Password")]
28 + public string Password { get; set; } = "";
29 +
30 + [DataType(DataType.Password), Display(Name = "Confirm password")]
31 + [Compare(nameof(Password), ErrorMessage = "The password and confirmation password do not match.")]
32 + public string ConfirmPassword { get; set; } = "";
33 +
34 + public string? ReturnUrl { get; set; }
35 +}
36 +
37 +public class ManageViewModel
38 +{
39 + [Display(Name = "Email")]
40 + public string Email { get; set; } = "";
41 +
42 + [Display(Name = "First Name")]
43 + public string FirstName { get; set; } = "";
44 +
45 + [Display(Name = "Last Name")]
46 + public string LastName { get; set; } = "";
47 +}
added SplitApp.Modular/src/SplitApp.WebApp/Models/ErrorViewModel.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace SplitApp.WebApp.Models;
2 +
3 +public class ErrorViewModel
4 +{
5 + public string? RequestId { get; set; }
6 +
7 + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
8 +}
added SplitApp.Modular/src/SplitApp.WebApp/Program.cs +240 −0
@@ -0,0 +1,240 @@
1 +using System.Globalization;
2 +using System.Text;
3 +using Asp.Versioning;
4 +using Asp.Versioning.ApiExplorer;
5 +using Microsoft.AspNetCore.Authentication.JwtBearer;
6 +using Microsoft.AspNetCore.DataProtection;
7 +using Microsoft.AspNetCore.Localization;
8 +using Microsoft.AspNetCore.Mvc;
9 +using Microsoft.Extensions.Options;
10 +using Microsoft.IdentityModel.Tokens;
11 +using SplitApp.Modules.Expenses.Infrastructure;
12 +using SplitApp.Modules.Trips.Infrastructure;
13 +using SplitApp.Shared.Messaging;
14 +using SplitApp.WebApp.Application.Messaging;
15 +using SplitApp.WebApp.Application.UsersService;
16 +using SplitApp.WebApp.Hosting;
17 +using Swashbuckle.AspNetCore.SwaggerGen;
18 +
19 +var builder = WebApplication.CreateBuilder(args);
20 +
21 +// MVC + API + view localization. ApplicationParts surface every remaining module's
22 +// controllers (Trips, Expenses). The Users module now lives in SplitApp.UsersService.
23 +builder.Services
24 + .AddControllersWithViews(opts =>
25 + {
26 + opts.ModelBinderProviders.Insert(0, new InvariantDecimalModelBinderProvider());
27 + })
28 + .AddApplicationPart(typeof(SplitApp.Modules.Trips.Api.Controllers.TripsController).Assembly)
29 + .AddApplicationPart(typeof(SplitApp.Modules.Expenses.Api.Controllers.ExpensesController).Assembly)
30 + .AddViewLocalization()
31 + .AddDataAnnotationsLocalization(options =>
32 + {
33 + options.DataAnnotationLocalizerProvider = (type, factory) =>
34 + factory.Create(typeof(App.Resources.Views.Shared));
35 + });
36 +
37 +builder.Services.AddRazorPages();
38 +
39 +// API versioning
40 +builder.Services.AddApiVersioning(options =>
41 +{
42 + options.ReportApiVersions = true;
43 + options.DefaultApiVersion = new ApiVersion(1, 0);
44 + options.AssumeDefaultVersionWhenUnspecified = true;
45 + options.ApiVersionReader = new UrlSegmentApiVersionReader();
46 +}).AddApiExplorer(options =>
47 +{
48 + options.GroupNameFormat = "'v'VVV";
49 + options.SubstituteApiVersionInUrl = true;
50 +});
51 +
52 +builder.Services.AddEndpointsApiExplorer();
53 +builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
54 +builder.Services.AddSwaggerGen();
55 +
56 +// Per-module DI registration. Users module is no longer in-process — it runs in
57 +// SplitApp.UsersService and is reached over HTTP REST + RabbitMQ RPC.
58 +builder.Services.AddTripsModule(builder.Configuration);
59 +builder.Services.AddExpensesModule(builder.Configuration);
60 +
61 +// JWT bearer auth. Same signing key/issuer/audience as the Users service — tokens
62 +// issued there are validated locally here. OnMessageReceived also accepts the JWT from
63 +// a `jwt` cookie so the MVC views work without a manual Authorization header.
64 +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
65 + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
66 + {
67 + options.RequireHttpsMetadata = false;
68 + options.SaveToken = false;
69 + options.TokenValidationParameters = new TokenValidationParameters
70 + {
71 + ValidateIssuer = true,
72 + ValidateAudience = true,
73 + ValidateLifetime = true,
74 + ValidateIssuerSigningKey = true,
75 + ValidIssuer = builder.Configuration["JWT:Issuer"],
76 + ValidAudience = builder.Configuration["JWT:Audience"],
77 + IssuerSigningKey = new SymmetricSecurityKey(
78 + Encoding.UTF8.GetBytes(builder.Configuration["JWT:Key"]!)),
79 + ClockSkew = TimeSpan.Zero,
80 + };
81 + options.Events = new JwtBearerEvents
82 + {
83 + OnMessageReceived = ctx =>
84 + {
85 + if (string.IsNullOrEmpty(ctx.Token)
86 + && ctx.Request.Cookies.TryGetValue("jwt", out var cookieJwt)
87 + && !string.IsNullOrEmpty(cookieJwt))
88 + {
89 + ctx.Token = cookieJwt;
90 + }
91 + return Task.CompletedTask;
92 + },
93 + };
94 + });
95 +
96 +builder.Services.AddAuthorization();
97 +
98 +// DataProtection keys persist to file system so antiforgery + cookie payloads survive
99 +// container restarts. Docker compose mounts /app/keys as a named volume.
100 +builder.Services.AddDataProtection()
101 + .PersistKeysToFileSystem(new DirectoryInfo(builder.Environment.IsDevelopment() ? "./keys" : "/app/keys"))
102 + .SetApplicationName("SplitApp.WebApp");
103 +
104 +// Composition-root facade: aggregates the two remaining module DbContexts (Trips + Expenses).
105 +// IUserLookup (from messaging) replaces UsersDbContext for hydrating cross-service navs.
106 +builder.Services.AddScoped<SplitApp.WebApp.Application.Contracts.IAppUnitOfWork,
107 + SplitApp.WebApp.Application.Persistence.AppUnitOfWork>();
108 +builder.Services.AddScoped<SplitApp.WebApp.Application.Persistence.CrossModuleNavigationLoader>();
109 +
110 +// Phase 2 BLL services (lifted into WebApp/Application) — use IAppUnitOfWork.
111 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ITripService, SplitApp.WebApp.Application.Services.TripService>();
112 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IExpenseService, SplitApp.WebApp.Application.Services.ExpenseService>();
113 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISettlementService, SplitApp.WebApp.Application.Services.SettlementService>();
114 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IInvitationService, SplitApp.WebApp.Application.Services.InvitationService>();
115 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IPollService, SplitApp.WebApp.Application.Services.PollService>();
116 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IBudgetCategoryService, SplitApp.WebApp.Application.Services.BudgetCategoryService>();
117 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IWishlistService, SplitApp.WebApp.Application.Services.WishlistService>();
118 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISplitPresetService, SplitApp.WebApp.Application.Services.SplitPresetService>();
119 +
120 +// Admin BLL services.
121 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IBudgetCategoryAdminService, SplitApp.WebApp.Application.Services.Admin.BudgetCategoryAdminService>();
122 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ICurrencyAdminService, SplitApp.WebApp.Application.Services.Admin.CurrencyAdminService>();
123 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripAdminService, SplitApp.WebApp.Application.Services.Admin.TripAdminService>();
124 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IExpenseAdminService, SplitApp.WebApp.Application.Services.Admin.ExpenseAdminService>();
125 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IPollAdminService, SplitApp.WebApp.Application.Services.Admin.PollAdminService>();
126 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IWishlistAdminService, SplitApp.WebApp.Application.Services.Admin.WishlistAdminService>();
127 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPlanAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPlanAdminService>();
128 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPaymentAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPaymentAdminService>();
129 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISplitPresetAdminService, SplitApp.WebApp.Application.Services.Admin.SplitPresetAdminService>();
130 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripParticipantAdminService, SplitApp.WebApp.Application.Services.Admin.TripParticipantAdminService>();
131 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IInvitationAdminService, SplitApp.WebApp.Application.Services.Admin.InvitationAdminService>();
132 +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IAdminStatsService, SplitApp.WebApp.Application.Services.Admin.AdminStatsService>();
133 +
134 +// Messaging: RabbitMQ for inter-service RPC + events. ServiceName "webapp" appears in
135 +// queue names so each subscriber gets its own durable queue.
136 +builder.Services.AddMessaging(builder.Configuration, serviceName: "webapp");
137 +builder.Services.AddIntegrationEventHandler<SplitApp.Shared.Messaging.Integration.Users.UserDeletedEvent, UserDeletedEventHandler>();
138 +
139 +// Typed HttpClient for the Users service. JwtForwardingHandler propagates the inbound
140 +// JWT (cookie or Authorization header) on outbound calls.
141 +builder.Services.AddHttpContextAccessor();
142 +builder.Services.AddTransient<JwtForwardingHandler>();
143 +builder.Services.AddHttpClient<IUsersServiceClient, UsersServiceClient>(c =>
144 + {
145 + c.BaseAddress = new Uri(builder.Configuration["UsersService:BaseUrl"] ?? "http://users-service:8080");
146 + c.Timeout = TimeSpan.FromSeconds(30);
147 + })
148 + .AddHttpMessageHandler<JwtForwardingHandler>();
149 +
150 +// Localization — EN+ET. resx files in Resources/Views/Shared.{resx,et.resx} are
151 +// embedded with explicit LogicalName.
152 +builder.Services.AddLocalization(options => options.ResourcesPath = "");
153 +
154 +var supportedCultures = (builder.Configuration.GetSection("SupportedCultures").Get<string[]>()
155 + ?? new[] { "en", "et" })
156 + .Select(c => new CultureInfo(c)).ToArray();
157 +builder.Services.Configure<RequestLocalizationOptions>(options =>
158 +{
159 + options.SupportedCultures = supportedCultures;
160 + options.SupportedUICultures = supportedCultures;
161 + options.DefaultRequestCulture = new RequestCulture("en", "en");
162 + options.SetDefaultCulture("en");
163 + options.RequestCultureProviders = new List<IRequestCultureProvider>
164 + {
165 + new QueryStringRequestCultureProvider(),
166 + new CookieRequestCultureProvider(),
167 + };
168 +});
169 +
170 +// CORS — same wide-open policy as phase 2/3 so external SPA frontends can call /api/v1/*.
171 +builder.Services.AddCors(options =>
172 +{
173 + options.AddPolicy("CorsAllowAll", policy =>
174 + {
175 + policy
176 + .AllowAnyOrigin()
177 + .AllowAnyHeader()
178 + .AllowAnyMethod()
179 + .WithExposedHeaders("X-Version", "X-Version-Created-At");
180 + });
181 +});
182 +
183 +var app = builder.Build();
184 +
185 +// Per-module pipeline + migration hooks. Users no longer here.
186 +if (!app.Environment.IsEnvironment("Testing"))
187 +{
188 + app.UseTripsModule();
189 + app.UseExpensesModule();
190 +
191 + if (app.Configuration.GetValue<bool>("DataInitialization:SeedData"))
192 + {
193 + SplitApp.WebApp.Hosting.AppDataInit.SeedExampleData(app.Services);
194 + }
195 +}
196 +
197 +if (app.Environment.IsDevelopment())
198 +{
199 + app.UseDeveloperExceptionPage();
200 +}
201 +else
202 +{
203 + app.UseExceptionHandler("/Home/Error");
204 +}
205 +
206 +app.UseSwagger();
207 +app.UseSwaggerUI(options =>
208 +{
209 + var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
210 + foreach (var description in provider.ApiVersionDescriptions)
211 + {
212 + options.SwaggerEndpoint(
213 + $"/swagger/{description.GroupName}/swagger.json",
214 + description.GroupName.ToUpperInvariant());
215 + }
216 +});
217 +
218 +app.UseStaticFiles();
219 +app.UseHttpsRedirection();
220 +app.UseRouting();
221 +
222 +app.UseRequestLocalization(app.Services.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value);
223 +
224 +app.UseCors("CorsAllowAll");
225 +
226 +app.UseAuthentication();
227 +app.UseAuthorization();
228 +
229 +app.MapControllerRoute(
230 + name: "areas",
231 + pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
232 +app.MapControllerRoute(
233 + name: "default",
234 + pattern: "{controller=Home}/{action=Index}/{id?}");
235 +app.MapControllers();
236 +app.MapRazorPages();
237 +
238 +app.Run();
239 +
240 +public partial class Program;
added SplitApp.Modular/src/SplitApp.WebApp/Properties/launchSettings.json +23 −0
@@ -0,0 +1,23 @@
1 +{
2 + "$schema": "https://json.schemastore.org/launchsettings.json",
3 + "profiles": {
4 + "http": {
5 + "commandName": "Project",
6 + "dotnetRunMessages": true,
7 + "launchBrowser": true,
8 + "applicationUrl": "http://localhost:5297",
9 + "environmentVariables": {
10 + "ASPNETCORE_ENVIRONMENT": "Development"
11 + }
12 + },
13 + "https": {
14 + "commandName": "Project",
15 + "dotnetRunMessages": true,
16 + "launchBrowser": true,
17 + "applicationUrl": "https://localhost:7133;http://localhost:5297",
18 + "environmentVariables": {
19 + "ASPNETCORE_ENVIRONMENT": "Development"
20 + }
21 + }
22 + }
23 +}
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.cs +8 −0
@@ -0,0 +1,8 @@
1 +namespace App.Resources.Domain;
2 +
3 +/// <summary>
4 +/// Marker class for the Enums.{resx,et.resx} resource bundle. The .resx files are
5 +/// embedded with explicit LogicalName "App.Resources.Domain.Enums.resources" so the
6 +/// ResourceManager can find them regardless of the host project's default namespace.
7 +/// </summary>
8 +public class Enums { }
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.et.resx +70 −0
@@ -0,0 +1,70 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<root>
3 + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4 + <xsd:element name="root" msdata:IsDataSet="true">
5 + <xsd:complexType>
6 + <xsd:choice maxOccurs="unbounded">
7 + <xsd:element name="data">
8 + <xsd:complexType>
9 + <xsd:sequence>
10 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
11 + </xsd:sequence>
12 + <xsd:attribute name="name" type="xsd:string" use="required" />
13 + <xsd:attribute name="type" type="xsd:string" />
14 + <xsd:attribute name="mimetype" type="xsd:string" />
15 + <xsd:attribute ref="xml:space" />
16 + </xsd:complexType>
17 + </xsd:element>
18 + <xsd:element name="resheader">
19 + <xsd:complexType>
20 + <xsd:sequence>
21 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
22 + </xsd:sequence>
23 + <xsd:attribute name="name" type="xsd:string" use="required" />
24 + </xsd:complexType>
25 + </xsd:element>
26 + </xsd:choice>
27 + </xsd:complexType>
28 + </xsd:element>
29 + </xsd:schema>
30 + <resheader name="resmimetype">
31 + <value>text/microsoft-resx</value>
32 + </resheader>
33 + <resheader name="version">
34 + <value>1.3</value>
35 + </resheader>
36 + <resheader name="reader">
37 + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
38 + </resheader>
39 + <resheader name="writer">
40 + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
41 + </resheader>
42 + <data name="ETripStatus_Active" xml:space="preserve"><value>Aktiivne</value></data>
43 + <data name="ETripStatus_Settled" xml:space="preserve"><value>Arveldatud</value></data>
44 + <data name="ETripStatus_Archived" xml:space="preserve"><value>Arhiveeritud</value></data>
45 + <data name="ETripStatus_Finalizing" xml:space="preserve"><value>Arveldamisel</value></data>
46 + <data name="ESplitMethod_EqualAll" xml:space="preserve"><value>V&#245;rdselt (k&#245;ik)</value></data>
47 + <data name="ESplitMethod_EqualSubset" xml:space="preserve"><value>V&#245;rdselt (valik)</value></data>
48 + <data name="ESplitMethod_ExactAmounts" xml:space="preserve"><value>T&#228;psed summad</value></data>
49 + <data name="ESplitMethod_Percentages" xml:space="preserve"><value>Protsendid</value></data>
50 + <data name="EParticipantRole_Organizer" xml:space="preserve"><value>Korraldaja</value></data>
51 + <data name="EParticipantRole_Participant" xml:space="preserve"><value>Osaleja</value></data>
52 + <data name="EInvitationStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
53 + <data name="EInvitationStatus_Accepted" xml:space="preserve"><value>Vastu v&#245;etud</value></data>
54 + <data name="EInvitationStatus_Declined" xml:space="preserve"><value>Keeldutud</value></data>
55 + <data name="EInvitationStatus_Expired" xml:space="preserve"><value>Aegunud</value></data>
56 + <data name="EInvitationStatus_Revoked" xml:space="preserve"><value>T&#252;histatud</value></data>
57 + <data name="EPaymentStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
58 + <data name="EPaymentStatus_MarkedPaid" xml:space="preserve"><value>M&#228;rgitud makstuks</value></data>
59 + <data name="EPaymentStatus_Confirmed" xml:space="preserve"><value>Kinnitatud</value></data>
60 + <data name="ESettlementStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
61 + <data name="ESettlementStatus_InProgress" xml:space="preserve"><value>Pooleli</value></data>
62 + <data name="ESettlementStatus_Completed" xml:space="preserve"><value>L&#245;petatud</value></data>
63 + <data name="EWishlistCategory_Place" xml:space="preserve"><value>Koht</value></data>
64 + <data name="EWishlistCategory_Activity" xml:space="preserve"><value>Tegevus</value></data>
65 + <data name="EWishlistCategory_Restaurant" xml:space="preserve"><value>Restoran</value></data>
66 + <data name="EWishlistCategory_Other" xml:space="preserve"><value>Muu</value></data>
67 + <data name="EWishlistPriority_MustDo" xml:space="preserve"><value>Kohustuslik</value></data>
68 + <data name="EWishlistPriority_NiceToHave" xml:space="preserve"><value>Soovituslik</value></data>
69 + <data name="EWishlistPriority_Optional" xml:space="preserve"><value>Valikuline</value></data>
70 +</root>
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.resx +70 −0
@@ -0,0 +1,70 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<root>
3 + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4 + <xsd:element name="root" msdata:IsDataSet="true">
5 + <xsd:complexType>
6 + <xsd:choice maxOccurs="unbounded">
7 + <xsd:element name="data">
8 + <xsd:complexType>
9 + <xsd:sequence>
10 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
11 + </xsd:sequence>
12 + <xsd:attribute name="name" type="xsd:string" use="required" />
13 + <xsd:attribute name="type" type="xsd:string" />
14 + <xsd:attribute name="mimetype" type="xsd:string" />
15 + <xsd:attribute ref="xml:space" />
16 + </xsd:complexType>
17 + </xsd:element>
18 + <xsd:element name="resheader">
19 + <xsd:complexType>
20 + <xsd:sequence>
21 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
22 + </xsd:sequence>
23 + <xsd:attribute name="name" type="xsd:string" use="required" />
24 + </xsd:complexType>
25 + </xsd:element>
26 + </xsd:choice>
27 + </xsd:complexType>
28 + </xsd:element>
29 + </xsd:schema>
30 + <resheader name="resmimetype">
31 + <value>text/microsoft-resx</value>
32 + </resheader>
33 + <resheader name="version">
34 + <value>1.3</value>
35 + </resheader>
36 + <resheader name="reader">
37 + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
38 + </resheader>
39 + <resheader name="writer">
40 + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
41 + </resheader>
42 + <data name="ETripStatus_Active" xml:space="preserve"><value>Active</value></data>
43 + <data name="ETripStatus_Settled" xml:space="preserve"><value>Settled</value></data>
44 + <data name="ETripStatus_Archived" xml:space="preserve"><value>Archived</value></data>
45 + <data name="ETripStatus_Finalizing" xml:space="preserve"><value>Finalizing</value></data>
46 + <data name="ESplitMethod_EqualAll" xml:space="preserve"><value>Equal (all)</value></data>
47 + <data name="ESplitMethod_EqualSubset" xml:space="preserve"><value>Equal (subset)</value></data>
48 + <data name="ESplitMethod_ExactAmounts" xml:space="preserve"><value>Exact amounts</value></data>
49 + <data name="ESplitMethod_Percentages" xml:space="preserve"><value>Percentages</value></data>
50 + <data name="EParticipantRole_Organizer" xml:space="preserve"><value>Organizer</value></data>
51 + <data name="EParticipantRole_Participant" xml:space="preserve"><value>Participant</value></data>
52 + <data name="EInvitationStatus_Pending" xml:space="preserve"><value>Pending</value></data>
53 + <data name="EInvitationStatus_Accepted" xml:space="preserve"><value>Accepted</value></data>
54 + <data name="EInvitationStatus_Declined" xml:space="preserve"><value>Declined</value></data>
55 + <data name="EInvitationStatus_Expired" xml:space="preserve"><value>Expired</value></data>
56 + <data name="EInvitationStatus_Revoked" xml:space="preserve"><value>Revoked</value></data>
57 + <data name="EPaymentStatus_Pending" xml:space="preserve"><value>Pending</value></data>
58 + <data name="EPaymentStatus_MarkedPaid" xml:space="preserve"><value>Marked Paid</value></data>
59 + <data name="EPaymentStatus_Confirmed" xml:space="preserve"><value>Confirmed</value></data>
60 + <data name="ESettlementStatus_Pending" xml:space="preserve"><value>Pending</value></data>
61 + <data name="ESettlementStatus_InProgress" xml:space="preserve"><value>In Progress</value></data>
62 + <data name="ESettlementStatus_Completed" xml:space="preserve"><value>Completed</value></data>
63 + <data name="EWishlistCategory_Place" xml:space="preserve"><value>Place</value></data>
64 + <data name="EWishlistCategory_Activity" xml:space="preserve"><value>Activity</value></data>
65 + <data name="EWishlistCategory_Restaurant" xml:space="preserve"><value>Restaurant</value></data>
66 + <data name="EWishlistCategory_Other" xml:space="preserve"><value>Other</value></data>
67 + <data name="EWishlistPriority_MustDo" xml:space="preserve"><value>Must Do</value></data>
68 + <data name="EWishlistPriority_NiceToHave" xml:space="preserve"><value>Nice to Have</value></data>
69 + <data name="EWishlistPriority_Optional" xml:space="preserve"><value>Optional</value></data>
70 +</root>
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Shared.cs +7 −0
@@ -0,0 +1,7 @@
1 +namespace App.Resources.Views;
2 +
3 +/// <summary>
4 +/// Marker type retained for IStringLocalizer references in copied phase 2 controllers/views.
5 +/// In modular monolith we route all calls to a no-op localizer that returns the key unchanged.
6 +/// </summary>
7 +public class Shared { }
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.et.resx +513 −0
@@ -0,0 +1,513 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<root>
3 + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4 + <xsd:element name="root" msdata:IsDataSet="true">
5 + <xsd:complexType>
6 + <xsd:choice maxOccurs="unbounded">
7 + <xsd:element name="data">
8 + <xsd:complexType>
9 + <xsd:sequence>
10 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
11 + </xsd:sequence>
12 + <xsd:attribute name="name" type="xsd:string" use="required" />
13 + <xsd:attribute name="type" type="xsd:string" />
14 + <xsd:attribute name="mimetype" type="xsd:string" />
15 + <xsd:attribute ref="xml:space" />
16 + </xsd:complexType>
17 + </xsd:element>
18 + <xsd:element name="resheader">
19 + <xsd:complexType>
20 + <xsd:sequence>
21 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
22 + </xsd:sequence>
23 + <xsd:attribute name="name" type="xsd:string" use="required" />
24 + </xsd:complexType>
25 + </xsd:element>
26 + </xsd:choice>
27 + </xsd:complexType>
28 + </xsd:element>
29 + </xsd:schema>
30 + <resheader name="resmimetype">
31 + <value>text/microsoft-resx</value>
32 + </resheader>
33 + <resheader name="version">
34 + <value>1.3</value>
35 + </resheader>
36 + <resheader name="reader">
37 + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
38 + </resheader>
39 + <resheader name="writer">
40 + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
41 + </resheader>
42 +
43 + <!-- ========== EXISTING KEYS (preserved) ========== -->
44 + <data name="Save" xml:space="preserve"><value>Salvesta</value></data>
45 + <data name="Cancel" xml:space="preserve"><value>T&#252;hista</value></data>
46 + <data name="Delete" xml:space="preserve"><value>Kustuta</value></data>
47 + <data name="Edit" xml:space="preserve"><value>Muuda</value></data>
48 + <data name="Create" xml:space="preserve"><value>Loo</value></data>
49 + <data name="Back" xml:space="preserve"><value>Tagasi</value></data>
50 + <data name="Confirm" xml:space="preserve"><value>Kinnita</value></data>
51 + <data name="Search" xml:space="preserve"><value>Otsi</value></data>
52 + <data name="Actions" xml:space="preserve"><value>Tegevused</value></data>
53 + <data name="Loading" xml:space="preserve"><value>Laadimine...</value></data>
54 + <data name="NoData" xml:space="preserve"><value>Andmed puuduvad</value></data>
55 + <data name="LogIn" xml:space="preserve"><value>Logi sisse</value></data>
56 + <data name="LogOut" xml:space="preserve"><value>Logi v&#228;lja</value></data>
57 + <data name="Register" xml:space="preserve"><value>Registreeru</value></data>
58 + <data name="Trips" xml:space="preserve"><value>Reisid</value></data>
59 + <data name="Expenses" xml:space="preserve"><value>Kulud</value></data>
60 + <data name="Budget" xml:space="preserve"><value>Eelarve</value></data>
61 + <data name="Members" xml:space="preserve"><value>Liikmed</value></data>
62 + <data name="Wishlist" xml:space="preserve"><value>Soovinimekiri</value></data>
63 + <data name="Polls" xml:space="preserve"><value>K&#252;sitlused</value></data>
64 + <data name="Settlement" xml:space="preserve"><value>Arveldus</value></data>
65 + <data name="Categories" xml:space="preserve"><value>Kategooriad</value></data>
66 + <data name="Dashboard" xml:space="preserve"><value>T&#246;&#246;laud</value></data>
67 + <data name="AreYouSure" xml:space="preserve"><value>Kas oled kindel?</value></data>
68 + <data name="DeleteConfirm" xml:space="preserve"><value>Kas soovid kustutada?</value></data>
69 + <data name="Details" xml:space="preserve"><value>Detailid</value></data>
70 + <data name="Name" xml:space="preserve"><value>Nimi</value></data>
71 + <data name="Description" xml:space="preserve"><value>Kirjeldus</value></data>
72 + <data name="Amount" xml:space="preserve"><value>Summa</value></data>
73 + <data name="Date" xml:space="preserve"><value>Kuup&#228;ev</value></data>
74 + <data name="Status" xml:space="preserve"><value>Olek</value></data>
75 + <data name="Admin" xml:space="preserve"><value>Admin</value></data>
76 +
77 + <!-- ========== COMMON UI ========== -->
78 + <data name="Home" xml:space="preserve"><value>Avaleht</value></data>
79 + <data name="Hello" xml:space="preserve"><value>Tere</value></data>
80 + <data name="Welcome" xml:space="preserve"><value>Tere tulemast</value></data>
81 + <data name="Total" xml:space="preserve"><value>Kokku</value></data>
82 + <data name="Email" xml:space="preserve"><value>E-post</value></data>
83 + <data name="Joined" xml:space="preserve"><value>Liitunud</value></data>
84 + <data name="Role" xml:space="preserve"><value>Roll</value></data>
85 + <data name="Summary" xml:space="preserve"><value>Kokkuv&#245;te</value></data>
86 + <data name="Dates" xml:space="preserve"><value>Kuup&#228;evad</value></data>
87 + <data name="Currency" xml:space="preserve"><value>Valuuta</value></data>
88 + <data name="Currencies" xml:space="preserve"><value>Valuutad</value></data>
89 + <data name="Destination" xml:space="preserve"><value>Sihtkoht</value></data>
90 + <data name="Participants" xml:space="preserve"><value>Osalejad</value></data>
91 + <data name="Code" xml:space="preserve"><value>Kood</value></data>
92 + <data name="Symbol" xml:space="preserve"><value>S&#252;mbol</value></data>
93 + <data name="Select" xml:space="preserve"><value>Vali</value></data>
94 + <data name="Copy" xml:space="preserve"><value>Kopeeri</value></data>
95 + <data name="Split" xml:space="preserve"><value>Jaotus</value></data>
96 + <data name="Member" xml:space="preserve"><value>Liige</value></data>
97 + <data name="Title" xml:space="preserve"><value>Pealkiri</value></data>
98 + <data name="Question" xml:space="preserve"><value>K&#252;simus</value></data>
99 + <data name="Option" xml:space="preserve"><value>Valik</value></data>
100 + <data name="Options" xml:space="preserve"><value>Valikud</value></data>
101 + <data name="Priority" xml:space="preserve"><value>Prioriteet</value></data>
102 + <data name="Location" xml:space="preserve"><value>Asukoht</value></data>
103 + <data name="URL" xml:space="preserve"><value>URL</value></data>
104 + <data name="User" xml:space="preserve"><value>Kasutaja</value></data>
105 + <data name="Trip" xml:space="preserve"><value>Reis</value></data>
106 + <data name="Close" xml:space="preserve"><value>Sulge</value></data>
107 + <data name="Yes" xml:space="preserve"><value>Jah</value></data>
108 + <data name="No" xml:space="preserve"><value>Ei</value></data>
109 + <data name="Submit" xml:space="preserve"><value>Esita</value></data>
110 + <data name="Add" xml:space="preserve"><value>Lisa</value></data>
111 + <data name="Remove" xml:space="preserve"><value>Eemalda</value></data>
112 + <data name="Unknown" xml:space="preserve"><value>Tundmatu</value></data>
113 + <data name="or" xml:space="preserve"><value>v&#245;i</value></data>
114 + <data name="of" xml:space="preserve"><value>st</value></data>
115 + <data name="Category" xml:space="preserve"><value>Kategooria</value></data>
116 + <data name="Expires" xml:space="preserve"><value>Aegub</value></data>
117 + <data name="Nickname" xml:space="preserve"><value>H&#252;&#252;dnimi</value></data>
118 +
119 + <!-- ========== TRIP ========== -->
120 + <data name="My Trips" xml:space="preserve"><value>Minu reisid</value></data>
121 + <data name="Create Trip" xml:space="preserve"><value>Loo reis</value></data>
122 + <data name="Edit Trip" xml:space="preserve"><value>Muuda reisi</value></data>
123 + <data name="Delete Trip" xml:space="preserve"><value>Kustuta reis</value></data>
124 + <data name="Trip Information" xml:space="preserve"><value>Reisi info</value></data>
125 + <data name="Back to Trips" xml:space="preserve"><value>Tagasi reiside juurde</value></data>
126 + <data name="Back to Trip" xml:space="preserve"><value>Tagasi reisi juurde</value></data>
127 + <data name="No trips yet" xml:space="preserve"><value>Reisid puuduvad</value></data>
128 + <data name="Create your first trip to get started!" xml:space="preserve"><value>Loo oma esimene reis alustamiseks!</value></data>
129 + <data name="View Details" xml:space="preserve"><value>Vaata detaile</value></data>
130 + <data name="View All" xml:space="preserve"><value>Vaata k&#245;iki</value></data>
131 + <data name="Go to My Trips" xml:space="preserve"><value>Mine minu reiside juurde</value></data>
132 + <data name="Start Date" xml:space="preserve"><value>Alguskuup&#228;ev</value></data>
133 + <data name="End Date" xml:space="preserve"><value>L&#245;ppkuup&#228;ev</value></data>
134 + <data name="Default Currency" xml:space="preserve"><value>Vaikevaluuta</value></data>
135 + <data name="Select currency..." xml:space="preserve"><value>Vali valuuta...</value></data>
136 + <data name="Are you sure you want to delete this trip? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle reisi kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
137 +
138 + <!-- ========== EXPENSE ========== -->
139 + <data name="Add Expense" xml:space="preserve"><value>Lisa kulu</value></data>
140 + <data name="Edit Expense" xml:space="preserve"><value>Muuda kulu</value></data>
141 + <data name="Delete Expense" xml:space="preserve"><value>Kustuta kulu</value></data>
142 + <data name="Paid By" xml:space="preserve"><value>Maksis</value></data>
143 + <data name="Who Paid" xml:space="preserve"><value>Kes maksis</value></data>
144 + <data name="Split Method" xml:space="preserve"><value>Jagamismeetod</value></data>
145 + <data name="Select Participants" xml:space="preserve"><value>Vali osalejad</value></data>
146 + <data name="No category" xml:space="preserve"><value>Kategooriata</value></data>
147 + <data name="Trip default" xml:space="preserve"><value>Reisi vaikimisi</value></data>
148 + <data name="No expenses yet" xml:space="preserve"><value>Kulusid pole veel</value></data>
149 + <data name="Add your first expense to start tracking." xml:space="preserve"><value>Lisa oma esimene kulu j&#228;lgimise alustamiseks.</value></data>
150 + <data name="Recent Expenses" xml:space="preserve"><value>Hiljutised kulud</value></data>
151 + <data name="Expense" xml:space="preserve"><value>Kulu</value></data>
152 + <data name="Total Expenses" xml:space="preserve"><value>Kulud kokku</value></data>
153 + <data name="Are you sure you want to delete this expense? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle kulu kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
154 + <data name="Estimated Cost" xml:space="preserve"><value>Hinnanguline maksumus</value></data>
155 +
156 + <!-- ========== SPLIT METHODS ========== -->
157 + <data name="Equal (all)" xml:space="preserve"><value>V&#245;rdselt (k&#245;ik)</value></data>
158 + <data name="Equal (subset)" xml:space="preserve"><value>V&#245;rdselt (valik)</value></data>
159 + <data name="Exact amounts" xml:space="preserve"><value>T&#228;psed summad</value></data>
160 + <data name="Percentages" xml:space="preserve"><value>Protsendid</value></data>
161 +
162 + <!-- ========== MEMBERS ========== -->
163 + <data name="Current Members" xml:space="preserve"><value>Praegused liikmed</value></data>
164 + <data name="Pending Invitations" xml:space="preserve"><value>Ootel kutsed</value></data>
165 + <data name="Invite Member" xml:space="preserve"><value>Kutsu liige</value></data>
166 + <data name="Generate Invitation Link" xml:space="preserve"><value>Genereeri kutse link</value></data>
167 + <data name="Back to Members" xml:space="preserve"><value>Tagasi liikmete juurde</value></data>
168 + <data name="Invited by" xml:space="preserve"><value>Kutsuja</value></data>
169 + <data name="Are you sure you want to remove this member?" xml:space="preserve"><value>Kas soovid selle liikme eemaldada?</value></data>
170 +
171 + <!-- ========== SETTLEMENT ========== -->
172 + <data name="Balances" xml:space="preserve"><value>Saldod</value></data>
173 + <data name="Settlement Plan" xml:space="preserve"><value>Arveldusplaan</value></data>
174 + <data name="Total Paid" xml:space="preserve"><value>Kokku makstud</value></data>
175 + <data name="Total Owed" xml:space="preserve"><value>Kokku v&#245;lgnetav</value></data>
176 + <data name="Net Balance" xml:space="preserve"><value>Neto saldo</value></data>
177 + <data name="From" xml:space="preserve"><value>Kellelt</value></data>
178 + <data name="To" xml:space="preserve"><value>Kellele</value></data>
179 + <data name="Calculate Settlement" xml:space="preserve"><value>Arvuta arveldus</value></data>
180 + <data name="Mark Paid" xml:space="preserve"><value>M&#228;rgi makstuks</value></data>
181 + <data name="Confirm Receipt" xml:space="preserve"><value>Kinnita kviitung</value></data>
182 + <data name="No settlement plan generated yet. Click 'Calculate Settlement' to generate one." xml:space="preserve"><value>Arveldusplaani pole veel loodud. Kliki 'Arvuta arveldus' selle loomiseks.</value></data>
183 +
184 + <!-- ========== BUDGET ========== -->
185 + <data name="Total Planned" xml:space="preserve"><value>Kokku planeeritud</value></data>
186 + <data name="Total Spent" xml:space="preserve"><value>Kokku kulutatud</value></data>
187 + <data name="Remaining" xml:space="preserve"><value>J&#228;&#228;k</value></data>
188 + <data name="No budget categories yet" xml:space="preserve"><value>Eelarve kategooriad puuduvad</value></data>
189 + <data name="Add categories to track your trip budget." xml:space="preserve"><value>Lisa kategooriaid oma reisieelarve j&#228;lgimiseks.</value></data>
190 + <data name="Add Category" xml:space="preserve"><value>Lisa kategooria</value></data>
191 + <data name="Add Budget Category" xml:space="preserve"><value>Lisa eelarve kategooria</value></data>
192 + <data name="Edit Budget Category" xml:space="preserve"><value>Muuda eelarve kategooriat</value></data>
193 + <data name="Delete Budget Category" xml:space="preserve"><value>Kustuta eelarve kategooria</value></data>
194 + <data name="Category Name" xml:space="preserve"><value>Kategooria nimi</value></data>
195 + <data name="Icon Name" xml:space="preserve"><value>Ikooni nimi</value></data>
196 + <data name="Bootstrap Icons name (optional)" xml:space="preserve"><value>Bootstrap Icons nimi (valikuline)</value></data>
197 + <data name="Planned Amount" xml:space="preserve"><value>Planeeritud summa</value></data>
198 + <data name="Display Order" xml:space="preserve"><value>Kuvamisj&#228;rjekord</value></data>
199 + <data name="Are you sure you want to delete this budget category? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle eelarve kategooria kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
200 +
201 + <!-- ========== WISHLIST ========== -->
202 + <data name="Link" xml:space="preserve"><value>Link</value></data>
203 + <data name="Added by" xml:space="preserve"><value>Lisas</value></data>
204 + <data name="Vote" xml:space="preserve"><value>H&#228;&#228;leta</value></data>
205 + <data name="Voted" xml:space="preserve"><value>H&#228;&#228;letatud</value></data>
206 + <data name="Mark Done" xml:space="preserve"><value>M&#228;rgi tehtuks</value></data>
207 + <data name="Completed" xml:space="preserve"><value>Tehtud</value></data>
208 + <data name="Add Item" xml:space="preserve"><value>Lisa element</value></data>
209 + <data name="Add Wishlist Item" xml:space="preserve"><value>Lisa soovinimekirja element</value></data>
210 + <data name="No wishlist items yet" xml:space="preserve"><value>Soovinimekirja elemendid puuduvad</value></data>
211 + <data name="Add places, activities, or restaurants you want to visit." xml:space="preserve"><value>Lisa kohti, tegevusi v&#245;i restorane, mida soovid k&#252;lastada.</value></data>
212 +
213 + <!-- ========== POLLS ========== -->
214 + <data name="Create Poll" xml:space="preserve"><value>Loo k&#252;sitlus</value></data>
215 + <data name="Open" xml:space="preserve"><value>Avatud</value></data>
216 + <data name="Closed" xml:space="preserve"><value>Suletud</value></data>
217 + <data name="Created by" xml:space="preserve"><value>Looja</value></data>
218 + <data name="View Results" xml:space="preserve"><value>Vaata tulemusi</value></data>
219 + <data name="Close Poll" xml:space="preserve"><value>Sulge k&#252;sitlus</value></data>
220 + <data name="Back to Polls" xml:space="preserve"><value>Tagasi k&#252;sitluste juurde</value></data>
221 + <data name="Poll Results" xml:space="preserve"><value>K&#252;sitluse tulemused</value></data>
222 + <data name="No polls yet" xml:space="preserve"><value>K&#252;sitlused puuduvad</value></data>
223 + <data name="Create a poll to help your group make decisions." xml:space="preserve"><value>Loo k&#252;sitlus, et aidata grupil otsuseid teha.</value></data>
224 + <data name="votes" xml:space="preserve"><value>h&#228;&#228;lt</value></data>
225 + <data name="Multiple votes allowed" xml:space="preserve"><value>Mitu h&#228;&#228;lt lubatud</value></data>
226 + <data name="Allow multiple votes" xml:space="preserve"><value>Luba mitu h&#228;&#228;lt</value></data>
227 + <data name="Anonymous voting" xml:space="preserve"><value>Anonuumne h&#228;&#228;letamine</value></data>
228 + <data name="At least 2 options are required. Up to 5 options supported." xml:space="preserve"><value>Vajalik on v&#228;hemalt 2 valikut. Toetatud kuni 5 valikut.</value></data>
229 + <data name="total votes" xml:space="preserve"><value>h&#228;&#228;lt kokku</value></data>
230 + <data name="Your vote" xml:space="preserve"><value>Sinu h&#228;&#228;l</value></data>
231 + <data name="Winner" xml:space="preserve"><value>V&#245;itja</value></data>
232 + <data name="Unvote" xml:space="preserve"><value>T&#252;hista h&#228;&#228;l</value></data>
233 +
234 + <!-- ========== ADMIN ========== -->
235 + <data name="AdminDashboard" xml:space="preserve"><value>Administreerimislaud</value></data>
236 + <data name="ManageEntities" xml:space="preserve"><value>Halda andmeid</value></data>
237 + <data name="ManageTrips" xml:space="preserve"><value>Halda reise</value></data>
238 + <data name="ManageExpenses" xml:space="preserve"><value>Halda kulusid</value></data>
239 + <data name="ManageCategories" xml:space="preserve"><value>Halda kategooriaid</value></data>
240 + <data name="ManageCurrencies" xml:space="preserve"><value>Halda valuutasid</value></data>
241 + <data name="ManageSettlements" xml:space="preserve"><value>Halda arveldusi</value></data>
242 + <data name="ManageParticipants" xml:space="preserve"><value>Halda osalejaid</value></data>
243 + <data name="ManageWishlist" xml:space="preserve"><value>Halda soovinimekirja</value></data>
244 + <data name="ManagePolls" xml:space="preserve"><value>Halda k&#252;sitlusi</value></data>
245 + <data name="Users" xml:space="preserve"><value>Kasutajad</value></data>
246 + <data name="Invitations" xml:space="preserve"><value>Kutsed</value></data>
247 +
248 + <!-- ========== ADMIN ENTITY FIELD KEYS ========== -->
249 + <data name="StartDate" xml:space="preserve"><value>Alguskuup&#228;ev</value></data>
250 + <data name="EndDate" xml:space="preserve"><value>L&#245;ppkuup&#228;ev</value></data>
251 + <data name="CreatedBy" xml:space="preserve"><value>Looja</value></data>
252 + <data name="DefaultCurrency" xml:space="preserve"><value>Vaikevaluuta</value></data>
253 + <data name="ExpenseDate" xml:space="preserve"><value>Kulu kuup&#228;ev</value></data>
254 + <data name="PaidByUser" xml:space="preserve"><value>Maksis</value></data>
255 + <data name="SplitMethod" xml:space="preserve"><value>Jagamismeetod</value></data>
256 + <data name="BudgetCategory" xml:space="preserve"><value>Eelarve kategooria</value></data>
257 + <data name="BudgetCategories" xml:space="preserve"><value>Eelarve kategooriad</value></data>
258 + <data name="PlannedAmount" xml:space="preserve"><value>Planeeritud summa</value></data>
259 + <data name="DisplayOrder" xml:space="preserve"><value>Kuvamisj&#228;rjekord</value></data>
260 + <data name="IconName" xml:space="preserve"><value>Ikooni nimi</value></data>
261 + <data name="SettlementPlans" xml:space="preserve"><value>Arveldusplaanid</value></data>
262 + <data name="SettlementPlan" xml:space="preserve"><value>Arveldusplaan</value></data>
263 + <data name="TotalAmount" xml:space="preserve"><value>Kogusumma</value></data>
264 + <data name="CreatedByUser" xml:space="preserve"><value>Looja</value></data>
265 + <data name="CompletedAt" xml:space="preserve"><value>L&#245;petatud</value></data>
266 + <data name="TripParticipants" xml:space="preserve"><value>Reisi osalejad</value></data>
267 + <data name="TripParticipant" xml:space="preserve"><value>Reisi osaleja</value></data>
268 + <data name="JoinedAt" xml:space="preserve"><value>Liitumisaeg</value></data>
269 + <data name="LeftAt" xml:space="preserve"><value>Lahkumisaeg</value></data>
270 + <data name="IsActive" xml:space="preserve"><value>Aktiivne</value></data>
271 +
272 + <!-- ========== ENUM VALUES ========== -->
273 + <data name="Active" xml:space="preserve"><value>Aktiivne</value></data>
274 + <data name="Settled" xml:space="preserve"><value>Arveldatud</value></data>
275 + <data name="Archived" xml:space="preserve"><value>Arhiveeritud</value></data>
276 + <data name="Pending" xml:space="preserve"><value>Ootel</value></data>
277 + <data name="Accepted" xml:space="preserve"><value>Vastu v&#245;etud</value></data>
278 + <data name="Declined" xml:space="preserve"><value>Keeldutud</value></data>
279 + <data name="Expired" xml:space="preserve"><value>Aegunud</value></data>
280 + <data name="Revoked" xml:space="preserve"><value>T&#252;histatud</value></data>
281 + <data name="Organizer" xml:space="preserve"><value>Korraldaja</value></data>
282 + <data name="Participant" xml:space="preserve"><value>Osaleja</value></data>
283 + <data name="MustDo" xml:space="preserve"><value>Kohustuslik</value></data>
284 + <data name="NiceToHave" xml:space="preserve"><value>Soovituslik</value></data>
285 + <data name="Optional" xml:space="preserve"><value>Valikuline</value></data>
286 + <data name="Place" xml:space="preserve"><value>Koht</value></data>
287 + <data name="Activity" xml:space="preserve"><value>Tegevus</value></data>
288 + <data name="Restaurant" xml:space="preserve"><value>Restoran</value></data>
289 + <data name="Other" xml:space="preserve"><value>Muu</value></data>
290 + <data name="InProgress" xml:space="preserve"><value>Pooleli</value></data>
291 + <data name="MarkedPaid" xml:space="preserve"><value>M&#228;rgitud makstuks</value></data>
292 + <data name="Confirmed" xml:space="preserve"><value>Kinnitatud</value></data>
293 +
294 + <!-- ========== HOME PAGE ========== -->
295 + <data name="AppTagline" xml:space="preserve"><value>R&#252;hma reisikulude haldamine lihtsaks tehtud.</value></data>
296 + <data name="AppDescription" xml:space="preserve"><value>J&#228;lgi kulusid, jaga kulusid, halda eelarveid ja arvlda v&#245;lgu oma reisigrupiga.</value></data>
297 +
298 + <!-- ========== INVITATION ========== -->
299 + <data name="Accept Invitation" xml:space="preserve"><value>V&#245;ta kutse vastu</value></data>
300 + <data name="Invitation Generated" xml:space="preserve"><value>Kutse genereeritud</value></data>
301 + <data name="Invalid Invitation" xml:space="preserve"><value>Kehtetu kutse</value></data>
302 + <data name="You've Been Invited!" xml:space="preserve"><value>Sind on kutsutud!</value></data>
303 + <data name="By accepting, you will join this trip as a participant." xml:space="preserve"><value>Vastu v&#245;ttes liitud selle reisiga osalejana.</value></data>
304 + <data name="This invitation is no longer valid." xml:space="preserve"><value>See kutse ei ole enam kehtiv.</value></data>
305 + <data name="Invitation link has been generated successfully!" xml:space="preserve"><value>Kutse link on edukalt genereeritud!</value></data>
306 + <data name="Share this link with the person you want to invite:" xml:space="preserve"><value>Jaga seda linki isikuga, keda soovid kutsuda:</value></data>
307 + <data name="This link will expire in 7 days." xml:space="preserve"><value>See link aegub 7 p&#228;eva p&#228;rast.</value></data>
308 + <data name="The invitation link will expire after 7 days." xml:space="preserve"><value>Kutse link aegub 7 p&#228;eva p&#228;rast.</value></data>
309 + <data name="Generate an invitation link that you can share with someone to join this trip." xml:space="preserve"><value>Genereeri kutse link, mida saad jagada kellegagi, kes soovib reisiga liituda.</value></data>
310 +
311 + <!-- ========== ADDITIONAL KEYS ========== -->
312 + <data name="AddedBy" xml:space="preserve"><value>Lisas</value></data>
313 + <data name="AllowMultipleVotes" xml:space="preserve"><value>Luba mitu h&#228;&#228;lt</value></data>
314 + <data name="Back to List" xml:space="preserve"><value>Tagasi nimekirja</value></data>
315 + <data name="Budget Categories" xml:space="preserve"><value>Eelarve kategooriad</value></data>
316 + <data name="ClosedAt" xml:space="preserve"><value>Suletud</value></data>
317 + <data name="CreatePoll" xml:space="preserve"><value>Loo k&#252;sitlus</value></data>
318 + <data name="DeletePoll" xml:space="preserve"><value>Kustuta k&#252;sitlus</value></data>
319 + <data name="EditPoll" xml:space="preserve"><value>Muuda k&#252;sitlust</value></data>
320 + <data name="IsAnonymous" xml:space="preserve"><value>Anonuumne</value></data>
321 + <data name="Manage Categories" xml:space="preserve"><value>Halda kategooriaid</value></data>
322 + <data name="Manage Currencies" xml:space="preserve"><value>Halda valuutasid</value></data>
323 + <data name="Manage Entities" xml:space="preserve"><value>Halda andmeid</value></data>
324 + <data name="Manage Expenses" xml:space="preserve"><value>Halda kulusid</value></data>
325 + <data name="Manage Participants" xml:space="preserve"><value>Halda osalejaid</value></data>
326 + <data name="Manage Polls" xml:space="preserve"><value>Halda k&#252;sitlusi</value></data>
327 + <data name="Manage Settlements" xml:space="preserve"><value>Halda arveldusi</value></data>
328 + <data name="Manage Trips" xml:space="preserve"><value>Halda reise</value></data>
329 + <data name="Manage Wishlist" xml:space="preserve"><value>Halda soovinimekirja</value></data>
330 + <data name="PollDetails" xml:space="preserve"><value>K&#252;sitluse detailid</value></data>
331 + <data name="Settlements" xml:space="preserve"><value>Arveldused</value></data>
332 + <data name="Text" xml:space="preserve"><value>Tekst</value></data>
333 + <data name="Toggle navigation" xml:space="preserve"><value>L&#252;lita navigatsioon</value></data>
334 + <data name="Wishlist Items" xml:space="preserve"><value>Soovinimekirja elemendid</value></data>
335 + <data name="WishlistItem" xml:space="preserve"><value>Soovinimekirja element</value></data>
336 + <data name="WishlistItemDetails" xml:space="preserve"><value>Soovinimekirja elemendi detailid</value></data>
337 +
338 + <!-- ========== NEW KEYS ========== -->
339 + <data name="Add expenses as you go. Choose how to split — we handle the math." xml:space="preserve"><value>Lisa kulusid jooksvalt. Vali jagamisviis &#x2014; meie teeme matemaatika.</value></data>
340 + <data name="Admin Panel" xml:space="preserve"><value>Administreerimispaneel</value></data>
341 + <data name="All settled up!" xml:space="preserve"><value>K&#245;ik arveldatud!</value></data>
342 + <data name="An error occurred while processing your request." xml:space="preserve"><value>Teie p&#228;ringu t&#246;&#246;tlemisel tekkis viga.</value></data>
343 + <data name="Anonymous" xml:space="preserve"><value>Anon&#252;&#252;mne</value></data>
344 + <data name="At least 2 required" xml:space="preserve"><value>V&#228;hemalt 2 n&#245;utud</value></data>
345 + <data name="Back to App" xml:space="preserve"><value>Tagasi rakendusse</value></data>
346 + <data name="Back to Home" xml:space="preserve"><value>Tagasi avalehele</value></data>
347 + <data name="Budget Progress" xml:space="preserve"><value>Eelarve edenemine</value></data>
348 + <data name="Budget Used" xml:space="preserve"><value>Eelarve kasutatud</value></data>
349 + <data name="Closed at" xml:space="preserve"><value>Suletud</value></data>
350 + <data name="Create Free Account" xml:space="preserve"><value>Loo tasuta konto</value></data>
351 + <data name="Create a Trip" xml:space="preserve"><value>Loo reis</value></data>
352 + <data name="Create polls, build wishlists, and vote together to make group planning effortless." xml:space="preserve"><value>Loo k&#252;sitlusi, koosta soovinimekirju ja h&#228;&#228;leta koos, et grupiplaneerimine oleks vaevatu.</value></data>
353 + <data name="Done" xml:space="preserve"><value>Tehtud</value></data>
354 + <data name="Entities" xml:space="preserve"><value>Andmed</value></data>
355 + <data name="Entity Management" xml:space="preserve"><value>Andmete haldus</value></data>
356 + <data name="Error" xml:space="preserve"><value>Viga</value></data>
357 + <data name="Everything you need for group travel" xml:space="preserve"><value>K&#245;ik, mida vajad grupireisiks</value></data>
358 + <data name="From splitting dinner bills to planning activities, SplitApp handles it all." xml:space="preserve"><value>&#213;htus&#246;&#246;giarve jagamisest tegevuste planeerimiseni &#x2014; SplitApp teeb k&#245;ik &#228;ra.</value></data>
359 + <data name="Get Started" xml:space="preserve"><value>Alusta</value></data>
360 + <data name="Group Decisions" xml:space="preserve"><value>Grupiotsused</value></data>
361 + <data name="Help your group make decisions together" xml:space="preserve"><value>Aita grupil koos otsuseid teha</value></data>
362 + <data name="How it works" xml:space="preserve"><value>Kuidas see t&#246;&#246;tab</value></data>
363 + <data name="Icon" xml:space="preserve"><value>Ikoon</value></data>
364 + <data name="Invite" xml:space="preserve"><value>Kutsu</value></data>
365 + <data name="Join thousands of travelers who split smarter." xml:space="preserve"><value>Liitu tuhandete reisijatega, kes jagavad targemalt.</value></data>
366 + <data name="Learn More" xml:space="preserve"><value>Loe lisaks</value></data>
367 + <data name="Link Generated!" xml:space="preserve"><value>Link genereeritud!</value></data>
368 + <data name="Log in" xml:space="preserve"><value>Logi sisse</value></data>
369 + <data name="Manage Wishlist Items" xml:space="preserve"><value>Halda soovinimekirja elemente</value></data>
370 + <data name="Multi-Vote" xml:space="preserve"><value>Mitu h&#228;&#228;lt</value></data>
371 + <data name="Multiple votes" xml:space="preserve"><value>Mitu h&#228;&#228;lt</value></data>
372 + <data name="New Category" xml:space="preserve"><value>Uus kategooria</value></data>
373 + <data name="New Currency" xml:space="preserve"><value>Uus valuuta</value></data>
374 + <data name="New Expense" xml:space="preserve"><value>Uus kulu</value></data>
375 + <data name="New Trip" xml:space="preserve"><value>Uus reis</value></data>
376 + <data name="No description" xml:space="preserve"><value>Kirjeldus puudub</value></data>
377 + <data name="No payments needed — everyone is even." xml:space="preserve"><value>Makseid pole vaja &#x2014; k&#245;ik on tasas.</value></data>
378 + <data name="No users yet" xml:space="preserve"><value>Kasutajaid pole veel</value></data>
379 + <data name="Not friendships." xml:space="preserve"><value>Mitte s&#245;prussuhteid.</value></data>
380 + <data name="Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification." xml:space="preserve"><value>Optimeeritud arveldus arvutab vajalike maksete miinimumi. Kinnita kahepoolse kinnitusega.</value></data>
381 + <data name="Over budget!" xml:space="preserve"><value>Eelarve &#252;letatud!</value></data>
382 + <data name="Overall Progress" xml:space="preserve"><value>&#220;ldine edenemine</value></data>
383 + <data name="Overview" xml:space="preserve"><value>&#220;levaade</value></data>
384 + <data name="Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place." xml:space="preserve"><value>Planeeri reise, j&#228;lgi kulusid, jaga kulusid ja arvlda v&#245;lgu oma reisigrupiga &#x2014; k&#245;ik &#252;hes kohas.</value></data>
385 + <data name="Planned" xml:space="preserve"><value>Planeeritud</value></data>
386 + <data name="Poll" xml:space="preserve"><value>K&#252;sitlus</value></data>
387 + <data name="Privacy Policy" xml:space="preserve"><value>Privaatsuspoliitika</value></data>
388 + <data name="Profile" xml:space="preserve"><value>Profiil</value></data>
389 + <data name="Quick Links" xml:space="preserve"><value>Kiirlingid</value></data>
390 + <data name="Ready to plan your next trip?" xml:space="preserve"><value>Valmis planeerima oma j&#228;rgmist reisi?</value></data>
391 + <data name="Recalculate" xml:space="preserve"><value>Arvuta uuesti</value></data>
392 + <data name="Recalculate after new expenses" xml:space="preserve"><value>Arvuta uuesti p&#228;rast uusi kulusid</value></data>
393 + <data name="Recent Trips" xml:space="preserve"><value>Hiljutised reisid</value></data>
394 + <data name="Recent Users" xml:space="preserve"><value>Hiljutised kasutajad</value></data>
395 + <data name="Request ID" xml:space="preserve"><value>P&#228;ringu ID</value></data>
396 + <data name="Search..." xml:space="preserve"><value>Otsi...</value></data>
397 + <data name="See who owes whom and settle with minimal payments." xml:space="preserve"><value>Vaata, kes kellele v&#245;lgneb, ja arvlda minimaalsete maksetega.</value></data>
398 + <data name="Set up your next adventure" xml:space="preserve"><value>Seadista oma j&#228;rgmine seiklus</value></data>
399 + <data name="Set up your trip and invite friends with a simple shareable link." xml:space="preserve"><value>Seadista oma reis ja kutsu s&#245;brad lihtsa jagatava lingiga.</value></data>
400 + <data name="Settle Up" xml:space="preserve"><value>Arvlda</value></data>
401 + <data name="Share what you want to experience" xml:space="preserve"><value>Jaga, mida soovid kogeda</value></data>
402 + <data name="Sign up" xml:space="preserve"><value>Registreeru</value></data>
403 + <data name="Something went wrong" xml:space="preserve"><value>Midagi l&#228;ks valesti</value></data>
404 + <data name="Split Any Way" xml:space="preserve"><value>Jaga kulusid</value></data>
405 + <data name="Split equally, by exact amounts, or percentages. Save presets for recurring groups." xml:space="preserve"><value>Jaga v&#245;rdselt, t&#228;psete summade v&#245;i protsentidega. Salvesta eelseaded korduvate gruppide jaoks.</value></data>
406 + <data name="Split expenses." xml:space="preserve"><value>Jaga kulusid.</value></data>
407 + <data name="Start tracking your group expenses." xml:space="preserve"><value>Alusta oma grupi kulude j&#228;lgimist.</value></data>
408 + <data name="System Administration" xml:space="preserve"><value>S&#252;steemihaldus</value></data>
409 + <data name="Total Trips" xml:space="preserve"><value>Reise kokku</value></data>
410 + <data name="Track &amp; Split" xml:space="preserve"><value>J&#228;lgi ja jaga</value></data>
411 + <data name="Track a group expense" xml:space="preserve"><value>J&#228;lgi grupi kulu</value></data>
412 + <data name="Use this page to detail your site's privacy policy." xml:space="preserve"><value>Kasuta seda lehte oma saidi privaatsuspoliitika kirjeldamiseks.</value></data>
413 + <data name="View Link" xml:space="preserve"><value>Vaata linki</value></data>
414 + <data name="What should the group decide?" xml:space="preserve"><value>Mida peaks grupp otsustama?</value></data>
415 + <data name="What's the plan?" xml:space="preserve"><value>Mis on plaan?</value></data>
416 + <data name="Why do you want to go here?" xml:space="preserve"><value>Miks soovid sinna minna?</value></data>
417 + <data name="Wishlist Item" xml:space="preserve"><value>Soovinimekirja element</value></data>
418 + <data name="Your Balance" xml:space="preserve"><value>Sinu saldo</value></data>
419 + <data name="e.g. Group dinner, taxi, museum tickets..." xml:space="preserve"><value>nt Grupi&#245;htus&#246;&#246;k, takso, muuseumi piletid...</value></data>
420 + <data name="e.g. Old Town, City Center" xml:space="preserve"><value>nt Vanalinn, Kesklinn</value></data>
421 + <data name="e.g. Sagrada Familia, Local tapas bar" xml:space="preserve"><value>nt Sagrada Familia, Kohalik tapas baar</value></data>
422 + <data name="in progress" xml:space="preserve"><value>pooleli</value></data>
423 + <data name="member(s)" xml:space="preserve"><value>liige/liikmed</value></data>
424 + <data name="payment(s) marked paid, awaiting confirmation" xml:space="preserve"><value>makse/maksed m&#228;rgitud makstuks, ootab kinnitust</value></data>
425 + <data name="pending invitation(s)" xml:space="preserve"><value>ootel kutse/kutsed</value></data>
426 + <data name="settlement payment(s) awaiting action" xml:space="preserve"><value>arvelduse makse/maksed ootavad tegevust</value></data>
427 + <data name="trip(s)" xml:space="preserve"><value>reis(i)</value></data>
428 +
429 + <data name="All" xml:space="preserve"><value>Kõik</value></data>
430 +
431 + <!-- ========== SPLIT PRESETS ========== -->
432 + <data name="Apply Preset" xml:space="preserve"><value>Rakenda mall</value></data>
433 + <data name="Select a preset..." xml:space="preserve"><value>Vali mall...</value></data>
434 + <data name="Apply" xml:space="preserve"><value>Rakenda</value></data>
435 + <data name="Save current split as preset" xml:space="preserve"><value>Salvesta praegune jaotus mallina</value></data>
436 + <data name="Preset name, e.g. Hotel group" xml:space="preserve"><value>Malli nimi, nt Hotelli grupp</value></data>
437 +
438 + <!-- ========== WISHLIST EDIT/DELETE ========== -->
439 + <data name="Edit Wishlist Item" xml:space="preserve"><value>Muuda soovinimekirja elementi</value></data>
440 + <data name="Delete Wishlist Item" xml:space="preserve"><value>Kustuta soovinimekirja element</value></data>
441 +
442 + <!-- ========== ADMIN USER MANAGEMENT ========== -->
443 + <data name="User Management" xml:space="preserve"><value>Kasutajate haldus</value></data>
444 + <data name="Edit Roles" xml:space="preserve"><value>Muuda rolle</value></data>
445 + <data name="System" xml:space="preserve"><value>S&#252;steem</value></data>
446 + <data name="More" xml:space="preserve"><value>Veel</value></data>
447 + <data name="Payments" xml:space="preserve"><value>Maksed</value></data>
448 + <data name="Split Presets" xml:space="preserve"><value>Jaotuse mallid</value></data>
449 + <data name="Revoke" xml:space="preserve"><value>T&#252;hista</value></data>
450 +
451 + <!-- ADMIN REBUILD ADDITIONS -->
452 + <data name="Core" xml:space="preserve"><value>Põhi</value></data>
453 + <data name="Back to site" xml:space="preserve"><value>Tagasi saidile</value></data>
454 + <data name="System overview and real-time statistics" xml:space="preserve"><value>Süsteemi ülevaade ja reaalajas statistika</value></data>
455 + <data name="payments awaiting action" xml:space="preserve"><value>makset ootavad tegevust</value></data>
456 + <data name="payments awaiting confirmation" xml:space="preserve"><value>makset ootavad kinnitust</value></data>
457 + <data name="Total Users" xml:space="preserve"><value>Kasutajaid kokku</value></data>
458 + <data name="Total amount" xml:space="preserve"><value>Kogusumma</value></data>
459 + <data name="Settlement plans" xml:space="preserve"><value>Arvelduste plaanid</value></data>
460 + <data name="Pending payments" xml:space="preserve"><value>Ootel maksed</value></data>
461 + <data name="Trip status breakdown" xml:space="preserve"><value>Reiside staatuse jaotus</value></data>
462 + <data name="Settlement status" xml:space="preserve"><value>Arvelduse staatus</value></data>
463 + <data name="Top active trips" xml:space="preserve"><value>Populaarseimad aktiivsed reisid</value></data>
464 + <data name="Biggest expenses" xml:space="preserve"><value>Suurimad kulud</value></data>
465 + <data name="No active trips yet" xml:space="preserve"><value>Aktiivseid reise veel pole</value></data>
466 + <data name="by" xml:space="preserve"><value>tegija</value></data>
467 + <data name="User activity" xml:space="preserve"><value>Kasutajate aktiivsus</value></data>
468 + <data name="Active 7d" xml:space="preserve"><value>Aktiivne 7p</value></data>
469 + <data name="Active 30d" xml:space="preserve"><value>Aktiivne 30p</value></data>
470 + <data name="Most active users" xml:space="preserve"><value>Kõige aktiivsemad kasutajad</value></data>
471 + <data name="No activity yet" xml:space="preserve"><value>Aktiivsust veel pole</value></data>
472 + <data name="Recent activity" xml:space="preserve"><value>Hiljutine aktiivsus</value></data>
473 + <data name="events" xml:space="preserve"><value>sündmust</value></data>
474 + <data name="No recent activity" xml:space="preserve"><value>Hiljutist aktiivsust pole</value></data>
475 + <data name="Quick actions" xml:space="preserve"><value>Kiirtegevused</value></data>
476 + <data name="Trip &quot;{0}&quot; created" xml:space="preserve"><value>Reis „{0}" loodi</value></data>
477 + <data name="Expense {0:0.00} added to &quot;{1}&quot;" xml:space="preserve"><value>Kulu {0:0.00} lisatud reisile „{1}"</value></data>
478 + <data name="Settlement plan for &quot;{0}&quot; ({1})" xml:space="preserve"><value>Arveldusplaan reisile „{0}" ({1})</value></data>
479 + <data name="Trip details" xml:space="preserve"><value>Reisi andmed</value></data>
480 + <data name="Expense details" xml:space="preserve"><value>Kulu andmed</value></data>
481 + <data name="Budget Category details" xml:space="preserve"><value>Eelarvekategooria andmed</value></data>
482 + <data name="New budget category" xml:space="preserve"><value>Uus eelarvekategooria</value></data>
483 + <data name="Currency details" xml:space="preserve"><value>Valuuta andmed</value></data>
484 + <data name="Edit currency" xml:space="preserve"><value>Muuda valuutat</value></data>
485 + <data name="Delete currency" xml:space="preserve"><value>Kustuta valuuta</value></data>
486 + <data name="Poll details" xml:space="preserve"><value>Hääletuse andmed</value></data>
487 + <data name="New poll" xml:space="preserve"><value>Uus hääletus</value></data>
488 + <data name="Edit poll" xml:space="preserve"><value>Muuda hääletust</value></data>
489 + <data name="Delete poll" xml:space="preserve"><value>Kustuta hääletus</value></data>
490 + <data name="Wishlist item details" xml:space="preserve"><value>Soovinimekirja andmed</value></data>
491 + <data name="New wishlist item" xml:space="preserve"><value>Uus soov</value></data>
492 + <data name="Invitation details" xml:space="preserve"><value>Kutse andmed</value></data>
493 + <data name="Settlement plan details" xml:space="preserve"><value>Arveldusplaani andmed</value></data>
494 + <data name="New settlement plan" xml:space="preserve"><value>Uus arveldusplaan</value></data>
495 + <data name="Edit settlement plan" xml:space="preserve"><value>Muuda arveldusplaani</value></data>
496 + <data name="Delete settlement plan" xml:space="preserve"><value>Kustuta arveldusplaan</value></data>
497 + <data name="Settlement payment details" xml:space="preserve"><value>Arveldusmakse andmed</value></data>
498 + <data name="Split preset details" xml:space="preserve"><value>Jaotuse eelseadistuse andmed</value></data>
499 + <data name="Trip participant details" xml:space="preserve"><value>Reisi osaleja andmed</value></data>
500 + <data name="New trip participant" xml:space="preserve"><value>Uus reisi osaleja</value></data>
501 + <data name="Edit trip participant" xml:space="preserve"><value>Muuda reisi osalejat</value></data>
502 + <data name="Delete trip participant" xml:space="preserve"><value>Kustuta reisi osaleja</value></data>
503 +
504 + <!-- ========== DEFAULT DATA ANNOTATION VALIDATION MESSAGES ========== -->
505 + <!-- These match the literal default error strings ASP.NET passes to the
506 + DataAnnotationLocalizerProvider as keys when ErrorMessage is not set. -->
507 + <data name="The {0} field is required." xml:space="preserve"><value>Väli {0} on kohustuslik.</value></data>
508 + <data name="The field {0} must be a string with a maximum length of {1}." xml:space="preserve"><value>Väli {0} ei tohi ületada {1} tähemärki.</value></data>
509 + <data name="The field {0} must be a string or array type with a maximum length of '{1}'." xml:space="preserve"><value>Väli {0} ei tohi ületada {1} tähemärki.</value></data>
510 + <data name="The field {0} must be between {1} and {2}." xml:space="preserve"><value>Väli {0} peab olema vahemikus {1} kuni {2}.</value></data>
511 + <data name="The field {0} must be a string with a minimum length of {2} and a maximum length of {1}." xml:space="preserve"><value>Väli {0} peab olema {2} kuni {1} tähemärki.</value></data>
512 + <data name="The {0} field is not a valid e-mail address." xml:space="preserve"><value>Väli {0} ei ole kehtiv e-posti aadress.</value></data>
513 +</root>
added SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.resx +514 −0
@@ -0,0 +1,514 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<root>
3 + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
4 + <xsd:element name="root" msdata:IsDataSet="true">
5 + <xsd:complexType>
6 + <xsd:choice maxOccurs="unbounded">
7 + <xsd:element name="data">
8 + <xsd:complexType>
9 + <xsd:sequence>
10 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
11 + </xsd:sequence>
12 + <xsd:attribute name="name" type="xsd:string" use="required" />
13 + <xsd:attribute name="type" type="xsd:string" />
14 + <xsd:attribute name="mimetype" type="xsd:string" />
15 + <xsd:attribute ref="xml:space" />
16 + </xsd:complexType>
17 + </xsd:element>
18 + <xsd:element name="resheader">
19 + <xsd:complexType>
20 + <xsd:sequence>
21 + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
22 + </xsd:sequence>
23 + <xsd:attribute name="name" type="xsd:string" use="required" />
24 + </xsd:complexType>
25 + </xsd:element>
26 + </xsd:choice>
27 + </xsd:complexType>
28 + </xsd:element>
29 + </xsd:schema>
30 + <resheader name="resmimetype">
31 + <value>text/microsoft-resx</value>
32 + </resheader>
33 + <resheader name="version">
34 + <value>1.3</value>
35 + </resheader>
36 + <resheader name="reader">
37 + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
38 + </resheader>
39 + <resheader name="writer">
40 + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
41 + </resheader>
42 +
43 + <!-- ========== EXISTING KEYS (preserved) ========== -->
44 + <data name="Save" xml:space="preserve"><value>Save</value></data>
45 + <data name="Cancel" xml:space="preserve"><value>Cancel</value></data>
46 + <data name="Delete" xml:space="preserve"><value>Delete</value></data>
47 + <data name="Edit" xml:space="preserve"><value>Edit</value></data>
48 + <data name="Create" xml:space="preserve"><value>Create</value></data>
49 + <data name="Back" xml:space="preserve"><value>Back</value></data>
50 + <data name="Confirm" xml:space="preserve"><value>Confirm</value></data>
51 + <data name="Search" xml:space="preserve"><value>Search</value></data>
52 + <data name="Actions" xml:space="preserve"><value>Actions</value></data>
53 + <data name="Loading" xml:space="preserve"><value>Loading...</value></data>
54 + <data name="NoData" xml:space="preserve"><value>No data available</value></data>
55 + <data name="LogIn" xml:space="preserve"><value>Log In</value></data>
56 + <data name="LogOut" xml:space="preserve"><value>Log Out</value></data>
57 + <data name="Register" xml:space="preserve"><value>Register</value></data>
58 + <data name="Trips" xml:space="preserve"><value>Trips</value></data>
59 + <data name="Expenses" xml:space="preserve"><value>Expenses</value></data>
60 + <data name="Budget" xml:space="preserve"><value>Budget</value></data>
61 + <data name="Members" xml:space="preserve"><value>Members</value></data>
62 + <data name="Wishlist" xml:space="preserve"><value>Wishlist</value></data>
63 + <data name="Polls" xml:space="preserve"><value>Polls</value></data>
64 + <data name="Settlement" xml:space="preserve"><value>Settlement</value></data>
65 + <data name="Categories" xml:space="preserve"><value>Categories</value></data>
66 + <data name="Dashboard" xml:space="preserve"><value>Dashboard</value></data>
67 + <data name="AreYouSure" xml:space="preserve"><value>Are you sure?</value></data>
68 + <data name="DeleteConfirm" xml:space="preserve"><value>Do you want to delete this?</value></data>
69 + <data name="Details" xml:space="preserve"><value>Details</value></data>
70 + <data name="Name" xml:space="preserve"><value>Name</value></data>
71 + <data name="Description" xml:space="preserve"><value>Description</value></data>
72 + <data name="Amount" xml:space="preserve"><value>Amount</value></data>
73 + <data name="Date" xml:space="preserve"><value>Date</value></data>
74 + <data name="Status" xml:space="preserve"><value>Status</value></data>
75 + <data name="Admin" xml:space="preserve"><value>Admin</value></data>
76 +
77 + <!-- ========== COMMON UI ========== -->
78 + <data name="Home" xml:space="preserve"><value>Home</value></data>
79 + <data name="Hello" xml:space="preserve"><value>Hello</value></data>
80 + <data name="Welcome" xml:space="preserve"><value>Welcome</value></data>
81 + <data name="Total" xml:space="preserve"><value>Total</value></data>
82 + <data name="Email" xml:space="preserve"><value>Email</value></data>
83 + <data name="Joined" xml:space="preserve"><value>Joined</value></data>
84 + <data name="Role" xml:space="preserve"><value>Role</value></data>
85 + <data name="Summary" xml:space="preserve"><value>Summary</value></data>
86 + <data name="Dates" xml:space="preserve"><value>Dates</value></data>
87 + <data name="Currency" xml:space="preserve"><value>Currency</value></data>
88 + <data name="Currencies" xml:space="preserve"><value>Currencies</value></data>
89 + <data name="Destination" xml:space="preserve"><value>Destination</value></data>
90 + <data name="Participants" xml:space="preserve"><value>Participants</value></data>
91 + <data name="Code" xml:space="preserve"><value>Code</value></data>
92 + <data name="Symbol" xml:space="preserve"><value>Symbol</value></data>
93 + <data name="Select" xml:space="preserve"><value>Select</value></data>
94 + <data name="Copy" xml:space="preserve"><value>Copy</value></data>
95 + <data name="Split" xml:space="preserve"><value>Split</value></data>
96 + <data name="Member" xml:space="preserve"><value>Member</value></data>
97 + <data name="Title" xml:space="preserve"><value>Title</value></data>
98 + <data name="Question" xml:space="preserve"><value>Question</value></data>
99 + <data name="Option" xml:space="preserve"><value>Option</value></data>
100 + <data name="Options" xml:space="preserve"><value>Options</value></data>
101 + <data name="Priority" xml:space="preserve"><value>Priority</value></data>
102 + <data name="Location" xml:space="preserve"><value>Location</value></data>
103 + <data name="URL" xml:space="preserve"><value>URL</value></data>
104 + <data name="User" xml:space="preserve"><value>User</value></data>
105 + <data name="Trip" xml:space="preserve"><value>Trip</value></data>
106 + <data name="Close" xml:space="preserve"><value>Close</value></data>
107 + <data name="Yes" xml:space="preserve"><value>Yes</value></data>
108 + <data name="No" xml:space="preserve"><value>No</value></data>
109 + <data name="Submit" xml:space="preserve"><value>Submit</value></data>
110 + <data name="Add" xml:space="preserve"><value>Add</value></data>
111 + <data name="Remove" xml:space="preserve"><value>Remove</value></data>
112 + <data name="Unknown" xml:space="preserve"><value>Unknown</value></data>
113 + <data name="or" xml:space="preserve"><value>or</value></data>
114 + <data name="of" xml:space="preserve"><value>of</value></data>
115 + <data name="Category" xml:space="preserve"><value>Category</value></data>
116 + <data name="Expires" xml:space="preserve"><value>Expires</value></data>
117 + <data name="Nickname" xml:space="preserve"><value>Nickname</value></data>
118 +
119 + <!-- ========== TRIP ========== -->
120 + <data name="My Trips" xml:space="preserve"><value>My Trips</value></data>
121 + <data name="Create Trip" xml:space="preserve"><value>Create Trip</value></data>
122 + <data name="Edit Trip" xml:space="preserve"><value>Edit Trip</value></data>
123 + <data name="Delete Trip" xml:space="preserve"><value>Delete Trip</value></data>
124 + <data name="Trip Information" xml:space="preserve"><value>Trip Information</value></data>
125 + <data name="Back to Trips" xml:space="preserve"><value>Back to Trips</value></data>
126 + <data name="Back to Trip" xml:space="preserve"><value>Back to Trip</value></data>
127 + <data name="No trips yet" xml:space="preserve"><value>No trips yet</value></data>
128 + <data name="Create your first trip to get started!" xml:space="preserve"><value>Create your first trip to get started!</value></data>
129 + <data name="View Details" xml:space="preserve"><value>View Details</value></data>
130 + <data name="View All" xml:space="preserve"><value>View All</value></data>
131 + <data name="Go to My Trips" xml:space="preserve"><value>Go to My Trips</value></data>
132 + <data name="Start Date" xml:space="preserve"><value>Start Date</value></data>
133 + <data name="End Date" xml:space="preserve"><value>End Date</value></data>
134 + <data name="Default Currency" xml:space="preserve"><value>Default Currency</value></data>
135 + <data name="Select currency..." xml:space="preserve"><value>Select currency...</value></data>
136 + <data name="Are you sure you want to delete this trip? This action cannot be undone." xml:space="preserve"><value>Are you sure you want to delete this trip? This action cannot be undone.</value></data>
137 +
138 + <!-- ========== EXPENSE ========== -->
139 + <data name="Add Expense" xml:space="preserve"><value>Add Expense</value></data>
140 + <data name="Edit Expense" xml:space="preserve"><value>Edit Expense</value></data>
141 + <data name="Delete Expense" xml:space="preserve"><value>Delete Expense</value></data>
142 + <data name="Paid By" xml:space="preserve"><value>Paid By</value></data>
143 + <data name="Who Paid" xml:space="preserve"><value>Who Paid</value></data>
144 + <data name="Split Method" xml:space="preserve"><value>Split Method</value></data>
145 + <data name="Select Participants" xml:space="preserve"><value>Select Participants</value></data>
146 + <data name="No category" xml:space="preserve"><value>No category</value></data>
147 + <data name="Trip default" xml:space="preserve"><value>Trip default</value></data>
148 + <data name="No expenses yet" xml:space="preserve"><value>No expenses yet</value></data>
149 + <data name="Add your first expense to start tracking." xml:space="preserve"><value>Add your first expense to start tracking.</value></data>
150 + <data name="Recent Expenses" xml:space="preserve"><value>Recent Expenses</value></data>
151 + <data name="Expense" xml:space="preserve"><value>Expense</value></data>
152 + <data name="Total Expenses" xml:space="preserve"><value>Total Expenses</value></data>
153 + <data name="Are you sure you want to delete this expense? This action cannot be undone." xml:space="preserve"><value>Are you sure you want to delete this expense? This action cannot be undone.</value></data>
154 + <data name="Estimated Cost" xml:space="preserve"><value>Estimated Cost</value></data>
155 +
156 + <!-- ========== SPLIT METHODS ========== -->
157 + <data name="Equal (all)" xml:space="preserve"><value>Equal (all)</value></data>
158 + <data name="Equal (subset)" xml:space="preserve"><value>Equal (subset)</value></data>
159 + <data name="Exact amounts" xml:space="preserve"><value>Exact amounts</value></data>
160 + <data name="Percentages" xml:space="preserve"><value>Percentages</value></data>
161 +
162 + <!-- ========== MEMBERS ========== -->
163 + <data name="Current Members" xml:space="preserve"><value>Current Members</value></data>
164 + <data name="Pending Invitations" xml:space="preserve"><value>Pending Invitations</value></data>
165 + <data name="Invite Member" xml:space="preserve"><value>Invite Member</value></data>
166 + <data name="Generate Invitation Link" xml:space="preserve"><value>Generate Invitation Link</value></data>
167 + <data name="Back to Members" xml:space="preserve"><value>Back to Members</value></data>
168 + <data name="Invited by" xml:space="preserve"><value>Invited by</value></data>
169 + <data name="Are you sure you want to remove this member?" xml:space="preserve"><value>Are you sure you want to remove this member?</value></data>
170 +
171 + <!-- ========== SETTLEMENT ========== -->
172 + <data name="Balances" xml:space="preserve"><value>Balances</value></data>
173 + <data name="Settlement Plan" xml:space="preserve"><value>Settlement Plan</value></data>
174 + <data name="Total Paid" xml:space="preserve"><value>Total Paid</value></data>
175 + <data name="Total Owed" xml:space="preserve"><value>Total Owed</value></data>
176 + <data name="Net Balance" xml:space="preserve"><value>Net Balance</value></data>
177 + <data name="From" xml:space="preserve"><value>From</value></data>
178 + <data name="To" xml:space="preserve"><value>To</value></data>
179 + <data name="Calculate Settlement" xml:space="preserve"><value>Calculate Settlement</value></data>
180 + <data name="Mark Paid" xml:space="preserve"><value>Mark Paid</value></data>
181 + <data name="Confirm Receipt" xml:space="preserve"><value>Confirm Receipt</value></data>
182 + <data name="No settlement plan generated yet. Click 'Calculate Settlement' to generate one." xml:space="preserve"><value>No settlement plan generated yet. Click 'Calculate Settlement' to generate one.</value></data>
183 +
184 + <!-- ========== BUDGET ========== -->
185 + <data name="Total Planned" xml:space="preserve"><value>Total Planned</value></data>
186 + <data name="Total Spent" xml:space="preserve"><value>Total Spent</value></data>
187 + <data name="Remaining" xml:space="preserve"><value>Remaining</value></data>
188 + <data name="No budget categories yet" xml:space="preserve"><value>No budget categories yet</value></data>
189 + <data name="Add categories to track your trip budget." xml:space="preserve"><value>Add categories to track your trip budget.</value></data>
190 + <data name="Add Category" xml:space="preserve"><value>Add Category</value></data>
191 + <data name="Add Budget Category" xml:space="preserve"><value>Add Budget Category</value></data>
192 + <data name="Edit Budget Category" xml:space="preserve"><value>Edit Budget Category</value></data>
193 + <data name="Delete Budget Category" xml:space="preserve"><value>Delete Budget Category</value></data>
194 + <data name="Category Name" xml:space="preserve"><value>Category Name</value></data>
195 + <data name="Icon Name" xml:space="preserve"><value>Icon Name</value></data>
196 + <data name="Bootstrap Icons name (optional)" xml:space="preserve"><value>Bootstrap Icons name (optional)</value></data>
197 + <data name="Planned Amount" xml:space="preserve"><value>Planned Amount</value></data>
198 + <data name="Display Order" xml:space="preserve"><value>Display Order</value></data>
199 + <data name="Are you sure you want to delete this budget category? This action cannot be undone." xml:space="preserve"><value>Are you sure you want to delete this budget category? This action cannot be undone.</value></data>
200 +
201 + <!-- ========== WISHLIST ========== -->
202 + <data name="Link" xml:space="preserve"><value>Link</value></data>
203 + <data name="Added by" xml:space="preserve"><value>Added by</value></data>
204 + <data name="Vote" xml:space="preserve"><value>Vote</value></data>
205 + <data name="Voted" xml:space="preserve"><value>Voted</value></data>
206 + <data name="Mark Done" xml:space="preserve"><value>Mark Done</value></data>
207 + <data name="Completed" xml:space="preserve"><value>Completed</value></data>
208 + <data name="Add Item" xml:space="preserve"><value>Add Item</value></data>
209 + <data name="Add Wishlist Item" xml:space="preserve"><value>Add Wishlist Item</value></data>
210 + <data name="No wishlist items yet" xml:space="preserve"><value>No wishlist items yet</value></data>
211 + <data name="Add places, activities, or restaurants you want to visit." xml:space="preserve"><value>Add places, activities, or restaurants you want to visit.</value></data>
212 +
213 + <!-- ========== POLLS ========== -->
214 + <data name="Create Poll" xml:space="preserve"><value>Create Poll</value></data>
215 + <data name="Open" xml:space="preserve"><value>Open</value></data>
216 + <data name="Closed" xml:space="preserve"><value>Closed</value></data>
217 + <data name="Created by" xml:space="preserve"><value>Created by</value></data>
218 + <data name="View Results" xml:space="preserve"><value>View Results</value></data>
219 + <data name="Close Poll" xml:space="preserve"><value>Close Poll</value></data>
220 + <data name="Back to Polls" xml:space="preserve"><value>Back to Polls</value></data>
221 + <data name="Poll Results" xml:space="preserve"><value>Poll Results</value></data>
222 + <data name="No polls yet" xml:space="preserve"><value>No polls yet</value></data>
223 + <data name="Create a poll to help your group make decisions." xml:space="preserve"><value>Create a poll to help your group make decisions.</value></data>
224 + <data name="votes" xml:space="preserve"><value>votes</value></data>
225 + <data name="Multiple votes allowed" xml:space="preserve"><value>Multiple votes allowed</value></data>
226 + <data name="Allow multiple votes" xml:space="preserve"><value>Allow multiple votes</value></data>
227 + <data name="Anonymous voting" xml:space="preserve"><value>Anonymous voting</value></data>
228 + <data name="At least 2 options are required. Up to 5 options supported." xml:space="preserve"><value>At least 2 options are required. Up to 5 options supported.</value></data>
229 + <data name="total votes" xml:space="preserve"><value>total votes</value></data>
230 + <data name="Your vote" xml:space="preserve"><value>Your vote</value></data>
231 + <data name="Winner" xml:space="preserve"><value>Winner</value></data>
232 + <data name="Unvote" xml:space="preserve"><value>Unvote</value></data>
233 +
234 + <!-- ========== ADMIN ========== -->
235 + <data name="AdminDashboard" xml:space="preserve"><value>Admin Dashboard</value></data>
236 + <data name="ManageEntities" xml:space="preserve"><value>Manage Entities</value></data>
237 + <data name="ManageTrips" xml:space="preserve"><value>Manage Trips</value></data>
238 + <data name="ManageExpenses" xml:space="preserve"><value>Manage Expenses</value></data>
239 + <data name="ManageCategories" xml:space="preserve"><value>Manage Categories</value></data>
240 + <data name="ManageCurrencies" xml:space="preserve"><value>Manage Currencies</value></data>
241 + <data name="ManageSettlements" xml:space="preserve"><value>Manage Settlements</value></data>
242 + <data name="ManageParticipants" xml:space="preserve"><value>Manage Participants</value></data>
243 + <data name="ManageWishlist" xml:space="preserve"><value>Manage Wishlist</value></data>
244 + <data name="ManagePolls" xml:space="preserve"><value>Manage Polls</value></data>
245 + <data name="Users" xml:space="preserve"><value>Users</value></data>
246 + <data name="Invitations" xml:space="preserve"><value>Invitations</value></data>
247 +
248 + <!-- ========== ADMIN ENTITY FIELD KEYS ========== -->
249 + <data name="StartDate" xml:space="preserve"><value>Start Date</value></data>
250 + <data name="EndDate" xml:space="preserve"><value>End Date</value></data>
251 + <data name="CreatedBy" xml:space="preserve"><value>Created By</value></data>
252 + <data name="DefaultCurrency" xml:space="preserve"><value>Default Currency</value></data>
253 + <data name="ExpenseDate" xml:space="preserve"><value>Expense Date</value></data>
254 + <data name="PaidByUser" xml:space="preserve"><value>Paid By</value></data>
255 + <data name="SplitMethod" xml:space="preserve"><value>Split Method</value></data>
256 + <data name="BudgetCategory" xml:space="preserve"><value>Budget Category</value></data>
257 + <data name="BudgetCategories" xml:space="preserve"><value>Budget Categories</value></data>
258 + <data name="PlannedAmount" xml:space="preserve"><value>Planned Amount</value></data>
259 + <data name="DisplayOrder" xml:space="preserve"><value>Display Order</value></data>
260 + <data name="IconName" xml:space="preserve"><value>Icon Name</value></data>
261 + <data name="SettlementPlans" xml:space="preserve"><value>Settlement Plans</value></data>
262 + <data name="SettlementPlan" xml:space="preserve"><value>Settlement Plan</value></data>
263 + <data name="TotalAmount" xml:space="preserve"><value>Total Amount</value></data>
264 + <data name="CreatedByUser" xml:space="preserve"><value>Created By</value></data>
265 + <data name="CompletedAt" xml:space="preserve"><value>Completed At</value></data>
266 + <data name="TripParticipants" xml:space="preserve"><value>Trip Participants</value></data>
267 + <data name="TripParticipant" xml:space="preserve"><value>Trip Participant</value></data>
268 + <data name="JoinedAt" xml:space="preserve"><value>Joined At</value></data>
269 + <data name="LeftAt" xml:space="preserve"><value>Left At</value></data>
270 + <data name="IsActive" xml:space="preserve"><value>Is Active</value></data>
271 +
272 + <!-- ========== ENUM VALUES ========== -->
273 + <data name="Active" xml:space="preserve"><value>Active</value></data>
274 + <data name="Settled" xml:space="preserve"><value>Settled</value></data>
275 + <data name="Archived" xml:space="preserve"><value>Archived</value></data>
276 + <data name="Pending" xml:space="preserve"><value>Pending</value></data>
277 + <data name="Accepted" xml:space="preserve"><value>Accepted</value></data>
278 + <data name="Declined" xml:space="preserve"><value>Declined</value></data>
279 + <data name="Expired" xml:space="preserve"><value>Expired</value></data>
280 + <data name="Revoked" xml:space="preserve"><value>Revoked</value></data>
281 + <data name="Organizer" xml:space="preserve"><value>Organizer</value></data>
282 + <data name="Participant" xml:space="preserve"><value>Participant</value></data>
283 + <data name="MustDo" xml:space="preserve"><value>Must Do</value></data>
284 + <data name="NiceToHave" xml:space="preserve"><value>Nice to Have</value></data>
285 + <data name="Optional" xml:space="preserve"><value>Optional</value></data>
286 + <data name="Place" xml:space="preserve"><value>Place</value></data>
287 + <data name="Activity" xml:space="preserve"><value>Activity</value></data>
288 + <data name="Restaurant" xml:space="preserve"><value>Restaurant</value></data>
289 + <data name="Other" xml:space="preserve"><value>Other</value></data>
290 + <data name="InProgress" xml:space="preserve"><value>In Progress</value></data>
291 + <data name="MarkedPaid" xml:space="preserve"><value>Marked Paid</value></data>
292 + <data name="Confirmed" xml:space="preserve"><value>Confirmed</value></data>
293 +
294 + <!-- ========== HOME PAGE ========== -->
295 + <data name="AppTagline" xml:space="preserve"><value>Group travel expense management made easy.</value></data>
296 + <data name="AppDescription" xml:space="preserve"><value>Track expenses, split costs, manage budgets, and settle debts with your travel group.</value></data>
297 +
298 + <!-- ========== INVITATION ========== -->
299 + <data name="Accept Invitation" xml:space="preserve"><value>Accept Invitation</value></data>
300 + <data name="Invitation Generated" xml:space="preserve"><value>Invitation Generated</value></data>
301 + <data name="Invalid Invitation" xml:space="preserve"><value>Invalid Invitation</value></data>
302 + <data name="You've Been Invited!" xml:space="preserve"><value>You've Been Invited!</value></data>
303 + <data name="By accepting, you will join this trip as a participant." xml:space="preserve"><value>By accepting, you will join this trip as a participant.</value></data>
304 + <data name="This invitation is no longer valid." xml:space="preserve"><value>This invitation is no longer valid.</value></data>
305 + <data name="Invitation link has been generated successfully!" xml:space="preserve"><value>Invitation link has been generated successfully!</value></data>
306 + <data name="Share this link with the person you want to invite:" xml:space="preserve"><value>Share this link with the person you want to invite:</value></data>
307 + <data name="This link will expire in 7 days." xml:space="preserve"><value>This link will expire in 7 days.</value></data>
308 + <data name="The invitation link will expire after 7 days." xml:space="preserve"><value>The invitation link will expire after 7 days.</value></data>
309 + <data name="Generate an invitation link that you can share with someone to join this trip." xml:space="preserve"><value>Generate an invitation link that you can share with someone to join this trip.</value></data>
310 +
311 + <!-- ========== ADDITIONAL KEYS ========== -->
312 + <data name="AddedBy" xml:space="preserve"><value>Added by</value></data>
313 + <data name="AllowMultipleVotes" xml:space="preserve"><value>Allow multiple votes</value></data>
314 + <data name="Back to List" xml:space="preserve"><value>Back to List</value></data>
315 + <data name="Budget Categories" xml:space="preserve"><value>Budget Categories</value></data>
316 + <data name="ClosedAt" xml:space="preserve"><value>Closed at</value></data>
317 + <data name="CreatePoll" xml:space="preserve"><value>Create Poll</value></data>
318 + <data name="DeletePoll" xml:space="preserve"><value>Delete Poll</value></data>
319 + <data name="EditPoll" xml:space="preserve"><value>Edit Poll</value></data>
320 + <data name="IsAnonymous" xml:space="preserve"><value>Anonymous</value></data>
321 + <data name="Manage Categories" xml:space="preserve"><value>Manage Categories</value></data>
322 + <data name="Manage Currencies" xml:space="preserve"><value>Manage Currencies</value></data>
323 + <data name="Manage Entities" xml:space="preserve"><value>Manage Entities</value></data>
324 + <data name="Manage Expenses" xml:space="preserve"><value>Manage Expenses</value></data>
325 + <data name="Manage Participants" xml:space="preserve"><value>Manage Participants</value></data>
326 + <data name="Manage Polls" xml:space="preserve"><value>Manage Polls</value></data>
327 + <data name="Manage Settlements" xml:space="preserve"><value>Manage Settlements</value></data>
328 + <data name="Manage Trips" xml:space="preserve"><value>Manage Trips</value></data>
329 + <data name="Manage Wishlist" xml:space="preserve"><value>Manage Wishlist</value></data>
330 + <data name="PollDetails" xml:space="preserve"><value>Poll Details</value></data>
331 + <data name="Settlements" xml:space="preserve"><value>Settlements</value></data>
332 + <data name="Text" xml:space="preserve"><value>Text</value></data>
333 + <data name="Toggle navigation" xml:space="preserve"><value>Toggle navigation</value></data>
334 + <data name="Wishlist Items" xml:space="preserve"><value>Wishlist Items</value></data>
335 + <data name="WishlistItem" xml:space="preserve"><value>Wishlist Item</value></data>
336 + <data name="WishlistItemDetails" xml:space="preserve"><value>Wishlist Item Details</value></data>
337 +
338 + <!-- ========== NEW KEYS ========== -->
339 + <data name="Add expenses as you go. Choose how to split — we handle the math." xml:space="preserve"><value>Add expenses as you go. Choose how to split — we handle the math.</value></data>
340 + <data name="Admin Panel" xml:space="preserve"><value>Admin Panel</value></data>
341 + <data name="All settled up!" xml:space="preserve"><value>All settled up!</value></data>
342 + <data name="An error occurred while processing your request." xml:space="preserve"><value>An error occurred while processing your request.</value></data>
343 + <data name="Anonymous" xml:space="preserve"><value>Anonymous</value></data>
344 + <data name="At least 2 required" xml:space="preserve"><value>At least 2 required</value></data>
345 + <data name="Back to App" xml:space="preserve"><value>Back to App</value></data>
346 + <data name="Back to Home" xml:space="preserve"><value>Back to Home</value></data>
347 + <data name="Budget Progress" xml:space="preserve"><value>Budget Progress</value></data>
348 + <data name="Budget Used" xml:space="preserve"><value>Budget Used</value></data>
349 + <data name="Closed at" xml:space="preserve"><value>Closed at</value></data>
350 + <data name="Create Free Account" xml:space="preserve"><value>Create Free Account</value></data>
351 + <data name="Create a Trip" xml:space="preserve"><value>Create a Trip</value></data>
352 + <data name="Create polls, build wishlists, and vote together to make group planning effortless." xml:space="preserve"><value>Create polls, build wishlists, and vote together to make group planning effortless.</value></data>
353 + <data name="Done" xml:space="preserve"><value>Done</value></data>
354 + <data name="Entities" xml:space="preserve"><value>Entities</value></data>
355 + <data name="Entity Management" xml:space="preserve"><value>Entity Management</value></data>
356 + <data name="Error" xml:space="preserve"><value>Error</value></data>
357 + <data name="Everything you need for group travel" xml:space="preserve"><value>Everything you need for group travel</value></data>
358 + <data name="From splitting dinner bills to planning activities, SplitApp handles it all." xml:space="preserve"><value>From splitting dinner bills to planning activities, SplitApp handles it all.</value></data>
359 + <data name="Get Started" xml:space="preserve"><value>Get Started</value></data>
360 + <data name="Group Decisions" xml:space="preserve"><value>Group Decisions</value></data>
361 + <data name="Help your group make decisions together" xml:space="preserve"><value>Help your group make decisions together</value></data>
362 + <data name="How it works" xml:space="preserve"><value>How it works</value></data>
363 + <data name="Icon" xml:space="preserve"><value>Icon</value></data>
364 + <data name="Invite" xml:space="preserve"><value>Invite</value></data>
365 + <data name="Join thousands of travelers who split smarter." xml:space="preserve"><value>Join thousands of travelers who split smarter.</value></data>
366 + <data name="Learn More" xml:space="preserve"><value>Learn More</value></data>
367 + <data name="Link Generated!" xml:space="preserve"><value>Link Generated!</value></data>
368 + <data name="Log in" xml:space="preserve"><value>Log in</value></data>
369 + <data name="Manage Wishlist Items" xml:space="preserve"><value>Manage Wishlist Items</value></data>
370 + <data name="Multi-Vote" xml:space="preserve"><value>Multi-Vote</value></data>
371 + <data name="Multiple votes" xml:space="preserve"><value>Multiple votes</value></data>
372 + <data name="New Category" xml:space="preserve"><value>New Category</value></data>
373 + <data name="New Currency" xml:space="preserve"><value>New Currency</value></data>
374 + <data name="New Expense" xml:space="preserve"><value>New Expense</value></data>
375 + <data name="New Trip" xml:space="preserve"><value>New Trip</value></data>
376 + <data name="No description" xml:space="preserve"><value>No description</value></data>
377 + <data name="No payments needed — everyone is even." xml:space="preserve"><value>No payments needed — everyone is even.</value></data>
378 + <data name="No users yet" xml:space="preserve"><value>No users yet</value></data>
379 + <data name="Not friendships." xml:space="preserve"><value>Not friendships.</value></data>
380 + <data name="Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification." xml:space="preserve"><value>Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification.</value></data>
381 + <data name="Over budget!" xml:space="preserve"><value>Over budget!</value></data>
382 + <data name="Overall Progress" xml:space="preserve"><value>Overall Progress</value></data>
383 + <data name="Overview" xml:space="preserve"><value>Overview</value></data>
384 + <data name="Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place." xml:space="preserve"><value>Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place.</value></data>
385 + <data name="Planned" xml:space="preserve"><value>Planned</value></data>
386 + <data name="Poll" xml:space="preserve"><value>Poll</value></data>
387 + <data name="Privacy Policy" xml:space="preserve"><value>Privacy Policy</value></data>
388 + <data name="Profile" xml:space="preserve"><value>Profile</value></data>
389 + <data name="Quick Links" xml:space="preserve"><value>Quick Links</value></data>
390 + <data name="Ready to plan your next trip?" xml:space="preserve"><value>Ready to plan your next trip?</value></data>
391 + <data name="Recalculate" xml:space="preserve"><value>Recalculate</value></data>
392 + <data name="Recalculate after new expenses" xml:space="preserve"><value>Recalculate after new expenses</value></data>
393 + <data name="Recent Trips" xml:space="preserve"><value>Recent Trips</value></data>
394 + <data name="Recent Users" xml:space="preserve"><value>Recent Users</value></data>
395 + <data name="Request ID" xml:space="preserve"><value>Request ID</value></data>
396 + <data name="Search..." xml:space="preserve"><value>Search...</value></data>
397 + <data name="See who owes whom and settle with minimal payments." xml:space="preserve"><value>See who owes whom and settle with minimal payments.</value></data>
398 + <data name="Set up your next adventure" xml:space="preserve"><value>Set up your next adventure</value></data>
399 + <data name="Set up your trip and invite friends with a simple shareable link." xml:space="preserve"><value>Set up your trip and invite friends with a simple shareable link.</value></data>
400 + <data name="Settle Up" xml:space="preserve"><value>Settle Up</value></data>
401 + <data name="Share what you want to experience" xml:space="preserve"><value>Share what you want to experience</value></data>
402 + <data name="Sign up" xml:space="preserve"><value>Sign up</value></data>
403 + <data name="Something went wrong" xml:space="preserve"><value>Something went wrong</value></data>
404 + <data name="Split Any Way" xml:space="preserve"><value>Split Any Way</value></data>
405 + <data name="Split equally, by exact amounts, or percentages. Save presets for recurring groups." xml:space="preserve"><value>Split equally, by exact amounts, or percentages. Save presets for recurring groups.</value></data>
406 + <data name="Split expenses." xml:space="preserve"><value>Split expenses.</value></data>
407 + <data name="Start tracking your group expenses." xml:space="preserve"><value>Start tracking your group expenses.</value></data>
408 + <data name="System Administration" xml:space="preserve"><value>System Administration</value></data>
409 + <data name="Total Trips" xml:space="preserve"><value>Total Trips</value></data>
410 + <data name="Track &amp; Split" xml:space="preserve"><value>Track &amp; Split</value></data>
411 + <data name="Track a group expense" xml:space="preserve"><value>Track a group expense</value></data>
412 + <data name="Use this page to detail your site's privacy policy." xml:space="preserve"><value>Use this page to detail your site's privacy policy.</value></data>
413 + <data name="View Link" xml:space="preserve"><value>View Link</value></data>
414 + <data name="What should the group decide?" xml:space="preserve"><value>What should the group decide?</value></data>
415 + <data name="What's the plan?" xml:space="preserve"><value>What's the plan?</value></data>
416 + <data name="Why do you want to go here?" xml:space="preserve"><value>Why do you want to go here?</value></data>
417 + <data name="Wishlist Item" xml:space="preserve"><value>Wishlist Item</value></data>
418 + <data name="Your Balance" xml:space="preserve"><value>Your Balance</value></data>
419 + <data name="e.g. Group dinner, taxi, museum tickets..." xml:space="preserve"><value>e.g. Group dinner, taxi, museum tickets...</value></data>
420 + <data name="e.g. Old Town, City Center" xml:space="preserve"><value>e.g. Old Town, City Center</value></data>
421 + <data name="e.g. Sagrada Familia, Local tapas bar" xml:space="preserve"><value>e.g. Sagrada Familia, Local tapas bar</value></data>
422 + <data name="in progress" xml:space="preserve"><value>in progress</value></data>
423 + <data name="member(s)" xml:space="preserve"><value>member(s)</value></data>
424 + <data name="payment(s) marked paid, awaiting confirmation" xml:space="preserve"><value>payment(s) marked paid, awaiting confirmation</value></data>
425 + <data name="pending invitation(s)" xml:space="preserve"><value>pending invitation(s)</value></data>
426 + <data name="settlement payment(s) awaiting action" xml:space="preserve"><value>settlement payment(s) awaiting action</value></data>
427 + <data name="trip(s)" xml:space="preserve"><value>trip(s)</value></data>
428 +
429 + <data name="All" xml:space="preserve"><value>All</value></data>
430 +
431 + <!-- ========== SPLIT PRESETS ========== -->
432 + <data name="Apply Preset" xml:space="preserve"><value>Apply Preset</value></data>
433 + <data name="Select a preset..." xml:space="preserve"><value>Select a preset...</value></data>
434 + <data name="Apply" xml:space="preserve"><value>Apply</value></data>
435 + <data name="Save current split as preset" xml:space="preserve"><value>Save current split as preset</value></data>
436 + <data name="Preset name, e.g. Hotel group" xml:space="preserve"><value>Preset name, e.g. Hotel group</value></data>
437 +
438 + <!-- ========== WISHLIST EDIT/DELETE ========== -->
439 + <data name="Edit Wishlist Item" xml:space="preserve"><value>Edit Wishlist Item</value></data>
440 + <data name="Delete Wishlist Item" xml:space="preserve"><value>Delete Wishlist Item</value></data>
441 +
442 + <!-- ========== ADMIN USER MANAGEMENT ========== -->
443 + <data name="User Management" xml:space="preserve"><value>User Management</value></data>
444 + <data name="Edit Roles" xml:space="preserve"><value>Edit Roles</value></data>
445 + <data name="System" xml:space="preserve"><value>System</value></data>
446 + <data name="More" xml:space="preserve"><value>More</value></data>
447 + <data name="Payments" xml:space="preserve"><value>Payments</value></data>
448 + <data name="Split Presets" xml:space="preserve"><value>Split Presets</value></data>
449 + <data name="Revoke" xml:space="preserve"><value>Revoke</value></data>
450 +
451 + <!-- ADMIN REBUILD ADDITIONS -->
452 + <data name="Core" xml:space="preserve"><value>Core</value></data>
453 + <data name="Back to site" xml:space="preserve"><value>Back to site</value></data>
454 + <data name="System overview and real-time statistics" xml:space="preserve"><value>System overview and real-time statistics</value></data>
455 + <data name="payments awaiting action" xml:space="preserve"><value>payments awaiting action</value></data>
456 + <data name="payments awaiting confirmation" xml:space="preserve"><value>payments awaiting confirmation</value></data>
457 + <data name="Total Users" xml:space="preserve"><value>Total Users</value></data>
458 + <data name="Total amount" xml:space="preserve"><value>Total amount</value></data>
459 + <data name="Settlement plans" xml:space="preserve"><value>Settlement plans</value></data>
460 + <data name="Pending payments" xml:space="preserve"><value>Pending payments</value></data>
461 + <data name="Trip status breakdown" xml:space="preserve"><value>Trip status breakdown</value></data>
462 + <data name="Settlement status" xml:space="preserve"><value>Settlement status</value></data>
463 + <data name="Top active trips" xml:space="preserve"><value>Top active trips</value></data>
464 + <data name="Biggest expenses" xml:space="preserve"><value>Biggest expenses</value></data>
465 + <data name="No active trips yet" xml:space="preserve"><value>No active trips yet</value></data>
466 + <data name="by" xml:space="preserve"><value>by</value></data>
467 + <data name="User activity" xml:space="preserve"><value>User activity</value></data>
468 + <data name="Active 7d" xml:space="preserve"><value>Active 7d</value></data>
469 + <data name="Active 30d" xml:space="preserve"><value>Active 30d</value></data>
470 + <data name="Most active users" xml:space="preserve"><value>Most active users</value></data>
471 + <data name="No activity yet" xml:space="preserve"><value>No activity yet</value></data>
472 + <data name="Recent activity" xml:space="preserve"><value>Recent activity</value></data>
473 + <data name="events" xml:space="preserve"><value>events</value></data>
474 + <data name="No recent activity" xml:space="preserve"><value>No recent activity</value></data>
475 + <data name="Quick actions" xml:space="preserve"><value>Quick actions</value></data>
476 + <data name="Trip &quot;{0}&quot; created" xml:space="preserve"><value>Trip &quot;{0}&quot; created</value></data>
477 + <data name="Expense {0:0.00} added to &quot;{1}&quot;" xml:space="preserve"><value>Expense {0:0.00} added to &quot;{1}&quot;</value></data>
478 + <data name="Settlement plan for &quot;{0}&quot; ({1})" xml:space="preserve"><value>Settlement plan for &quot;{0}&quot; ({1})</value></data>
479 + <data name="Trip details" xml:space="preserve"><value>Trip details</value></data>
480 + <data name="Expense details" xml:space="preserve"><value>Expense details</value></data>
481 + <data name="Budget Category details" xml:space="preserve"><value>Budget Category details</value></data>
482 + <data name="New budget category" xml:space="preserve"><value>New budget category</value></data>
483 + <data name="Currency details" xml:space="preserve"><value>Currency details</value></data>
484 + <data name="Edit currency" xml:space="preserve"><value>Edit currency</value></data>
485 + <data name="Delete currency" xml:space="preserve"><value>Delete currency</value></data>
486 + <data name="Poll details" xml:space="preserve"><value>Poll details</value></data>
487 + <data name="New poll" xml:space="preserve"><value>New poll</value></data>
488 + <data name="Edit poll" xml:space="preserve"><value>Edit poll</value></data>
489 + <data name="Delete poll" xml:space="preserve"><value>Delete poll</value></data>
490 + <data name="Wishlist item details" xml:space="preserve"><value>Wishlist item details</value></data>
491 + <data name="New wishlist item" xml:space="preserve"><value>New wishlist item</value></data>
492 + <data name="Invitation details" xml:space="preserve"><value>Invitation details</value></data>
493 + <data name="Settlement plan details" xml:space="preserve"><value>Settlement plan details</value></data>
494 + <data name="New settlement plan" xml:space="preserve"><value>New settlement plan</value></data>
495 + <data name="Edit settlement plan" xml:space="preserve"><value>Edit settlement plan</value></data>
496 + <data name="Delete settlement plan" xml:space="preserve"><value>Delete settlement plan</value></data>
497 + <data name="Settlement payment details" xml:space="preserve"><value>Settlement payment details</value></data>
498 + <data name="Split preset details" xml:space="preserve"><value>Split preset details</value></data>
499 + <data name="Trip participant details" xml:space="preserve"><value>Trip participant details</value></data>
500 + <data name="New trip participant" xml:space="preserve"><value>New trip participant</value></data>
501 + <data name="Edit trip participant" xml:space="preserve"><value>Edit trip participant</value></data>
502 + <data name="Delete trip participant" xml:space="preserve"><value>Delete trip participant</value></data>
503 +
504 + <!-- ========== DEFAULT DATA ANNOTATION VALIDATION MESSAGES ========== -->
505 + <!-- Identity values for English; the et.resx pair holds Estonian translations.
506 + These are the literal default strings ASP.NET passes to the
507 + DataAnnotationLocalizerProvider as keys when ErrorMessage is not set. -->
508 + <data name="The {0} field is required." xml:space="preserve"><value>The {0} field is required.</value></data>
509 + <data name="The field {0} must be a string with a maximum length of {1}." xml:space="preserve"><value>The field {0} must be a string with a maximum length of {1}.</value></data>
510 + <data name="The field {0} must be a string or array type with a maximum length of '{1}'." xml:space="preserve"><value>The field {0} must be a string or array type with a maximum length of '{1}'.</value></data>
511 + <data name="The field {0} must be between {1} and {2}." xml:space="preserve"><value>The field {0} must be between {1} and {2}.</value></data>
512 + <data name="The field {0} must be a string with a minimum length of {2} and a maximum length of {1}." xml:space="preserve"><value>The field {0} must be a string with a minimum length of {2} and a maximum length of {1}.</value></data>
513 + <data name="The {0} field is not a valid e-mail address." xml:space="preserve"><value>The {0} field is not a valid e-mail address.</value></data>
514 +</root>
added SplitApp.Modular/src/SplitApp.WebApp/SplitApp.WebApp.csproj +40 −0
@@ -0,0 +1,40 @@
1 +<Project Sdk="Microsoft.NET.Sdk.Web">
2 +
3 + <ItemGroup>
4 + <ProjectReference Include="..\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" />
5 + <ProjectReference Include="..\Shared\SplitApp.Shared.Contracts\SplitApp.Shared.Contracts.csproj" />
6 + <ProjectReference Include="..\Shared\SplitApp.Shared.Messaging\SplitApp.Shared.Messaging.csproj" />
7 + <ProjectReference Include="..\Modules\Trips\SplitApp.Modules.Trips.Api\SplitApp.Modules.Trips.Api.csproj" />
8 + <ProjectReference Include="..\Modules\Trips\SplitApp.Modules.Trips.Infrastructure\SplitApp.Modules.Trips.Infrastructure.csproj" />
9 + <ProjectReference Include="..\Modules\Expenses\SplitApp.Modules.Expenses.Api\SplitApp.Modules.Expenses.Api.csproj" />
10 + <ProjectReference Include="..\Modules\Expenses\SplitApp.Modules.Expenses.Infrastructure\SplitApp.Modules.Expenses.Infrastructure.csproj" />
11 + </ItemGroup>
12 +
13 + <ItemGroup>
14 + <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" />
15 + <PackageReference Include="MediatR" Version="12.4.1" />
16 + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
17 + <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.5" />
18 + <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.5" />
19 + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5">
20 + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
21 + <PrivateAssets>all</PrivateAssets>
22 + </PackageReference>
23 + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
24 + <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
25 + </ItemGroup>
26 +
27 + <PropertyGroup>
28 + <TargetFramework>net10.0</TargetFramework>
29 + <Nullable>enable</Nullable>
30 + <ImplicitUsings>enable</ImplicitUsings>
31 + </PropertyGroup>
32 +
33 + <ItemGroup>
34 + <EmbeddedResource Update="Resources\Views\Shared.resx" LogicalName="App.Resources.Views.Shared.resources" />
35 + <EmbeddedResource Update="Resources\Views\Shared.et.resx" LogicalName="App.Resources.Views.Shared.et.resources" />
36 + <EmbeddedResource Update="Resources\Domain\Enums.resx" LogicalName="App.Resources.Domain.Enums.resources" />
37 + <EmbeddedResource Update="Resources\Domain\Enums.et.resx" LogicalName="App.Resources.Domain.Enums.et.resources" />
38 + </ItemGroup>
39 +
40 +</Project>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Account/Login.cshtml +30 −0
@@ -0,0 +1,30 @@
1 +@model SplitApp.WebApp.Models.Account.LoginViewModel
2 +@{
3 + ViewData["Title"] = Localizer["Log in"].Value;
4 +}
5 +
6 +<div class="container py-5" style="max-width: 480px;">
7 + <div class="sa-card p-4">
8 + <h1 class="h3 mb-4">@Localizer["Log in"]</h1>
9 + <form asp-action="Login" method="post">
10 + @Html.AntiForgeryToken()
11 + <input type="hidden" asp-for="ReturnUrl" />
12 + <div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
13 + <div class="mb-3">
14 + <label asp-for="Email" class="form-label"></label>
15 + <input asp-for="Email" class="form-control" autocomplete="email" />
16 + <span asp-validation-for="Email" class="text-danger"></span>
17 + </div>
18 + <div class="mb-3">
19 + <label asp-for="Password" class="form-label"></label>
20 + <input asp-for="Password" class="form-control" autocomplete="current-password" />
21 + <span asp-validation-for="Password" class="text-danger"></span>
22 + </div>
23 + <button type="submit" class="sa-btn sa-btn-primary w-100">@Localizer["Log in"]</button>
24 + </form>
25 + <hr class="my-4" />
26 + <p class="mb-0 text-center text-muted">
27 + <a asp-action="Register" asp-route-returnUrl="@Model.ReturnUrl">@Localizer["Sign up"]</a>
28 + </p>
29 + </div>
30 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Account/Manage.cshtml +18 −0
@@ -0,0 +1,18 @@
1 +@model SplitApp.WebApp.Models.Account.ManageViewModel
2 +@{
3 + ViewData["Title"] = Localizer["Profile"].Value;
4 +}
5 +
6 +<div class="container py-5" style="max-width: 520px;">
7 + <div class="sa-card p-4">
8 + <h1 class="h3 mb-4">@Localizer["Profile"]</h1>
9 + <dl class="row">
10 + <dt class="col-sm-4">@Localizer["Email"]</dt>
11 + <dd class="col-sm-8">@Model.Email</dd>
12 + <dt class="col-sm-4">@Localizer["First Name"]</dt>
13 + <dd class="col-sm-8">@Model.FirstName</dd>
14 + <dt class="col-sm-4">@Localizer["Last Name"]</dt>
15 + <dd class="col-sm-8">@Model.LastName</dd>
16 + </dl>
17 + </div>
18 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Account/Register.cshtml +47 −0
@@ -0,0 +1,47 @@
1 +@model SplitApp.WebApp.Models.Account.RegisterViewModel
2 +@{
3 + ViewData["Title"] = Localizer["Sign up"].Value;
4 +}
5 +
6 +<div class="container py-5" style="max-width: 520px;">
7 + <div class="sa-card p-4">
8 + <h1 class="h3 mb-4">@Localizer["Sign up"]</h1>
9 + <form asp-action="Register" method="post">
10 + @Html.AntiForgeryToken()
11 + <input type="hidden" asp-for="ReturnUrl" />
12 + <div asp-validation-summary="ModelOnly" class="text-danger mb-3"></div>
13 + <div class="row g-3">
14 + <div class="col-md-6">
15 + <label asp-for="FirstName" class="form-label"></label>
16 + <input asp-for="FirstName" class="form-control" />
17 + <span asp-validation-for="FirstName" class="text-danger"></span>
18 + </div>
19 + <div class="col-md-6">
20 + <label asp-for="LastName" class="form-label"></label>
21 + <input asp-for="LastName" class="form-control" />
22 + <span asp-validation-for="LastName" class="text-danger"></span>
23 + </div>
24 + </div>
25 + <div class="mb-3 mt-3">
26 + <label asp-for="Email" class="form-label"></label>
27 + <input asp-for="Email" class="form-control" autocomplete="email" />
28 + <span asp-validation-for="Email" class="text-danger"></span>
29 + </div>
30 + <div class="mb-3">
31 + <label asp-for="Password" class="form-label"></label>
32 + <input asp-for="Password" class="form-control" autocomplete="new-password" />
33 + <span asp-validation-for="Password" class="text-danger"></span>
34 + </div>
35 + <div class="mb-3">
36 + <label asp-for="ConfirmPassword" class="form-label"></label>
37 + <input asp-for="ConfirmPassword" class="form-control" autocomplete="new-password" />
38 + <span asp-validation-for="ConfirmPassword" class="text-danger"></span>
39 + </div>
40 + <button type="submit" class="sa-btn sa-btn-primary w-100">@Localizer["Sign up"]</button>
41 + </form>
42 + <hr class="my-4" />
43 + <p class="mb-0 text-center text-muted">
44 + <a asp-action="Login" asp-route-returnUrl="@Model.ReturnUrl">@Localizer["Log in"]</a>
45 + </p>
46 + </div>
47 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/CreateCategory.cshtml +71 −0
@@ -0,0 +1,71 @@
1 +@model SplitApp.WebApp.Application.DTO.BudgetCategoryBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Add Budget Category"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-secondary">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-success); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-wallet2"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Add Budget Category"]</h1>
17 + </div>
18 +
19 + <form asp-action="CreateCategory" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="TripId" />
22 +
23 + <div class="mb-3">
24 + <label for="name" class="form-label">@Localizer["Category Name"]</label>
25 + <input type="text" id="name" name="name" class="form-control" placeholder="e.g. Food, Transport, Activities" required />
26 + <span asp-validation-for="Name" class="text-danger"></span>
27 + </div>
28 +
29 + <div class="mb-3">
30 + <label class="form-label">@Localizer["Icon"]</label>
31 + <input type="hidden" asp-for="IconName" id="iconNameInput" />
32 + <div class="sa-icon-picker">
33 + @{
34 + var icons = new[] { "airplane", "house", "cart", "cup-hot", "film", "music-note-beamed", "cash-stack", "ticket-perforated", "bus-front", "fuel-pump", "gift", "heart-fill", "camera-fill", "egg-fried", "bed", "bicycle", "shop", "palette", "binoculars", "lightning" };
35 + }
36 + @foreach (var icon in icons)
37 + {
38 + <div class="sa-icon-option @(Model.IconName == icon ? "active" : "")" data-icon="@icon" title="@icon">
39 + <i class="bi bi-@icon"></i>
40 + </div>
41 + }
42 + </div>
43 + </div>
44 +
45 + <div class="mb-3">
46 + <label asp-for="PlannedAmount" class="form-label">@Localizer["Planned Amount"]</label>
47 + <input asp-for="PlannedAmount" type="number" step="0.01" min="0" class="form-control" placeholder="0.00" />
48 + <span asp-validation-for="PlannedAmount" class="text-danger"></span>
49 + </div>
50 +
51 + <div class="mb-4">
52 + <label asp-for="DisplayOrder" class="form-label">@Localizer["Display Order"]</label>
53 + <input asp-for="DisplayOrder" type="number" class="form-control" />
54 + <span asp-validation-for="DisplayOrder" class="text-danger"></span>
55 + </div>
56 +
57 + <div class="d-flex gap-3">
58 + <button type="submit" class="sa-btn sa-btn-secondary flex-grow-1">
59 + <i class="bi bi-check-lg"></i> @Localizer["Create"]
60 + </button>
61 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
62 + </div>
63 + </form>
64 + </div>
65 + </div>
66 + </div>
67 +</div>
68 +
69 +@section Scripts {
70 + <partial name="_ValidationScriptsPartial" />
71 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/DeleteCategory.cshtml +51 −0
@@ -0,0 +1,51 @@
1 +@model SplitApp.WebApp.Application.DTO.BudgetCategoryBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Delete Budget Category"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="sa-invite-page">
9 + <div class="sa-card-static sa-confirm-card sa-card-accent sa-card-accent-danger">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center">
12 + <div class="sa-confirm-icon sa-confirm-icon-danger">
13 + <i class="bi bi-exclamation-triangle-fill"></i>
14 + </div>
15 + <h2 style="font-size: 1.5rem; margin-bottom: var(--sa-space-2);">@Localizer["Delete Budget Category"]</h2>
16 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
17 + @Localizer["Are you sure you want to delete this budget category? This action cannot be undone."]
18 + </p>
19 + </div>
20 +
21 + <div class="sa-card-static" style="margin-bottom: var(--sa-space-6);">
22 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
23 + <div class="d-flex align-items-center gap-3">
24 + <div class="sa-expense-icon sa-category-default">
25 + @if (!string.IsNullOrEmpty(Model.IconName))
26 + { <i class="bi bi-@Model.IconName"></i> }
27 + else
28 + { <i class="bi bi-tag"></i> }
29 + </div>
30 + <div>
31 + <strong>@Model.Name</strong>
32 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">
33 + @Localizer["Planned"]: @(Model.PlannedAmount?.ToString("N2") ?? "-")
34 + </div>
35 + </div>
36 + </div>
37 + </div>
38 + </div>
39 +
40 + <form asp-action="DeleteCategory">
41 + <input type="hidden" asp-for="Id" />
42 + <div class="d-flex gap-3">
43 + <button type="submit" class="sa-btn sa-btn-danger flex-grow-1">
44 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
45 + </button>
46 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
47 + </div>
48 + </form>
49 + </div>
50 + </div>
51 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/EditCategory.cshtml +72 −0
@@ -0,0 +1,72 @@
1 +@model SplitApp.WebApp.Application.DTO.BudgetCategoryBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Edit Budget Category"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-secondary">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-secondary); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-pencil-square"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Edit Budget Category"]</h1>
17 + </div>
18 +
19 + <form asp-action="EditCategory" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="Id" />
22 + <input type="hidden" asp-for="TripId" />
23 +
24 + <div class="mb-3">
25 + <label for="name" class="form-label">@Localizer["Category Name"]</label>
26 + <input type="text" id="name" name="name" class="form-control" value="@Model.Name.ToString()" required />
27 + <span asp-validation-for="Name" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="mb-3">
31 + <label class="form-label">@Localizer["Icon"]</label>
32 + <input type="hidden" asp-for="IconName" id="iconNameInput" />
33 + <div class="sa-icon-picker">
34 + @{
35 + var icons = new[] { "airplane", "house", "cart", "cup-hot", "film", "music-note-beamed", "cash-stack", "ticket-perforated", "bus-front", "fuel-pump", "gift", "heart-fill", "camera-fill", "egg-fried", "bed", "bicycle", "shop", "palette", "binoculars", "lightning" };
36 + }
37 + @foreach (var icon in icons)
38 + {
39 + <div class="sa-icon-option @(Model.IconName == icon ? "active" : "")" data-icon="@icon" title="@icon">
40 + <i class="bi bi-@icon"></i>
41 + </div>
42 + }
43 + </div>
44 + </div>
45 +
46 + <div class="mb-3">
47 + <label asp-for="PlannedAmount" class="form-label">@Localizer["Planned Amount"]</label>
48 + <input asp-for="PlannedAmount" type="number" step="0.01" min="0" class="form-control" />
49 + <span asp-validation-for="PlannedAmount" class="text-danger"></span>
50 + </div>
51 +
52 + <div class="mb-4">
53 + <label asp-for="DisplayOrder" class="form-label">@Localizer["Display Order"]</label>
54 + <input asp-for="DisplayOrder" type="number" class="form-control" />
55 + <span asp-validation-for="DisplayOrder" class="text-danger"></span>
56 + </div>
57 +
58 + <div class="d-flex gap-3">
59 + <button type="submit" class="sa-btn sa-btn-secondary flex-grow-1">
60 + <i class="bi bi-check-lg"></i> @Localizer["Save"]
61 + </button>
62 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
63 + </div>
64 + </form>
65 + </div>
66 + </div>
67 + </div>
68 +</div>
69 +
70 +@section Scripts {
71 + <partial name="_ValidationScriptsPartial" />
72 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/Index.cshtml +155 −0
@@ -0,0 +1,155 @@
1 +@model List<SplitApp.WebApp.Controllers.BudgetCategoryViewModel>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Budget"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var tripName = (string)ViewData["TripName"]!;
7 + var totalPlanned = (decimal)ViewData["TotalPlanned"]!;
8 + var totalSpent = (decimal)ViewData["TotalSpent"]!;
9 + var remaining = totalPlanned - totalSpent;
10 + var overallPct = totalPlanned > 0 ? (int)(totalSpent * 100 / totalPlanned) : 0;
11 + var isOrganizer = (bool)ViewData["IsOrganizer"]!;
12 +}
13 +
14 +<!-- Page Header -->
15 +<div class="sa-gradient-header" style="background: linear-gradient(135deg, #22c55e 0%, #1a9e8f 100%);">
16 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
17 + <div>
18 + <h1 style="margin-bottom: 4px;">@Localizer["Budget"]</h1>
19 + <p class="text-muted mb-0">@tripName</p>
20 + </div>
21 + <div class="d-flex gap-2">
22 + @if (isOrganizer)
23 + {
24 + <a asp-action="CreateCategory" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
25 + <i class="bi bi-plus-lg"></i> @Localizer["Add Category"]
26 + </a>
27 + }
28 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
29 + <i class="bi bi-arrow-left"></i>
30 + </a>
31 + </div>
32 + </div>
33 +</div>
34 +
35 +<!-- Summary Cards -->
36 +<div class="row g-3 mb-4">
37 + <div class="col-md-4">
38 + <div class="sa-card-static">
39 + <div class="sa-stat">
40 + <div class="sa-stat-icon" style="color: var(--sa-info);"><i class="bi bi-bullseye"></i></div>
41 + <div class="sa-stat-value">@totalPlanned.ToString("N2")</div>
42 + <div class="sa-stat-label">@Localizer["Total Planned"]</div>
43 + </div>
44 + </div>
45 + </div>
46 + <div class="col-md-4">
47 + <div class="sa-card-static">
48 + <div class="sa-stat">
49 + <div class="sa-stat-icon" style="color: @(totalSpent > totalPlanned && totalPlanned > 0 ? "var(--sa-danger)" : "var(--sa-accent)");"><i class="bi bi-credit-card"></i></div>
50 + <div class="sa-stat-value" style="color: @(totalSpent > totalPlanned && totalPlanned > 0 ? "var(--sa-danger)" : "inherit");">@totalSpent.ToString("N2")</div>
51 + <div class="sa-stat-label">@Localizer["Total Spent"]</div>
52 + </div>
53 + </div>
54 + </div>
55 + <div class="col-md-4">
56 + <div class="sa-card-static">
57 + <div class="sa-stat">
58 + <div class="sa-stat-icon" style="color: @(remaining >= 0 ? "var(--sa-success)" : "var(--sa-danger)");"><i class="bi bi-piggy-bank"></i></div>
59 + <div class="sa-stat-value" style="color: @(remaining >= 0 ? "var(--sa-success)" : "var(--sa-danger)");">@remaining.ToString("N2")</div>
60 + <div class="sa-stat-label">@Localizer["Remaining"]</div>
61 + </div>
62 + </div>
63 + </div>
64 +</div>
65 +
66 +<!-- Overall progress -->
67 +@if (totalPlanned > 0)
68 +{
69 + <div class="sa-card-static mb-4">
70 + <div class="sa-card-body">
71 + <div class="d-flex justify-content-between mb-2">
72 + <span style="font-weight: 600; font-size: 0.9rem;">@Localizer["Overall Progress"]</span>
73 + <span class="@(overallPct > 100 ? "text-danger fw-bold" : "sa-text-muted")" style="font-size: 0.85rem;">@overallPct%</span>
74 + </div>
75 + @if (overallPct > 100)
76 + {
77 + <small style="color: var(--sa-danger); font-weight: 600;">@Localizer["Over budget!"]</small>
78 + }
79 + @{
80 + var barClass = overallPct <= 60 ? "sa-progress-bar-success" : overallPct <= 85 ? "sa-progress-bar-warning" : "sa-progress-bar-danger";
81 + }
82 + <div class="sa-progress sa-progress-lg">
83 + <div class="sa-progress-bar @barClass" data-width="@Math.Min(overallPct, 100)%" style="width: 0%;"></div>
84 + </div>
85 + </div>
86 + </div>
87 +}
88 +
89 +@if (!Model.Any())
90 +{
91 + <div class="sa-empty">
92 + <div class="sa-empty-icon"><i class="bi bi-wallet2"></i></div>
93 + <div class="sa-empty-title">@Localizer["No budget categories yet"]</div>
94 + <p class="sa-empty-text">@Localizer["Add categories to track your trip budget."]</p>
95 + <a asp-action="CreateCategory" asp-route-tripId="@tripId" class="sa-btn sa-btn-secondary sa-btn-pill">
96 + <i class="bi bi-plus-lg"></i> @Localizer["Add Category"]
97 + </a>
98 + </div>
99 +}
100 +else
101 +{
102 + <div class="d-flex flex-column gap-3">
103 + @foreach (var category in Model)
104 + {
105 + var catPct = category.ProgressPercentage;
106 + var catBarClass = catPct <= 60 ? "sa-progress-bar-success" : catPct <= 85 ? "sa-progress-bar-warning" : "sa-progress-bar-danger";
107 +
108 + <div class="sa-card-static">
109 + <div class="sa-card-body">
110 + <div class="d-flex justify-content-between align-items-center mb-3">
111 + <div class="d-flex align-items-center gap-3">
112 + <div class="sa-expense-icon sa-category-default">
113 + @if (!string.IsNullOrEmpty(category.IconName))
114 + {
115 + <i class="bi bi-@category.IconName"></i>
116 + }
117 + else
118 + {
119 + <i class="bi bi-tag"></i>
120 + }
121 + </div>
122 + <div>
123 + <div style="font-weight: 700;">@category.Name</div>
124 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">
125 + @category.SpentAmount.ToString("N2") / @category.PlannedAmount.ToString("N2")
126 + </div>
127 + </div>
128 + </div>
129 + @if (isOrganizer)
130 + {
131 + <div class="d-flex gap-2">
132 + <a asp-action="EditCategory" asp-route-id="@category.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Edit"]">
133 + <i class="bi bi-pencil"></i>
134 + </a>
135 + <a asp-action="DeleteCategory" asp-route-id="@category.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Delete"]" style="color: var(--sa-danger);">
136 + <i class="bi bi-trash3"></i>
137 + </a>
138 + </div>
139 + }
140 + </div>
141 + <div class="d-flex justify-content-between mb-1">
142 + <small class="sa-text-muted">@catPct%</small>
143 + @if (catPct > 100)
144 + {
145 + <small style="color: var(--sa-danger); font-weight: 600;">@Localizer["Over budget!"]</small>
146 + }
147 + </div>
148 + <div class="sa-progress">
149 + <div class="sa-progress-bar @catBarClass" data-width="@Math.Min(catPct, 100)%"></div>
150 + </div>
151 + </div>
152 + </div>
153 + }
154 + </div>
155 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Create.cshtml +365 −0
@@ -0,0 +1,365 @@
1 +@model SplitApp.WebApp.Application.DTO.ExpenseBllDto
2 +
3 +
4 +@{
5 + ViewData["Title"] = Localizer["Add Expense"];
6 + var tripId = (Guid)ViewData["TripId"]!;
7 + var participants = (List<SplitApp.WebApp.Application.DTO.TripParticipantBllDto>)ViewData["Participants"]!;
8 + var presets = (List<SplitApp.WebApp.Application.DTO.SplitPresetBllDto>)ViewData["SplitPresets"]!;
9 +}
10 +
11 +<div class="row justify-content-center">
12 + <div class="col-md-8 col-lg-6">
13 + <div class="sa-card-static sa-card-accent sa-card-accent-primary">
14 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
15 + <div class="text-center mb-4">
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Add Expense"]</h1>
17 + <p class="sa-text-muted">@Localizer["Track a group expense"]</p>
18 + </div>
19 +
20 + <form asp-action="Create" data-sa-loading>
21 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
22 + <input type="hidden" asp-for="TripId" />
23 +
24 + <!-- Amount (prominent) -->
25 + <div class="text-center mb-4">
26 + <label asp-for="Amount" class="form-label" style="text-align: center; display: block;">@Localizer["Amount"]</label>
27 + <input asp-for="Amount" type="number" step="0.01" min="0.01"
28 + class="form-control sa-amount-input" id="expenseAmount"
29 + placeholder="0.00" />
30 + <span asp-validation-for="Amount" class="text-danger"></span>
31 + </div>
32 +
33 + <div class="row mb-3">
34 + <div class="col-md-6 mb-3 mb-md-0">
35 + <label asp-for="PaidByUserId" class="form-label">@Localizer["Who Paid"]</label>
36 + <select asp-for="PaidByUserId" asp-items="@((SelectList)ViewData["PaidByUserId"]!)" class="form-select"></select>
37 + <span asp-validation-for="PaidByUserId" class="text-danger"></span>
38 + </div>
39 + <div class="col-md-6">
40 + <label asp-for="ExpenseDate" class="form-label">@Localizer["Date"]</label>
41 + <input asp-for="ExpenseDate" type="date" class="form-control" />
42 + </div>
43 + </div>
44 +
45 + <div class="mb-3">
46 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
47 + <input asp-for="Description" class="form-control" placeholder="@Localizer["e.g. Group dinner, taxi, museum tickets..."]" />
48 + <span asp-validation-for="Description" class="text-danger"></span>
49 + </div>
50 +
51 + <div class="row mb-3">
52 + <div class="col-md-6 mb-3 mb-md-0">
53 + <label asp-for="BudgetCategoryId" class="form-label">@Localizer["Category"]</label>
54 + <select asp-for="BudgetCategoryId" asp-items="@((SelectList)ViewData["BudgetCategoryId"]!)" class="form-select">
55 + <option value="">@Localizer["No category"]</option>
56 + </select>
57 + </div>
58 + <div class="col-md-6">
59 + <label asp-for="CurrencyId" class="form-label">@Localizer["Currency"]</label>
60 + <select asp-for="CurrencyId" asp-items="@((SelectList)ViewData["CurrencyId"]!)" class="form-select">
61 + <option value="">@Localizer["Trip default"]</option>
62 + </select>
63 + </div>
64 + </div>
65 +
66 + <!-- Split Method -->
67 + <div class="mb-3">
68 + <label class="form-label">@Localizer["Split Method"]</label>
69 + <div class="sa-split-methods">
70 + @foreach (var method in Enum.GetValues<ESplitMethod>())
71 + {
72 + var icon = method switch
73 + {
74 + ESplitMethod.EqualAll => "bi-pie-chart-fill",
75 + ESplitMethod.EqualSubset => "bi-pie-chart",
76 + ESplitMethod.ExactAmounts => "bi-hash",
77 + ESplitMethod.Percentages => "bi-percent",
78 + _ => "bi-circle"
79 + };
80 + var label = method switch
81 + {
82 + ESplitMethod.EqualAll => Localizer["Equal (all)"].Value,
83 + ESplitMethod.EqualSubset => Localizer["Equal (subset)"].Value,
84 + ESplitMethod.ExactAmounts => Localizer["Exact amounts"].Value,
85 + ESplitMethod.Percentages => Localizer["Percentages"].Value,
86 + _ => method.ToString()
87 + };
88 + <label class="sa-split-option">
89 + <input type="radio" name="SplitMethod" value="@((int)method)"
90 + @(method == ESplitMethod.EqualAll ? "checked" : "") />
91 + <div class="sa-split-option-label">
92 + <i class="bi @icon sa-split-option-icon"></i>
93 + <span class="sa-split-option-text">@label</span>
94 + </div>
95 + </label>
96 + }
97 + </div>
98 + </div>
99 +
100 + <!-- Split Presets -->
101 + @if (presets.Any())
102 + {
103 + <div id="presetSection" class="mb-3" style="display:none;">
104 + <label class="form-label">@Localizer["Apply Preset"]</label>
105 + <div class="d-flex gap-2">
106 + <select id="presetSelect" class="form-select">
107 + <option value="">@Localizer["Select a preset..."]</option>
108 + @foreach (var preset in presets)
109 + {
110 + <option value="@preset.Id"
111 + data-method="@((int)preset.SplitMethod)"
112 + data-members="@string.Join(",", preset.Members?.Select(m => m.UserId.ToString()) ?? Array.Empty<string>())"
113 + data-percentages="@string.Join(",", preset.Members?.Select(m => m.Percentage?.ToString("0.##") ?? "") ?? Array.Empty<string>())">
114 + @preset.Name (@preset.SplitMethod)
115 + </option>
116 + }
117 + </select>
118 + <button type="button" id="applyPresetBtn" class="sa-btn sa-btn-ghost sa-btn-sm" style="white-space:nowrap;">
119 + <i class="bi bi-lightning"></i> @Localizer["Apply"]
120 + </button>
121 + </div>
122 + </div>
123 + }
124 +
125 + <!-- Participant Selection -->
126 + <div id="participantSection" class="mb-3" style="display:none;">
127 + <label class="form-label">@Localizer["Select Participants"]</label>
128 + <div class="sa-card-static">
129 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
130 + @for (var i = 0; i < participants.Count; i++)
131 + {
132 + var p = participants[i];
133 + var name = $"{p.User!.FirstName} {p.User.LastName}";
134 + var initial = $"{p.User.FirstName?[0]}{p.User.LastName?[0]}";
135 + <div class="d-flex align-items-center gap-3 mb-2 participant-row">
136 + <input type="checkbox" class="form-check-input participant-check"
137 + name="selectedParticipants" value="@p.UserId"
138 + data-index="@i" checked style="flex-shrink:0;" />
139 + <span class="sa-avatar sa-avatar-sm sa-avatar-@((i % 8) + 1)" style="border:none; font-size: 0.65rem;">@initial</span>
140 + <span style="font-weight: 500; flex: 1;">@name</span>
141 + <div class="split-amount-col" style="display:none; width: 100px;">
142 + <input type="number" step="0.01" min="0" class="form-control form-control-sm split-amount"
143 + name="splitAmounts" placeholder="0.00" />
144 + </div>
145 + <div class="split-pct-col" style="display:none; width: 90px;">
146 + <div class="input-group input-group-sm">
147 + <input type="number" step="0.01" min="0" max="100"
148 + class="form-control split-pct" name="splitPercentages" placeholder="0" />
149 + <span class="input-group-text">%</span>
150 + </div>
151 + </div>
152 + <span class="sa-badge sa-badge-neutral split-preview" style="min-width: 60px; text-align: center;"></span>
153 + </div>
154 + }
155 +
156 + <!-- Remaining indicator for exact amounts -->
157 + <div id="splitRemainingRow" style="display:none;" class="d-flex justify-content-end mt-2 pt-2" style="border-top: 1px solid var(--sa-gray-100);">
158 + <span class="sa-text-muted" style="font-size: 0.85rem;">@Localizer["Remaining"]: </span>
159 + <span id="splitRemaining" class="sa-amount ms-2" style="font-size: 0.85rem;">0.00</span>
160 + </div>
161 + <!-- Total indicator for percentages -->
162 + <div id="splitPctTotalRow" style="display:none;" class="d-flex justify-content-end mt-2 pt-2">
163 + <span class="sa-text-muted" style="font-size: 0.85rem;">@Localizer["Total"]: </span>
164 + <span id="splitPctTotal" class="sa-amount ms-2" style="font-size: 0.85rem;">0.0%</span>
165 + </div>
166 + </div>
167 + </div>
168 + </div>
169 +
170 + <div class="d-flex gap-3 mt-4">
171 + <button type="submit" class="sa-btn sa-btn-primary flex-grow-1">
172 + <i class="bi bi-check-lg"></i> @Localizer["Add Expense"]
173 + </button>
174 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
175 + </div>
176 + </form>
177 +
178 + <!-- Save as Preset (collapsible) -->
179 + <div id="savePresetSection" class="mt-3 pt-3" style="display:none; border-top: 1px solid var(--sa-gray-200);">
180 + <form asp-action="SavePreset" method="post">
181 + <input type="hidden" name="tripId" value="@tripId" />
182 + <input type="hidden" name="splitMethod" id="presetSplitMethod" value="0" />
183 + <div id="presetParticipantInputs"></div>
184 + <div id="presetPercentageInputs"></div>
185 + <div class="d-flex gap-2 align-items-end">
186 + <div class="flex-grow-1">
187 + <label class="form-label" style="font-size: 0.85rem;">@Localizer["Save current split as preset"]</label>
188 + <input type="text" name="presetName" class="form-control form-control-sm" placeholder="@Localizer["Preset name, e.g. Hotel group"]" required />
189 + </div>
190 + <button type="submit" class="sa-btn sa-btn-ghost sa-btn-sm">
191 + <i class="bi bi-bookmark-plus"></i> @Localizer["Save"]
192 + </button>
193 + </div>
194 + </form>
195 + </div>
196 + </div>
197 + </div>
198 + </div>
199 +</div>
200 +
201 +@section Scripts {
202 + <partial name="_ValidationScriptsPartial" />
203 + <script>
204 + // Preset: apply selected preset to participant checkboxes
205 + document.getElementById('applyPresetBtn')?.addEventListener('click', function() {
206 + const select = document.getElementById('presetSelect');
207 + const option = select?.selectedOptions[0];
208 + if (!option || !option.value) return;
209 +
210 + const method = option.dataset.method;
211 + const memberIds = option.dataset.members.split(',').filter(m => m);
212 + const percentages = option.dataset.percentages.split(',').filter(p => p);
213 +
214 + // Set split method
215 + const radio = document.querySelector(`input[name="SplitMethod"][value="${method}"]`);
216 + if (radio) { radio.checked = true; onSplitMethodChange(); }
217 +
218 + // Uncheck all, then check only preset members
219 + document.querySelectorAll('.participant-check').forEach(c => c.checked = false);
220 + memberIds.forEach((id, idx) => {
221 + const check = document.querySelector(`.participant-check[value="${id}"]`);
222 + if (check) {
223 + check.checked = true;
224 + // Set percentage if available
225 + if (method === '3' && percentages[idx]) {
226 + const row = check.closest('.participant-row');
227 + const pctInput = row?.querySelector('.split-pct');
228 + if (pctInput) pctInput.value = percentages[idx];
229 + }
230 + }
231 + });
232 + updatePreview();
233 + });
234 +
235 + // Save Preset: sync form hidden fields before submit
236 + function updateSavePresetForm() {
237 + const method = document.querySelector('input[name="SplitMethod"]:checked')?.value || '0';
238 + document.getElementById('presetSplitMethod').value = method;
239 +
240 + const container = document.getElementById('presetParticipantInputs');
241 + const pctContainer = document.getElementById('presetPercentageInputs');
242 + container.innerHTML = '';
243 + pctContainer.innerHTML = '';
244 +
245 + const checks = document.querySelectorAll('.participant-check:checked');
246 + checks.forEach(c => {
247 + container.innerHTML += `<input type="hidden" name="selectedParticipants" value="${c.value}" />`;
248 + const row = c.closest('.participant-row');
249 + const pct = row?.querySelector('.split-pct')?.value || '';
250 + pctContainer.innerHTML += `<input type="hidden" name="splitPercentages" value="${pct}" />`;
251 + });
252 + }
253 +
254 + // Show/hide preset sections based on split method
255 + function updatePresetVisibility() {
256 + const method = document.querySelector('input[name="SplitMethod"]:checked')?.value;
257 + const presetSection = document.getElementById('presetSection');
258 + const saveSection = document.getElementById('savePresetSection');
259 + if (presetSection) presetSection.style.display = method !== '0' ? 'block' : 'none';
260 + if (saveSection) saveSection.style.display = method !== '0' ? 'block' : 'none';
261 + updateSavePresetForm();
262 + }
263 +
264 + document.querySelector('#savePresetSection form')?.addEventListener('submit', updateSavePresetForm);
265 +
266 + function onSplitMethodChange() {
267 + const method = document.querySelector('input[name="SplitMethod"]:checked')?.value;
268 + const section = document.getElementById('participantSection');
269 + const amountCols = document.querySelectorAll('.split-amount-col');
270 + const pctCols = document.querySelectorAll('.split-pct-col');
271 + const checks = document.querySelectorAll('.participant-check');
272 + const remainingRow = document.getElementById('splitRemainingRow');
273 + const pctTotalRow = document.getElementById('splitPctTotalRow');
274 +
275 + if (method === '0') {
276 + section.style.display = 'none';
277 + } else {
278 + section.style.display = 'block';
279 + }
280 +
281 + amountCols.forEach(c => c.style.display = method === '2' ? 'block' : 'none');
282 + pctCols.forEach(c => c.style.display = method === '3' ? 'block' : 'none');
283 + if (remainingRow) remainingRow.style.display = method === '2' ? 'flex' : 'none';
284 + if (pctTotalRow) pctTotalRow.style.display = method === '3' ? 'flex' : 'none';
285 +
286 + if (method === '2' || method === '3') {
287 + checks.forEach(c => c.checked = true);
288 + }
289 +
290 + updatePreview();
291 + updatePresetVisibility();
292 + }
293 +
294 + function updatePreview() {
295 + const method = document.querySelector('input[name="SplitMethod"]:checked')?.value;
296 + const amount = parseFloat(document.getElementById('expenseAmount')?.value) || 0;
297 + const checks = document.querySelectorAll('.participant-check:checked');
298 + const allChecks = document.querySelectorAll('.participant-check');
299 + const previews = document.querySelectorAll('.split-preview');
300 +
301 + previews.forEach(p => { p.textContent = ''; p.className = 'sa-badge sa-badge-neutral split-preview'; });
302 +
303 + if (method === '0') {
304 + if (allChecks.length > 0 && amount > 0) {
305 + const each = (amount / allChecks.length).toFixed(2);
306 + allChecks.forEach(c => {
307 + const idx = c.dataset.index;
308 + if (previews[idx]) {
309 + previews[idx].textContent = '€' + each;
310 + previews[idx].className = 'sa-badge sa-badge-secondary split-preview';
311 + }
312 + });
313 + }
314 + return;
315 + }
316 +
317 + if (method === '1') {
318 + if (checks.length > 0 && amount > 0) {
319 + const each = (amount / checks.length).toFixed(2);
320 + checks.forEach(c => {
321 + const idx = c.dataset.index;
322 + if (previews[idx]) {
323 + previews[idx].textContent = '€' + each;
324 + previews[idx].className = 'sa-badge sa-badge-secondary split-preview';
325 + }
326 + });
327 + }
328 + }
329 +
330 + if (method === '2') {
331 + const amountInputs = document.querySelectorAll('.split-amount');
332 + let total = 0;
333 + amountInputs.forEach(input => { total += parseFloat(input.value) || 0; });
334 + const remaining = amount - total;
335 + const indicator = document.getElementById('splitRemaining');
336 + if (indicator) {
337 + indicator.textContent = remaining.toFixed(2);
338 + indicator.className = Math.abs(remaining) < 0.01
339 + ? 'sa-amount sa-amount-positive ms-2'
340 + : 'sa-amount sa-amount-negative ms-2';
341 + }
342 + }
343 +
344 + if (method === '3') {
345 + const pctInputs = document.querySelectorAll('.split-pct');
346 + let totalPct = 0;
347 + pctInputs.forEach(input => { totalPct += parseFloat(input.value) || 0; });
348 + const indicator = document.getElementById('splitPctTotal');
349 + if (indicator) {
350 + indicator.textContent = totalPct.toFixed(1) + '%';
351 + indicator.className = Math.abs(totalPct - 100) < 0.1
352 + ? 'sa-amount sa-amount-positive ms-2'
353 + : 'sa-amount sa-amount-negative ms-2';
354 + }
355 + }
356 + }
357 +
358 + document.querySelectorAll('input[name="SplitMethod"]').forEach(r => r.addEventListener('change', onSplitMethodChange));
359 + document.getElementById('expenseAmount')?.addEventListener('input', updatePreview);
360 + document.querySelectorAll('.participant-check').forEach(c => c.addEventListener('change', updatePreview));
361 + document.querySelectorAll('.split-amount, .split-pct').forEach(input => input.addEventListener('input', updatePreview));
362 +
363 + onSplitMethodChange();
364 + </script>
365 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Delete.cshtml +51 −0
@@ -0,0 +1,51 @@
1 +@model SplitApp.WebApp.Application.DTO.ExpenseBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Delete Expense"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="sa-invite-page">
9 + <div class="sa-card-static sa-confirm-card sa-card-accent sa-card-accent-danger">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center">
12 + <div class="sa-confirm-icon sa-confirm-icon-danger">
13 + <i class="bi bi-exclamation-triangle-fill"></i>
14 + </div>
15 + <h2 style="font-size: 1.5rem; margin-bottom: var(--sa-space-2);">@Localizer["Delete Expense"]</h2>
16 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
17 + @Localizer["Are you sure you want to delete this expense? This action cannot be undone."]
18 + </p>
19 + </div>
20 +
21 + <div class="sa-card-static" style="margin-bottom: var(--sa-space-6);">
22 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
23 + <div class="d-flex justify-content-between align-items-start mb-2">
24 + <strong>@(Model.Description ?? "-")</strong>
25 + <span class="sa-amount" style="font-size: 1.1rem;">@Model.Amount.ToString("N2")
26 + @if (Model.Currency != null) { <small class="sa-text-muted">@Model.Currency.Code</small> }
27 + </span>
28 + </div>
29 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">
30 + <i class="bi bi-calendar-event me-1"></i> @Model.ExpenseDate.ToString("MMM dd, yyyy")
31 + &middot; @Localizer["Paid by"] @(Model.PaidByUser != null ? $"{Model.PaidByUser.FirstName} {Model.PaidByUser.LastName}" : "?")
32 + @if (Model.BudgetCategory != null)
33 + {
34 + <span>&middot; @Model.BudgetCategory.Name</span>
35 + }
36 + </div>
37 + </div>
38 + </div>
39 +
40 + <form asp-action="Delete">
41 + <input type="hidden" asp-for="Id" />
42 + <div class="d-flex gap-3">
43 + <button type="submit" class="sa-btn sa-btn-danger flex-grow-1">
44 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
45 + </button>
46 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
47 + </div>
48 + </form>
49 + </div>
50 + </div>
51 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Edit.cshtml +84 −0
@@ -0,0 +1,84 @@
1 +@model SplitApp.WebApp.Application.DTO.ExpenseBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Edit Expense"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-primary">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-primary); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-pencil-square"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Edit Expense"]</h1>
17 + </div>
18 +
19 + <form asp-action="Edit" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="Id" />
22 + <input type="hidden" asp-for="TripId" />
23 +
24 + <!-- Amount -->
25 + <div class="text-center mb-4">
26 + <label asp-for="Amount" class="form-label" style="text-align: center; display: block;">@Localizer["Amount"]</label>
27 + <input asp-for="Amount" type="number" step="0.01" min="0.01"
28 + class="form-control sa-amount-input" placeholder="0.00" />
29 + <span asp-validation-for="Amount" class="text-danger"></span>
30 + </div>
31 +
32 + <div class="row mb-3">
33 + <div class="col-md-6 mb-3 mb-md-0">
34 + <label asp-for="PaidByUserId" class="form-label">@Localizer["Who Paid"]</label>
35 + <select asp-for="PaidByUserId" asp-items="@((SelectList)ViewData["PaidByUserId"]!)" class="form-select"></select>
36 + <span asp-validation-for="PaidByUserId" class="text-danger"></span>
37 + </div>
38 + <div class="col-md-6">
39 + <label asp-for="ExpenseDate" class="form-label">@Localizer["Date"]</label>
40 + <input asp-for="ExpenseDate" type="date" class="form-control" />
41 + </div>
42 + </div>
43 +
44 + <div class="mb-3">
45 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
46 + <input asp-for="Description" class="form-control" />
47 + <span asp-validation-for="Description" class="text-danger"></span>
48 + </div>
49 +
50 + <div class="row mb-3">
51 + <div class="col-md-6 mb-3 mb-md-0">
52 + <label asp-for="BudgetCategoryId" class="form-label">@Localizer["Category"]</label>
53 + <select asp-for="BudgetCategoryId" asp-items="@((SelectList)ViewData["BudgetCategoryId"]!)" class="form-select">
54 + <option value="">@Localizer["No category"]</option>
55 + </select>
56 + </div>
57 + <div class="col-md-6">
58 + <label asp-for="CurrencyId" class="form-label">@Localizer["Currency"]</label>
59 + <select asp-for="CurrencyId" asp-items="@((SelectList)ViewData["CurrencyId"]!)" class="form-select">
60 + <option value="">@Localizer["Trip default"]</option>
61 + </select>
62 + </div>
63 + </div>
64 +
65 + <div class="mb-4">
66 + <label asp-for="SplitMethod" class="form-label">@Localizer["Split Method"]</label>
67 + <select asp-for="SplitMethod" asp-items="@((SelectList)ViewData["SplitMethods"]!)" class="form-select"></select>
68 + </div>
69 +
70 + <div class="d-flex gap-3">
71 + <button type="submit" class="sa-btn sa-btn-primary flex-grow-1">
72 + <i class="bi bi-check-lg"></i> @Localizer["Save"]
73 + </button>
74 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
75 + </div>
76 + </form>
77 + </div>
78 + </div>
79 + </div>
80 +</div>
81 +
82 +@section Scripts {
83 + <partial name="_ValidationScriptsPartial" />
84 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Index.cshtml +111 −0
@@ -0,0 +1,111 @@
1 +@model SplitApp.WebApp.Controllers.ExpensesIndexViewModel
2 +@using SplitApp.WebApp.Hosting.Helpers
3 +
4 +@{
5 + ViewData["Title"] = Localizer["Expenses"];
6 +}
7 +
8 +<!-- Page Header -->
9 +<div class="sa-gradient-header sa-gradient-header-coral">
10 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
11 + <div>
12 + <h1 style="margin-bottom: 4px;">@Localizer["Expenses"]</h1>
13 + <p class="text-muted mb-0">@Model.TripName</p>
14 + </div>
15 + <div class="d-flex gap-2">
16 + @if (Model.TripStatus == "Active")
17 + {
18 + <a asp-action="Create" asp-route-tripId="@Model.TripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
19 + <i class="bi bi-plus-lg"></i> @Localizer["Add Expense"]
20 + </a>
21 + }
22 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@Model.TripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
23 + <i class="bi bi-arrow-left"></i>
24 + </a>
25 + </div>
26 + </div>
27 + @if (Model.Expenses.Any())
28 + {
29 + <div class="mt-3" style="font-size: 1.8rem; font-weight: 800; letter-spacing: -0.02em;">
30 + @Model.Expenses.Sum(e => CurrencyConverter.Convert(e.Amount, e.Currency?.Code ?? Model.DefaultCurrencyCode, Model.DefaultCurrencyCode)).ToString("N2")
31 + <span style="font-size: 0.9rem; opacity: 0.7; font-weight: 500; margin-left: 4px;">@Model.CurrencySymbol @Localizer["total"]</span>
32 + </div>
33 + }
34 +</div>
35 +
36 +@if (!Model.Expenses.Any())
37 +{
38 + <div class="sa-empty">
39 + <div class="sa-empty-icon"><i class="bi bi-receipt"></i></div>
40 + <div class="sa-empty-title">@Localizer["No expenses yet"]</div>
41 + <p class="sa-empty-text">@Localizer["Add your first expense to start tracking."]</p>
42 + @if (Model.TripStatus == "Active")
43 + {
44 + <a asp-action="Create" asp-route-tripId="@Model.TripId" class="sa-btn sa-btn-primary sa-btn-pill">
45 + <i class="bi bi-plus-lg"></i> @Localizer["Add Expense"]
46 + </a>
47 + }
48 + </div>
49 +}
50 +else
51 +{
52 + <div class="sa-card-static">
53 + @foreach (var expense in Model.Expenses)
54 + {
55 + var isDifferentCurrency = expense.Currency != null && expense.Currency.Code != Model.DefaultCurrencyCode;
56 + <div class="sa-expense-item">
57 + <div class="sa-expense-icon @(expense.BudgetCategory != null ? "sa-category-default" : "sa-category-default")">
58 + <i class="bi bi-receipt"></i>
59 + </div>
60 + <div class="sa-expense-details">
61 + <div class="sa-expense-desc">@(expense.Description ?? Localizer["No description"].Value)</div>
62 + <div class="sa-expense-meta">
63 + <span>@(expense.PaidByUser != null ? $"{expense.PaidByUser.FirstName} {expense.PaidByUser.LastName}" : "?")</span>
64 + &middot; @expense.ExpenseDate.ToString("MMM dd, yyyy")
65 + @if (expense.BudgetCategory != null)
66 + {
67 + <span>&middot;</span>
68 + <span class="sa-badge sa-badge-secondary" style="font-size: 0.65rem; padding: 1px 6px;">@expense.BudgetCategory.Name</span>
69 + }
70 + </div>
71 + </div>
72 + <div class="d-flex align-items-center gap-3">
73 + <div class="text-end">
74 + @if (isDifferentCurrency)
75 + {
76 + <div class="sa-expense-amount">@(CurrencyConverter.Convert(expense.Amount, expense.Currency!.Code, Model.DefaultCurrencyCode).ToString("N2"))</div>
77 + <div style="font-size: 0.7rem; color: var(--sa-gray-400);">@expense.Amount.ToString("N2") @expense.Currency.Code</div>
78 + }
79 + else
80 + {
81 + <div class="sa-expense-amount">@expense.Amount.ToString("N2")</div>
82 + @if (expense.Currency != null)
83 + {
84 + <div style="font-size: 0.75rem; color: var(--sa-gray-400);">@expense.Currency.Code</div>
85 + }
86 + }
87 + </div>
88 + @if (Model.TripStatus == "Active")
89 + {
90 + <div class="sa-expense-actions">
91 + <a asp-action="Edit" asp-route-id="@expense.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Edit"]">
92 + <i class="bi bi-pencil"></i>
93 + </a>
94 + <a asp-action="Delete" asp-route-id="@expense.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Delete"]" style="color: var(--sa-danger);">
95 + <i class="bi bi-trash3"></i>
96 + </a>
97 + </div>
98 + }
99 + </div>
100 + </div>
101 + }
102 + </div>
103 +}
104 +
105 +@if (Model.TripStatus == "Active")
106 +{
107 + <!-- Mobile FAB -->
108 + <a asp-action="Create" asp-route-tripId="@Model.TripId" class="sa-fab sa-hide-desktop" title="@Localizer["Add Expense"]">
109 + <i class="bi bi-plus-lg"></i>
110 + </a>
111 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Index.cshtml +116 −0
@@ -0,0 +1,116 @@
1 +@{
2 + ViewData["Title"] = Localizer["Home"].Value;
3 +}
4 +
5 +<!-- Hero Section -->
6 +<div class="sa-hero">
7 + <h1>@Localizer["Split expenses."] <br />@Localizer["Not friendships."] ✈️</h1>
8 + <p>@Localizer["Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place."]</p>
9 + <div class="sa-hero-btns">
10 + @if (!User.Identity!.IsAuthenticated)
11 + {
12 + <a asp-area="Identity" asp-page="/Account/Register" class="sa-hero-btn-white">
13 + <i class="bi bi-rocket-takeoff"></i> @Localizer["Get Started"]
14 + </a>
15 + <a href="#features" class="sa-hero-btn-outline">
16 + @Localizer["Learn More"] <i class="bi bi-arrow-down"></i>
17 + </a>
18 + }
19 + else
20 + {
21 + <a asp-controller="Trips" asp-action="Index" class="sa-hero-btn-white">
22 + <i class="bi bi-luggage"></i> @Localizer["My Trips"]
23 + </a>
24 + }
25 + </div>
26 +</div>
27 +
28 +<!-- Features Section -->
29 +<section id="features" style="padding: var(--sa-space-12) 0;">
30 + <div class="text-center mb-5">
31 + <h2 style="font-size: 1.75rem;">@Localizer["Everything you need for group travel"]</h2>
32 + <p class="sa-text-muted" style="max-width: 500px; margin: var(--sa-space-3) auto 0;">
33 + @Localizer["From splitting dinner bills to planning activities, SplitApp handles it all."]
34 + </p>
35 + </div>
36 +
37 + <div class="row g-4">
38 + <div class="col-md-4 sa-animate-on-scroll">
39 + <div class="sa-card-static">
40 + <div class="sa-feature-card">
41 + <div class="sa-feature-icon sa-nav-icon-expenses">
42 + <i class="bi bi-receipt-cutoff"></i>
43 + </div>
44 + <h3 class="sa-feature-title">@Localizer["Split Any Way"]</h3>
45 + <p class="sa-feature-text">
46 + @Localizer["Split equally, by exact amounts, or percentages. Save presets for recurring groups."]
47 + </p>
48 + </div>
49 + </div>
50 + </div>
51 + <div class="col-md-4 sa-animate-on-scroll sa-stagger-2">
52 + <div class="sa-card-static">
53 + <div class="sa-feature-card">
54 + <div class="sa-feature-icon sa-nav-icon-polls">
55 + <i class="bi bi-bar-chart-fill"></i>
56 + </div>
57 + <h3 class="sa-feature-title">@Localizer["Group Decisions"]</h3>
58 + <p class="sa-feature-text">
59 + @Localizer["Create polls, build wishlists, and vote together to make group planning effortless."]
60 + </p>
61 + </div>
62 + </div>
63 + </div>
64 + <div class="col-md-4 sa-animate-on-scroll sa-stagger-4">
65 + <div class="sa-card-static">
66 + <div class="sa-feature-card">
67 + <div class="sa-feature-icon sa-nav-icon-settlement">
68 + <i class="bi bi-check2-circle"></i>
69 + </div>
70 + <h3 class="sa-feature-title">@Localizer["Settle Up"]</h3>
71 + <p class="sa-feature-text">
72 + @Localizer["Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification."]
73 + </p>
74 + </div>
75 + </div>
76 + </div>
77 + </div>
78 +</section>
79 +
80 +<!-- How It Works -->
81 +<section style="padding: var(--sa-space-12) 0 var(--sa-space-16);">
82 + <div class="text-center mb-5">
83 + <h2 style="font-size: 1.75rem;">@Localizer["How it works"]</h2>
84 + </div>
85 + <div class="sa-steps">
86 + <div class="sa-step sa-animate-on-scroll">
87 + <div class="sa-step-number">1</div>
88 + <h3 class="sa-step-title">@Localizer["Create a Trip"]</h3>
89 + <p class="sa-step-text">@Localizer["Set up your trip and invite friends with a simple shareable link."]</p>
90 + </div>
91 + <div class="sa-step sa-animate-on-scroll sa-stagger-2">
92 + <div class="sa-step-number">2</div>
93 + <h3 class="sa-step-title">@Localizer["Track & Split"]</h3>
94 + <p class="sa-step-text">@Localizer["Add expenses as you go. Choose how to split — we handle the math."]</p>
95 + </div>
96 + <div class="sa-step sa-animate-on-scroll sa-stagger-4">
97 + <div class="sa-step-number">3</div>
98 + <h3 class="sa-step-title">@Localizer["Settle Up"]</h3>
99 + <p class="sa-step-text">@Localizer["See who owes whom and settle with minimal payments."]</p>
100 + </div>
101 + </div>
102 +</section>
103 +
104 +<!-- Bottom CTA -->
105 +@if (!User.Identity!.IsAuthenticated)
106 +{
107 + <section class="text-center" style="padding: var(--sa-space-10) 0 var(--sa-space-6);">
108 + <div class="sa-card-static" style="background: var(--sa-secondary-gradient); color: #fff; padding: var(--sa-space-10) var(--sa-space-6); border-radius: var(--sa-radius-lg);">
109 + <h2 style="color: #fff; margin-bottom: var(--sa-space-3);">@Localizer["Ready to plan your next trip?"]</h2>
110 + <p style="opacity: 0.9; margin-bottom: var(--sa-space-6);">@Localizer["Join thousands of travelers who split smarter."]</p>
111 + <a asp-area="Identity" asp-page="/Account/Register" class="sa-hero-btn-white">
112 + <i class="bi bi-person-plus"></i> @Localizer["Create Free Account"]
113 + </a>
114 + </div>
115 + </section>
116 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Privacy.cshtml +6 −0
@@ -0,0 +1,6 @@
1 +@{
2 + ViewData["Title"] = Localizer["Privacy Policy"].Value;
3 +}
4 +<h1>@ViewData["Title"]</h1>
5 +
6 +<p>@Localizer["Use this page to detail your site's privacy policy."]</p>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/AcceptInvitation.cshtml +60 −0
@@ -0,0 +1,60 @@
1 +@model SplitApp.WebApp.Application.DTO.TripInvitationBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Accept Invitation"];
5 + var token = (string)ViewData["Token"]!;
6 +}
7 +
8 +<div class="sa-invite-page">
9 + <div class="sa-card-static sa-invite-card sa-animate-scale-in">
10 + <div style="height: 6px; background: var(--sa-secondary-gradient);"></div>
11 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
12 + <div class="sa-invite-icon" style="color: var(--sa-secondary);">
13 + <i class="bi bi-envelope-paper-heart-fill"></i>
14 + </div>
15 +
16 + <h2 style="margin-bottom: var(--sa-space-4);">@Localizer["You've Been Invited!"]</h2>
17 +
18 + @if (Model.Trip != null)
19 + {
20 + <div class="sa-card-static" style="margin-bottom: var(--sa-space-6);">
21 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
22 + <h4 style="margin-bottom: var(--sa-space-2); font-weight: 700;">@Model.Trip.Name</h4>
23 + @if (!string.IsNullOrEmpty(Model.Trip.Destination))
24 + {
25 + <div style="color: var(--sa-gray-600); font-size: 0.9rem; margin-bottom: 4px;">
26 + <i class="bi bi-geo-alt-fill me-1" style="color: var(--sa-primary);"></i> @Model.Trip.Destination
27 + </div>
28 + }
29 + @if (Model.Trip.StartDate.HasValue || Model.Trip.EndDate.HasValue)
30 + {
31 + <div style="color: var(--sa-gray-500); font-size: 0.85rem;">
32 + <i class="bi bi-calendar-event me-1"></i>
33 + @(Model.Trip.StartDate?.ToString("MMM dd, yyyy") ?? "?") — @(Model.Trip.EndDate?.ToString("MMM dd, yyyy") ?? "?")
34 + </div>
35 + }
36 + </div>
37 + </div>
38 + }
39 +
40 + @if (Model.InvitedByUser != null)
41 + {
42 + <p style="color: var(--sa-gray-600); font-size: 0.9rem; margin-bottom: var(--sa-space-4);">
43 + <i class="bi bi-person-fill me-1"></i>
44 + @Localizer["Invited by"] @Model.InvitedByUser.FirstName @Model.InvitedByUser.LastName
45 + </p>
46 + }
47 +
48 + <form asp-action="AcceptInvitation" asp-route-token="@token" method="post">
49 + <input type="hidden" name="_" value="1" />
50 + <button type="submit" class="sa-btn sa-btn-success sa-btn-lg" style="width: 100%;">
51 + <i class="bi bi-check-circle"></i> @Localizer["Accept Invitation"]
52 + </button>
53 + </form>
54 +
55 + <p style="font-size: 0.8rem; color: var(--sa-gray-400); margin-top: var(--sa-space-4);">
56 + @Localizer["By accepting, you will join this trip as a participant."]
57 + </p>
58 + </div>
59 + </div>
60 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Index.cshtml +125 −0
@@ -0,0 +1,125 @@
1 +@model List<SplitApp.WebApp.Application.DTO.TripParticipantBllDto>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Members"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var tripName = (string)ViewData["TripName"]!;
7 + var currentUserId = (Guid)ViewData["CurrentUserId"]!;
8 + var isOrganizer = (bool)ViewData["IsOrganizer"]!;
9 + var pendingInvitations = ViewData["PendingInvitations"] as List<SplitApp.WebApp.Application.DTO.TripInvitationBllDto> ?? new();
10 +}
11 +
12 +<!-- Page Header -->
13 +<div class="sa-gradient-header" style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);">
14 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
15 + <div>
16 + <h1 style="margin-bottom: 4px;">@Localizer["Members"]</h1>
17 + <p class="text-muted mb-0">@tripName &middot; @Model.Count @Localizer["member(s)"]</p>
18 + </div>
19 + <div class="d-flex gap-2">
20 + @if (isOrganizer)
21 + {
22 + <a asp-action="Invite" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
23 + <i class="bi bi-person-plus-fill"></i> @Localizer["Invite"]
24 + </a>
25 + }
26 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
27 + <i class="bi bi-arrow-left"></i>
28 + </a>
29 + </div>
30 + </div>
31 +</div>
32 +
33 +<!-- Members List -->
34 +<div class="sa-card-static mb-4">
35 + <div class="sa-card-header">
36 + <i class="bi bi-people-fill me-2"></i>@Localizer["Current Members"]
37 + </div>
38 + <div>
39 + @{ var pIdx = 0; }
40 + @foreach (var participant in Model)
41 + {
42 + var name = participant.User != null ? $"{participant.User.FirstName} {participant.User.LastName}" : Localizer["Unknown"].Value;
43 + var initial = participant.User != null ? $"{participant.User.FirstName?[0]}{participant.User.LastName?[0]}" : "?";
44 + var email = participant.User?.Email ?? "-";
45 +
46 + <div class="d-flex align-items-center gap-3 px-4 py-3" style="border-bottom: 1px solid var(--sa-gray-100);">
47 + <span class="sa-avatar sa-avatar-@((pIdx % 8) + 1)" style="border: none;">@initial</span>
48 + <div style="flex: 1; min-width: 0;">
49 + <div style="font-weight: 600;">
50 + @name
51 + @if (!string.IsNullOrEmpty(participant.Nickname))
52 + {
53 + <small class="sa-text-muted">(@participant.Nickname)</small>
54 + }
55 + </div>
56 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">@email</div>
57 + </div>
58 + <div class="d-flex align-items-center gap-2">
59 + @if (participant.Role == EParticipantRole.Organizer)
60 + {
61 + <span class="sa-badge sa-badge-accent">
62 + <i class="bi bi-star-fill" style="font-size: 0.6rem;"></i> @Localizer["Organizer"]
63 + </span>
64 + }
65 + else
66 + {
67 + <span class="sa-badge sa-badge-neutral">@Localizer["Participant"]</span>
68 + }
69 + <span class="sa-text-muted sa-hide-mobile" style="font-size: 0.8rem;">
70 + @participant.JoinedAt.ToString("MMM dd, yyyy")
71 + </span>
72 + @if (isOrganizer && participant.UserId != currentUserId && participant.Role != EParticipantRole.Organizer)
73 + {
74 + <form asp-action="Remove" method="post" style="display:inline"
75 + onsubmit="return confirm('@Localizer["Are you sure you want to remove this member?"]');">
76 + <input type="hidden" name="tripId" value="@tripId" />
77 + <input type="hidden" name="participantId" value="@participant.Id" />
78 + <button type="submit" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Remove"]" style="color: var(--sa-danger);">
79 + <i class="bi bi-x-lg"></i>
80 + </button>
81 + </form>
82 + }
83 + </div>
84 + </div>
85 + pIdx++;
86 + }
87 + </div>
88 +</div>
89 +
90 +<!-- Pending Invitations -->
91 +@if (isOrganizer && pendingInvitations.Any())
92 +{
93 + <div class="sa-card-static">
94 + <div class="sa-card-header">
95 + <i class="bi bi-hourglass-split me-2"></i>@Localizer["Pending Invitations"]
96 + </div>
97 + <div>
98 + @foreach (var inv in pendingInvitations)
99 + {
100 + <div class="d-flex align-items-center gap-3 px-4 py-3" style="border-bottom: 1px solid var(--sa-gray-100);">
101 + <div class="sa-expense-icon" style="background: var(--sa-info-light); color: var(--sa-info);">
102 + <i class="bi bi-envelope-paper"></i>
103 + </div>
104 + <div style="flex: 1;">
105 + <div style="font-weight: 500; font-size: 0.9rem;">
106 + @Localizer["Invited by"] @(inv.InvitedByUser != null ? $"{inv.InvitedByUser.FirstName} {inv.InvitedByUser.LastName}" : "?")
107 + </div>
108 + <div style="font-size: 0.8rem; color: var(--sa-gray-500);">
109 + @Localizer["Expires"] @inv.ExpiresAt.ToString("MMM dd, yyyy HH:mm")
110 + </div>
111 + </div>
112 + <span class="sa-badge sa-badge-info">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(inv.Status)</span>
113 + <form asp-action="RevokeInvitation" method="post" style="display:inline;">
114 + <input type="hidden" name="id" value="@inv.Id" />
115 + <input type="hidden" name="tripId" value="@tripId" />
116 + <button type="submit" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Revoke"]" style="color: var(--sa-danger);"
117 + onclick="return confirm('@Localizer["AreYouSure"]');">
118 + <i class="bi bi-x-circle"></i>
119 + </button>
120 + </form>
121 + </div>
122 + }
123 + </div>
124 + </div>
125 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InvitationInvalid.cshtml +24 −0
@@ -0,0 +1,24 @@
1 +
2 +@{
3 + ViewData["Title"] = Localizer["Invalid Invitation"];
4 + var error = ViewData["Error"] as string;
5 +}
6 +
7 +<div class="sa-invite-page">
8 + <div class="sa-card-static sa-invite-card sa-card-accent sa-card-accent-danger">
9 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
10 + <div class="sa-confirm-icon sa-confirm-icon-danger" style="margin: 0 auto var(--sa-space-4);">
11 + <i class="bi bi-x-lg"></i>
12 + </div>
13 +
14 + <h2 style="margin-bottom: var(--sa-space-3);">@Localizer["Invalid Invitation"]</h2>
15 + <p style="color: var(--sa-gray-600); margin-bottom: var(--sa-space-6);">
16 + @(error ?? Localizer["This invitation is no longer valid."].Value)
17 + </p>
18 +
19 + <a asp-controller="Trips" asp-action="Index" class="sa-btn sa-btn-primary sa-btn-pill">
20 + <i class="bi bi-luggage me-1"></i> @Localizer["Go to My Trips"]
21 + </a>
22 + </div>
23 + </div>
24 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Invite.cshtml +34 −0
@@ -0,0 +1,34 @@
1 +
2 +@{
3 + ViewData["Title"] = Localizer["Invite Member"];
4 + var tripId = (Guid)ViewData["TripId"]!;
5 + var tripName = (string)ViewData["TripName"]!;
6 +}
7 +
8 +<div class="sa-invite-page">
9 + <div class="sa-card-static sa-invite-card">
10 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
11 + <div class="sa-invite-icon" style="color: var(--sa-secondary);">
12 + <i class="bi bi-send-fill"></i>
13 + </div>
14 + <h2 style="margin-bottom: var(--sa-space-2);">@Localizer["Invite Member"]</h2>
15 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-2);">@tripName</p>
16 + <p style="color: var(--sa-gray-600); font-size: 0.938rem; margin-bottom: var(--sa-space-6); line-height: 1.6;">
17 + @Localizer["Generate an invitation link that you can share with someone to join this trip."]
18 + <br /><small class="sa-text-muted">@Localizer["The invitation link will expire after 7 days."]</small>
19 + </p>
20 +
21 + <form asp-action="Invite" asp-route-tripId="@tripId">
22 + <input type="hidden" name="_" value="1" />
23 + <div class="d-flex flex-column gap-3">
24 + <button type="submit" class="sa-btn sa-btn-secondary sa-btn-lg" style="width: 100%;">
25 + <i class="bi bi-link-45deg"></i> @Localizer["Generate Invitation Link"]
26 + </button>
27 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost" style="width: 100%;">
28 + @Localizer["Cancel"]
29 + </a>
30 + </div>
31 + </form>
32 + </div>
33 + </div>
34 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InviteGenerated.cshtml +40 −0
@@ -0,0 +1,40 @@
1 +
2 +@{
3 + ViewData["Title"] = Localizer["Invitation Generated"];
4 + var tripId = (Guid)ViewData["TripId"]!;
5 + var tripName = (string)ViewData["TripName"]!;
6 + var inviteUrl = (string)ViewData["InviteUrl"]!;
7 +}
8 +
9 +<div class="sa-invite-page">
10 + <div class="sa-card-static sa-invite-card">
11 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
12 + <div class="sa-animate-bounce-in" style="margin-bottom: var(--sa-space-4);">
13 + <div class="sa-confirm-icon sa-confirm-icon-success" style="margin: 0 auto;">
14 + <i class="bi bi-check-lg"></i>
15 + </div>
16 + </div>
17 +
18 + <h2 style="margin-bottom: var(--sa-space-2);">@Localizer["Link Generated!"]</h2>
19 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
20 + @Localizer["Share this link with the person you want to invite:"]
21 + </p>
22 +
23 + <div class="sa-copy-group mb-3">
24 + <input type="text" class="form-control sa-copy-input" id="inviteLink" value="@inviteUrl" readonly />
25 + <button class="sa-btn sa-btn-secondary sa-copy-btn" type="button"
26 + onclick="SplitApp.copyToClipboard(document.getElementById('inviteLink').value, this)">
27 + <i class="bi bi-clipboard"></i> @Localizer["Copy"]
28 + </button>
29 + </div>
30 +
31 + <p style="font-size: 0.85rem; color: var(--sa-gray-500); margin-bottom: var(--sa-space-6);">
32 + <i class="bi bi-clock me-1"></i> @Localizer["This link will expire in 7 days."]
33 + </p>
34 +
35 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost" style="width: 100%;">
36 + <i class="bi bi-arrow-left me-1"></i> @Localizer["Back to Members"]
37 + </a>
38 + </div>
39 + </div>
40 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Create.cshtml +83 −0
@@ -0,0 +1,83 @@
1 +
2 +@{
3 + ViewData["Title"] = Localizer["Create Poll"];
4 + var tripId = (Guid)ViewData["TripId"]!;
5 +}
6 +
7 +<div class="row justify-content-center">
8 + <div class="col-md-8 col-lg-6">
9 + <div class="sa-card-static sa-card-accent" style="border-top-color: #6366f1;">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center mb-4">
12 + <div style="font-size: 2rem; color: #6366f1; margin-bottom: var(--sa-space-2);">
13 + <i class="bi bi-bar-chart-fill"></i>
14 + </div>
15 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Create Poll"]</h1>
16 + <p class="sa-text-muted">@Localizer["Help your group make decisions together"]</p>
17 + </div>
18 +
19 + <form asp-action="Create" asp-route-tripId="@tripId" method="post" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 +
22 + <div class="mb-4">
23 + <label for="question" class="form-label">@Localizer["Question"]</label>
24 + <input type="text" id="question" name="question" class="form-control"
25 + required maxlength="500" placeholder="@Localizer["What should the group decide?"]"
26 + style="font-size: 1.1rem; font-weight: 600;" />
27 + </div>
28 +
29 + <div class="d-flex gap-4 mb-4" style="padding: var(--sa-space-4); background: var(--sa-gray-50); border-radius: var(--sa-radius-md);">
30 + <div class="form-check form-switch">
31 + <input type="checkbox" id="allowMultipleVotes" name="allowMultipleVotes" value="true" class="form-check-input" />
32 + <label for="allowMultipleVotes" class="form-check-label" style="font-size: 0.9rem;">
33 + <i class="bi bi-check2-all me-1"></i>@Localizer["Allow multiple votes"]
34 + </label>
35 + </div>
36 + <div class="form-check form-switch">
37 + <input type="checkbox" id="isAnonymous" name="isAnonymous" value="true" class="form-check-input" />
38 + <label for="isAnonymous" class="form-check-label" style="font-size: 0.9rem;">
39 + <i class="bi bi-incognito me-1"></i>@Localizer["Anonymous voting"]
40 + </label>
41 + </div>
42 + </div>
43 +
44 + <div class="mb-3">
45 + <div class="d-flex justify-content-between align-items-center mb-2">
46 + <label class="form-label mb-0">@Localizer["Options"]</label>
47 + <small class="sa-text-muted">@Localizer["At least 2 required"]</small>
48 + </div>
49 +
50 + <div id="pollOptionsContainer" class="d-flex flex-column gap-2">
51 + <div class="poll-option-row d-flex gap-2">
52 + <input type="text" name="option1" class="form-control" placeholder="@Localizer["Option"] 1 *" required maxlength="300" />
53 + </div>
54 + <div class="poll-option-row d-flex gap-2">
55 + <input type="text" name="option2" class="form-control" placeholder="@Localizer["Option"] 2 *" required maxlength="300" />
56 + </div>
57 + <div class="poll-option-row d-flex gap-2">
58 + <input type="text" name="option3" class="form-control" placeholder="@Localizer["Option"] 3" maxlength="300" />
59 + </div>
60 + <div class="poll-option-row d-flex gap-2">
61 + <input type="text" name="option4" class="form-control" placeholder="@Localizer["Option"] 4" maxlength="300" />
62 + </div>
63 + <div class="poll-option-row d-flex gap-2">
64 + <input type="text" name="option5" class="form-control" placeholder="@Localizer["Option"] 5" maxlength="300" />
65 + </div>
66 + </div>
67 + </div>
68 +
69 + <div class="d-flex gap-3 mt-4">
70 + <button type="submit" class="sa-btn flex-grow-1" style="background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff;">
71 + <i class="bi bi-check-lg"></i> @Localizer["Create Poll"]
72 + </button>
73 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
74 + </div>
75 + </form>
76 + </div>
77 + </div>
78 + </div>
79 +</div>
80 +
81 +@section Scripts {
82 + <partial name="_ValidationScriptsPartial" />
83 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Details.cshtml +126 −0
@@ -0,0 +1,126 @@
1 +@model SplitApp.WebApp.Application.DTO.TripPollBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Poll Results"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var userId = (Guid)ViewData["UserId"]!;
7 + var isCreator = (bool)ViewData["IsCreator"]!;
8 + var isClosed = Model.ClosedAt != null;
9 + var totalVotes = Model.Options?.Sum(o => o.VoteCount) ?? 0;
10 + var maxVotes = Model.Options?.Max(o => o.VoteCount) ?? 0;
11 +}
12 +
13 +<div class="row justify-content-center">
14 + <div class="col-lg-8">
15 + <!-- Header -->
16 + <div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-3">
17 + <div>
18 + <h1 style="font-size: 1.5rem; margin-bottom: var(--sa-space-2);">@Model.Question</h1>
19 + <div class="d-flex align-items-center gap-3" style="font-size: 0.9rem; color: var(--sa-gray-500);">
20 + @if (isClosed)
21 + {
22 + <span class="d-flex align-items-center gap-1">
23 + <span class="sa-status-dot sa-status-dot-pending"></span> @Localizer["Closed"]
24 + </span>
25 + }
26 + else
27 + {
28 + <span class="d-flex align-items-center gap-1">
29 + <span class="sa-status-dot sa-status-dot-active sa-status-dot-pulse"></span> @Localizer["Open"]
30 + </span>
31 + }
32 + <span>@Localizer["Created by"] @(Model.CreatedByUser != null ? $"{Model.CreatedByUser.FirstName} {Model.CreatedByUser.LastName}" : "?")</span>
33 + <span>@totalVotes @Localizer["total votes"]</span>
34 + @if (Model.AllowMultipleVotes)
35 + {
36 + <span><i class="bi bi-check2-all me-1"></i>@Localizer["Multiple votes allowed"]</span>
37 + }
38 + </div>
39 + </div>
40 + <div class="d-flex gap-2">
41 + @if (isCreator && !isClosed)
42 + {
43 + <form asp-action="Close" asp-route-id="@Model.Id" method="post" style="display:inline">
44 + <button type="submit" class="sa-btn sa-btn-sm" style="background: var(--sa-warning); color: #fff;">
45 + <i class="bi bi-lock-fill"></i> @Localizer["Close Poll"]
46 + </button>
47 + </form>
48 + }
49 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost sa-btn-sm">
50 + <i class="bi bi-arrow-left"></i> @Localizer["Back"]
51 + </a>
52 + </div>
53 + </div>
54 +
55 + <!-- Options -->
56 + @if (Model.Options != null)
57 + {
58 + <div class="d-flex flex-column gap-3">
59 + @foreach (var option in Model.Options.OrderBy(o => o.DisplayOrder))
60 + {
61 + var voteCount = option.VoteCount;
62 + var percentage = totalVotes > 0 ? (int)(voteCount * 100.0 / totalVotes) : 0;
63 + var userVoted = option.VoterUserIds.Contains(userId);
64 + var isWinning = voteCount == maxVotes && maxVotes > 0;
65 +
66 + <div class="sa-poll-option @(userVoted ? "sa-poll-option-voted" : "") @(isWinning && isClosed ? "sa-poll-option-winner" : "")">
67 + <div class="d-flex justify-content-between align-items-center mb-3">
68 + <div class="d-flex align-items-center gap-2">
69 + <strong style="font-size: 1rem;">@option.Text</strong>
70 + @if (userVoted)
71 + {
72 + <span class="sa-badge sa-badge-solid-secondary" style="font-size: 0.65rem;">
73 + <i class="bi bi-check2"></i> @Localizer["Your vote"]
74 + </span>
75 + }
76 + @if (isWinning && isClosed)
77 + {
78 + <span class="sa-badge sa-badge-accent" style="font-size: 0.65rem;">
79 + <i class="bi bi-trophy-fill"></i> @Localizer["Winner"]
80 + </span>
81 + }
82 + </div>
83 + <div class="d-flex align-items-center gap-3">
84 + <span style="font-size: 0.9rem; color: var(--sa-gray-500);">
85 + @voteCount @Localizer["votes"] (@percentage%)
86 + </span>
87 + @if (!isClosed)
88 + {
89 + <form asp-action="Vote" method="post" style="display:inline">
90 + <input type="hidden" name="pollId" value="@Model.Id" />
91 + <input type="hidden" name="optionId" value="@option.Id" />
92 + <button type="submit" class="sa-btn sa-btn-sm sa-btn-pill @(userVoted ? "sa-btn-secondary" : "sa-btn-ghost")">
93 + @(userVoted ? Localizer["Unvote"] : Localizer["Vote"])
94 + </button>
95 + </form>
96 + }
97 + </div>
98 + </div>
99 +
100 + @{
101 + var barClass = isWinning && isClosed ? "sa-progress-bar-success"
102 + : userVoted ? "sa-progress-bar"
103 + : "sa-progress-bar-primary";
104 + }
105 + <div class="sa-progress">
106 + <div class="sa-progress-bar @barClass" data-width="@percentage%"></div>
107 + </div>
108 +
109 + @if (!Model.IsAnonymous && option.Voters.Any())
110 + {
111 + <div class="d-flex gap-1 mt-2 flex-wrap">
112 + @foreach (var voter in option.Voters)
113 + {
114 + var voterName = $"{voter.FirstName} {voter.LastName}".Trim();
115 + var voterInitial = $"{(voter.FirstName.Length > 0 ? voter.FirstName[0].ToString() : "")}{(voter.LastName.Length > 0 ? voter.LastName[0].ToString() : "")}";
116 + <span class="sa-avatar sa-avatar-sm sa-avatar-@((voter.Id.GetHashCode() % 8 + 8) % 8 + 1)"
117 + title="@voterName" style="border: none; width: 26px; height: 26px; font-size: 0.6rem;">@voterInitial</span>
118 + }
119 + </div>
120 + }
121 + </div>
122 + }
123 + </div>
124 + }
125 + </div>
126 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Index.cshtml +99 −0
@@ -0,0 +1,99 @@
1 +@model List<SplitApp.WebApp.Application.DTO.TripPollBllDto>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Polls"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var tripName = (string)ViewData["TripName"]!;
7 +}
8 +
9 +<!-- Page Header -->
10 +<div class="sa-gradient-header" style="background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);">
11 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
12 + <div>
13 + <h1 style="margin-bottom: 4px;">@Localizer["Polls"]</h1>
14 + <p class="text-muted mb-0">@tripName</p>
15 + </div>
16 + <div class="d-flex gap-2">
17 + <a asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
18 + <i class="bi bi-plus-lg"></i> @Localizer["Create Poll"]
19 + </a>
20 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
21 + <i class="bi bi-arrow-left"></i>
22 + </a>
23 + </div>
24 + </div>
25 +</div>
26 +
27 +@if (!Model.Any())
28 +{
29 + <div class="sa-empty">
30 + <div class="sa-empty-icon"><i class="bi bi-bar-chart"></i></div>
31 + <div class="sa-empty-title">@Localizer["No polls yet"]</div>
32 + <p class="sa-empty-text">@Localizer["Create a poll to help your group make decisions."]</p>
33 + <a asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-primary sa-btn-pill">
34 + <i class="bi bi-plus-lg"></i> @Localizer["Create Poll"]
35 + </a>
36 + </div>
37 +}
38 +else
39 +{
40 + <div class="row justify-content-center">
41 + <div class="col-lg-8">
42 + <div class="d-flex flex-column gap-3">
43 + @{ var idx = 0; }
44 + @foreach (var poll in Model)
45 + {
46 + var totalVotes = poll.Options?.Sum(o => o.VoteCount) ?? 0;
47 + var isClosed = poll.ClosedAt != null;
48 +
49 + <div class="sa-card sa-animate-slide-up sa-stagger-@(Math.Min(idx + 1, 6))">
50 + <div class="sa-card-body">
51 + <div class="d-flex justify-content-between align-items-start mb-2">
52 + <h5 style="font-weight: 700; margin: 0; font-size: 1.05rem;">@poll.Question</h5>
53 + <div class="d-flex align-items-center gap-2" style="flex-shrink: 0;">
54 + @if (isClosed)
55 + {
56 + <span class="sa-status-dot sa-status-dot-pending"></span>
57 + <span class="sa-badge sa-badge-neutral">@Localizer["Closed"]</span>
58 + }
59 + else
60 + {
61 + <span class="sa-status-dot sa-status-dot-active sa-status-dot-pulse"></span>
62 + <span class="sa-badge sa-badge-success">@Localizer["Open"]</span>
63 + }
64 + </div>
65 + </div>
66 +
67 + <div style="font-size: 0.85rem; color: var(--sa-gray-500); margin-bottom: var(--sa-space-3);">
68 + @Localizer["Created by"] @(poll.CreatedByUser != null ? $"{poll.CreatedByUser.FirstName} {poll.CreatedByUser.LastName}" : "?")
69 + &middot; @totalVotes @Localizer["votes"]
70 + &middot; @(poll.Options?.Count ?? 0) @Localizer["options"]
71 + @if (poll.AllowMultipleVotes)
72 + {
73 + <span>&middot; <i class="bi bi-check2-all me-1"></i>@Localizer["Multiple votes"]</span>
74 + }
75 + </div>
76 +
77 + <a asp-action="Details" asp-route-id="@poll.Id"
78 + class="sa-btn sa-btn-sm sa-btn-pill @(isClosed ? "sa-btn-ghost" : "sa-btn-secondary")">
79 + @if (isClosed)
80 + {
81 + <i class="bi bi-eye"></i> @Localizer["View Results"]
82 + }
83 + else
84 + {
85 + <i class="bi bi-check2-square"></i> @Localizer["Vote"]
86 + }
87 + </a>
88 + </div>
89 + </div>
90 + idx++;
91 + }
92 + </div>
93 + </div>
94 + </div>
95 +}
96 +
97 +<a asp-action="Create" asp-route-tripId="@tripId" class="sa-fab sa-hide-desktop" title="@Localizer["Create Poll"]">
98 + <i class="bi bi-plus-lg"></i>
99 +</a>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Settlement/Index.cshtml +232 −0
@@ -0,0 +1,232 @@
1 +@model SplitApp.WebApp.Controllers.SettlementIndexViewModel
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Settlement"];
5 + var maxBalance = Model.Balances.Any() ? Model.Balances.Max(b => Math.Abs(b.NetBalance)) : 1m;
6 +}
7 +
8 +<!-- Page Header -->
9 +<div class="sa-gradient-header">
10 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
11 + <div>
12 + <h1 style="margin-bottom: 4px;">@Localizer["Settlement"]</h1>
13 + <p class="text-muted mb-0">
14 + @Model.TripName
15 + @if (Model.TripStatus != "Active" && Model.LatestPlan != null)
16 + {
17 + var statusBadge = Model.LatestPlan.Status switch
18 + {
19 + ESettlementStatus.Completed => "sa-badge-success",
20 + ESettlementStatus.InProgress => "sa-badge-warning",
21 + _ => "sa-badge-info"
22 + };
23 + var statusIcon = Model.LatestPlan.Status switch
24 + {
25 + ESettlementStatus.Completed => "bi-check-circle-fill",
26 + ESettlementStatus.InProgress => "bi-hourglass-split",
27 + _ => "bi-lock-fill"
28 + };
29 + <span class="sa-badge @statusBadge ms-2" style="font-size: 0.75rem;">
30 + <i class="bi @statusIcon me-1"></i>@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.LatestPlan.Status)
31 + </span>
32 + }
33 + else if (Model.TripStatus != "Active")
34 + {
35 + <span class="sa-badge sa-badge-info ms-2" style="font-size: 0.75rem;">
36 + <i class="bi bi-lock-fill me-1"></i>@Localizer["Pending"]
37 + </span>
38 + }
39 + </p>
40 + </div>
41 + <div class="d-flex gap-2">
42 + @if (Model.IsOrganizer && Model.TripStatus == "Active")
43 + {
44 + <form asp-action="Finalize" asp-route-tripId="@Model.TripId" method="post" style="display:inline">
45 + <button type="submit" class="sa-btn sa-btn-sm" style="background: #fff; color: var(--sa-success);">
46 + <i class="bi bi-check-circle"></i> @Localizer["Finalize Trip"]
47 + </button>
48 + </form>
49 + }
50 + @if (Model.IsOrganizer && Model.TripStatus != "Active" && Model.LatestPlan != null && Model.LatestPlan.Status != ESettlementStatus.Completed)
51 + {
52 + <form asp-action="Reopen" asp-route-tripId="@Model.TripId" method="post" style="display:inline">
53 + <button type="submit" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8);">
54 + <i class="bi bi-unlock"></i> @Localizer["Reopen"]
55 + </button>
56 + </form>
57 + }
58 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@Model.TripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
59 + <i class="bi bi-arrow-left"></i>
60 + </a>
61 + </div>
62 + </div>
63 +</div>
64 +
65 +<!-- Balances -->
66 +<div class="sa-card-static mb-4">
67 + <div class="sa-card-header">
68 + <i class="bi bi-bar-chart me-2"></i>@Localizer["Balances"]
69 + </div>
70 + <div class="sa-card-body">
71 + @foreach (var balance in Model.Balances)
72 + {
73 + var pct = maxBalance > 0 ? (int)(Math.Abs(balance.NetBalance) * 45 / maxBalance) : 0;
74 + <div class="d-flex align-items-center gap-3 mb-3">
75 + <div style="width: 120px; font-weight: 600; font-size: 0.9rem;" class="sa-truncate">
76 + @balance.UserName
77 + </div>
78 + <div style="flex: 1; position: relative; height: 28px; display: flex; align-items: center;">
79 + <!-- Center line -->
80 + <div style="position: absolute; left: 50%; top: 2px; bottom: 2px; width: 2px; background: var(--sa-gray-200); transform: translateX(-50%);"></div>
81 + @if (balance.NetBalance >= 0)
82 + {
83 + <div style="position: absolute; left: 50%; height: 20px; width: @pct%; background: linear-gradient(90deg, var(--sa-success), #16a34a); border-radius: 0 var(--sa-radius-full) var(--sa-radius-full) 0; transition: width 0.8s ease;"></div>
84 + }
85 + else
86 + {
87 + <div style="position: absolute; right: 50%; height: 20px; width: @pct%; background: linear-gradient(270deg, var(--sa-danger), #dc2626); border-radius: var(--sa-radius-full) 0 0 var(--sa-radius-full); transition: width 0.8s ease;"></div>
88 + }
89 + </div>
90 + <div style="width: 100px; text-align: right;">
91 + <span class="sa-amount @(balance.NetBalance >= 0 ? "sa-amount-positive" : "sa-amount-negative")" style="font-size: 0.95rem;">
92 + @(balance.NetBalance >= 0 ? "+" : "")@Model.CurrencySymbol@balance.NetBalance.ToString("N2")
93 + </span>
94 + </div>
95 + </div>
96 + }
97 + </div>
98 +</div>
99 +
100 +<!-- Settlement Plan -->
101 +@if (Model.LatestPlan != null && Model.LatestPlan.Payments != null && Model.LatestPlan.Payments.Any() && Model.TripStatus != "Active")
102 +{
103 + <div class="sa-card-static">
104 + <div class="sa-card-header d-flex justify-content-between align-items-center">
105 + <span><i class="bi bi-check2-circle me-2"></i>@Localizer["Settlement Plan"]</span>
106 + @{
107 + var planBadge = Model.LatestPlan.Status switch
108 + {
109 + ESettlementStatus.Completed => "sa-badge-success",
110 + ESettlementStatus.InProgress => "sa-badge-warning",
111 + _ => "sa-badge-neutral"
112 + };
113 + }
114 + <span class="sa-badge @planBadge">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.LatestPlan.Status)</span>
115 + </div>
116 + <div class="sa-card-body">
117 + <div class="d-flex flex-column gap-3">
118 + @foreach (var payment in Model.LatestPlan.Payments.OrderByDescending(p => p.Amount))
119 + {
120 + var fromName = payment.FromUser != null ? $"{payment.FromUser.FirstName} {payment.FromUser.LastName}" : "?";
121 + var toName = payment.ToUser != null ? $"{payment.ToUser.FirstName} {payment.ToUser.LastName}" : "?";
122 + var fromInitial = payment.FromUser != null ? $"{payment.FromUser.FirstName?[0]}{payment.FromUser.LastName?[0]}" : "?";
123 + var toInitial = payment.ToUser != null ? $"{payment.ToUser.FirstName?[0]}{payment.ToUser.LastName?[0]}" : "?";
124 +
125 + <div class="sa-settlement-card">
126 + <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1;">
127 + <span class="sa-avatar sa-avatar-sm sa-avatar-5" style="border: none;" title="@fromName">@fromInitial</span>
128 + <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@fromName</span>
129 + </div>
130 + <div class="d-flex flex-column align-items-center" style="flex-shrink: 0;">
131 + <i class="bi bi-arrow-right sa-settlement-arrow"></i>
132 + <span class="sa-settlement-amount">@Model.CurrencySymbol@payment.Amount.ToString("N2")</span>
133 + </div>
134 + <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1; justify-content: flex-end;">
135 + <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@toName</span>
136 + <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border: none;" title="@toName">@toInitial</span>
137 + </div>
138 + <div style="flex-shrink: 0; min-width: 110px; text-align: right;">
139 + @if (payment.Status == EPaymentStatus.Confirmed)
140 + {
141 + <span class="sa-badge sa-badge-solid-success"><i class="bi bi-check2-all me-1"></i>@Localizer["Confirmed"]</span>
142 + }
143 + else if (payment.Status == EPaymentStatus.MarkedPaid)
144 + {
145 + @if (payment.ToUserId == Model.CurrentUserId)
146 + {
147 + <form asp-action="ConfirmReceipt" asp-route-paymentId="@payment.Id" method="post" style="display:inline">
148 + <button type="submit" class="sa-btn sa-btn-sm sa-btn-pill" style="background: var(--sa-info); color: #fff;">
149 + <i class="bi bi-hand-thumbs-up"></i> @Localizer["Confirm"]
150 + </button>
151 + </form>
152 + }
153 + else
154 + {
155 + <span class="sa-badge sa-badge-warning"><i class="bi bi-hourglass-split me-1"></i>@Localizer["Awaiting Confirmation"]</span>
156 + }
157 + }
158 + else
159 + {
160 + @if (payment.FromUserId == Model.CurrentUserId)
161 + {
162 + <form asp-action="MarkPaid" asp-route-paymentId="@payment.Id" method="post" style="display:inline">
163 + <button type="submit" class="sa-btn sa-btn-success sa-btn-sm sa-btn-pill">
164 + <i class="bi bi-check-lg"></i> @Localizer["Mark Paid"]
165 + </button>
166 + </form>
167 + }
168 + else
169 + {
170 + <span class="sa-badge sa-badge-neutral"><i class="bi bi-clock me-1"></i>@Localizer["Pending"]</span>
171 + }
172 + }
173 + </div>
174 + </div>
175 + }
176 + </div>
177 +
178 + <div class="sa-divider"></div>
179 + <div class="d-flex justify-content-between" style="font-weight: 700;">
180 + <span>@Localizer["Total"]</span>
181 + <span>@Model.CurrencySymbol@Model.LatestPlan.TotalAmount.ToString("N2")</span>
182 + </div>
183 + </div>
184 + </div>
185 +}
186 +else if (Model.PreviewPayments.Any())
187 +{
188 + <div class="sa-card-static">
189 + <div class="sa-card-header d-flex justify-content-between align-items-center">
190 + <span><i class="bi bi-eye me-2"></i>@Localizer["Suggested Payments"]</span>
191 + <span class="sa-badge sa-badge-neutral">@Localizer["Preview"]</span>
192 + </div>
193 + <div class="sa-card-body">
194 + <div class="d-flex flex-column gap-3">
195 + @foreach (var payment in Model.PreviewPayments.OrderByDescending(p => p.Amount))
196 + {
197 + var fromInitial = payment.FromUserName.Length >= 2 ? payment.FromUserName[..2].ToUpper() : "?";
198 + var toInitial = payment.ToUserName.Length >= 2 ? payment.ToUserName[..2].ToUpper() : "?";
199 +
200 + <div class="sa-settlement-card">
201 + <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1;">
202 + <span class="sa-avatar sa-avatar-sm sa-avatar-5" style="border: none;">@fromInitial</span>
203 + <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@payment.FromUserName</span>
204 + </div>
205 + <div class="d-flex flex-column align-items-center" style="flex-shrink: 0;">
206 + <i class="bi bi-arrow-right sa-settlement-arrow"></i>
207 + <span class="sa-settlement-amount">@Model.CurrencySymbol@payment.Amount.ToString("N2")</span>
208 + </div>
209 + <div class="d-flex align-items-center gap-2" style="min-width: 0; flex: 1; justify-content: flex-end;">
210 + <span class="sa-truncate" style="font-weight: 600; font-size: 0.9rem;">@payment.ToUserName</span>
211 + <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border: none;">@toInitial</span>
212 + </div>
213 + </div>
214 + }
215 + </div>
216 + <div class="sa-divider"></div>
217 + <p style="font-size: 0.8rem; color: var(--sa-gray-400); margin: 0;">
218 + <i class="bi bi-info-circle me-1"></i>@Localizer["These are suggested payments. Finalize the trip to lock them in."]
219 + </p>
220 + </div>
221 + </div>
222 +}
223 +else
224 +{
225 + <div class="sa-card-static">
226 + <div class="sa-empty" style="padding: var(--sa-space-8) var(--sa-space-4);">
227 + <div class="sa-empty-icon" style="font-size: 2.5rem; color: var(--sa-success);"><i class="bi bi-check-circle-fill"></i></div>
228 + <div class="sa-empty-title" style="font-size: 1rem;">@Localizer["All settled up!"]</div>
229 + <p class="sa-empty-text" style="font-size: 0.875rem;">@Localizer["No payments needed — everyone is even."]</p>
230 + </div>
231 + </div>
232 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/Error.cshtml +29 −0
@@ -0,0 +1,29 @@
1 +@model ErrorViewModel
2 +@{
3 + ViewData["Title"] = Localizer["Error"].Value;
4 +}
5 +
6 +<div class="sa-invite-page">
7 + <div class="sa-card-static sa-invite-card">
8 + <div class="sa-card-body" style="padding: var(--sa-space-10) var(--sa-space-8);">
9 + <div class="sa-confirm-icon sa-confirm-icon-danger mb-4" style="margin: 0 auto var(--sa-space-6);">
10 + <i class="bi bi-exclamation-triangle-fill"></i>
11 + </div>
12 + <h2 style="margin-bottom: var(--sa-space-3);">@Localizer["Something went wrong"]</h2>
13 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
14 + @Localizer["An error occurred while processing your request."]
15 + </p>
16 +
17 + @if (Model.ShowRequestId)
18 + {
19 + <div class="sa-bg-soft-danger" style="padding: var(--sa-space-3) var(--sa-space-4); border-radius: var(--sa-radius-sm); margin-bottom: var(--sa-space-6);">
20 + <small><strong>@Localizer["Request ID"]:</strong> <code>@Model.RequestId</code></small>
21 + </div>
22 + }
23 +
24 + <a asp-area="" asp-controller="Home" asp-action="Index" class="sa-btn sa-btn-primary sa-btn-pill">
25 + <i class="bi bi-house-door me-1"></i> @Localizer["Back to Home"]
26 + </a>
27 + </div>
28 + </div>
29 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LanguageSelection.cshtml +24 −0
@@ -0,0 +1,24 @@
1 +@using Microsoft.AspNetCore.Localization
2 +@using Microsoft.Extensions.Options
3 +
4 +@inject IOptions<RequestLocalizationOptions> LocalizationOptions
5 +
6 +@{
7 + var requestCulture = Context.Features.Get<IRequestCultureFeature>();
8 + var cultureItems = LocalizationOptions.Value.SupportedUICultures!
9 + .Select(c => new { c.Name, c.NativeName })
10 + .ToList();
11 +}
12 +
13 +<div class="sa-lang-pills">
14 + @foreach (var culture in cultureItems)
15 + {
16 + <a class="sa-lang-pill @(requestCulture?.RequestCulture.UICulture.Name == culture.Name ? "active" : "")"
17 + asp-area=""
18 + asp-controller="Home" asp-action="SetLanguage"
19 + asp-route-culture="@culture.Name"
20 + asp-route-returnUrl="@(Context.Request.Path + Context.Request.QueryString)">
21 + @culture.Name.ToUpper()
22 + </a>
23 + }
24 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml +112 −0
@@ -0,0 +1,112 @@
1 +<!DOCTYPE html>
2 +<html lang="@Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName">
3 +<head>
4 + <meta charset="utf-8" />
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 + <meta name="theme-color" content="#e8604c" />
7 + <title>@ViewData["Title"] - SplitApp</title>
8 + <script type="importmap"></script>
9 +
10 + <!-- Fonts -->
11 + <link rel="preconnect" href="https://fonts.googleapis.com" />
12 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
13 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
14 +
15 + <!-- Icons -->
16 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
17 +
18 + <!-- Styles -->
19 + <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
20 + <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
21 + <link rel="stylesheet" href="~/css/splitapp-design.css" asp-append-version="true" />
22 + <link rel="stylesheet" href="~/SplitApp.WebApp.styles.css" asp-append-version="true" />
23 +</head>
24 +<body>
25 + <!-- Toast Container -->
26 + <div id="sa-toast-container" class="sa-toast-container"></div>
27 +
28 + <!-- TempData Messages (read by splitapp.js) -->
29 + <div id="sa-tempdata-messages" style="display:none"
30 + data-success="@TempData["Success"]"
31 + data-error="@TempData["Error"]"
32 + data-warning="@TempData["Warning"]"></div>
33 +
34 + <!-- Navbar -->
35 + <header>
36 + <nav class="sa-navbar navbar navbar-expand-md">
37 + <div class="container">
38 + <a class="sa-navbar-brand" asp-area="" asp-controller="Home" asp-action="Index">
39 + <i class="bi bi-airplane-fill"></i>
40 + SplitApp
41 + </a>
42 + <button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav"
43 + aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
44 + <i class="bi bi-list" style="font-size: 1.5rem; color: var(--sa-gray-700);"></i>
45 + </button>
46 + <div class="navbar-collapse collapse" id="mainNav">
47 + <ul class="navbar-nav me-auto">
48 + <li class="nav-item">
49 + <a class="nav-link" asp-area="" asp-controller="Home" asp-action="Index">
50 + <i class="bi bi-house-door me-1"></i>@Localizer["Home"]
51 + </a>
52 + </li>
53 + <li class="nav-item">
54 + <a class="nav-link" href="/swagger" target="_blank">
55 + <i class="bi bi-braces me-1"></i>API
56 + </a>
57 + </li>
58 + @if (User.Identity?.IsAuthenticated == true)
59 + {
60 + <li class="nav-item">
61 + <a class="nav-link" asp-area="" asp-controller="Trips" asp-action="Index">
62 + <i class="bi bi-luggage me-1"></i>@Localizer["Trips"]
63 + </a>
64 + </li>
65 + @if (User.IsInRole("admin"))
66 + {
67 + <li class="nav-item">
68 + <a class="nav-link" asp-area="Admin" asp-controller="Dashboard" asp-action="Index">
69 + <i class="bi bi-speedometer2 me-1"></i>@Localizer["Admin Panel"]
70 + </a>
71 + </li>
72 + }
73 + }
74 + </ul>
75 + <div class="d-flex align-items-center gap-3">
76 + <partial name="_LanguageSelection" />
77 + <partial name="_LoginPartial" />
78 + </div>
79 + </div>
80 + </div>
81 + </nav>
82 + </header>
83 +
84 + <!-- Main Content -->
85 + <div class="container sa-animate-fade-in" style="padding-top: var(--sa-space-6); padding-bottom: var(--sa-space-8);">
86 + <main role="main">
87 + @RenderBody()
88 + </main>
89 + </div>
90 +
91 + <!-- Footer -->
92 + <footer class="sa-footer">
93 + <div class="container d-flex justify-content-between align-items-center flex-wrap gap-3">
94 + <div>
95 + <span style="font-weight: 600; color: var(--sa-gray-200);">
96 + <i class="bi bi-airplane-fill me-1"></i> SplitApp
97 + </span>
98 + <span class="ms-2">&copy; 2026 SplitApp</span>
99 + </div>
100 + <div class="d-flex align-items-center gap-2">
101 + <span style="font-size: 0.8rem;">@Thread.CurrentThread.CurrentUICulture.Name</span>
102 + </div>
103 + </div>
104 + </footer>
105 +
106 + <script src="~/lib/jquery/dist/jquery.min.js"></script>
107 + <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
108 + <script src="~/js/splitapp.js" asp-append-version="true"></script>
109 + <script src="~/js/site.js" asp-append-version="true"></script>
110 + @await RenderSectionAsync("Scripts", required: false)
111 +</body>
112 +</html>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml.css +48 −0
@@ -0,0 +1,48 @@
1 +/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
2 +for details on configuring this project to bundle and minify static web assets. */
3 +
4 +a.navbar-brand {
5 + white-space: normal;
6 + text-align: center;
7 + word-break: break-all;
8 +}
9 +
10 +a {
11 + color: #0077cc;
12 +}
13 +
14 +.btn-primary {
15 + color: #fff;
16 + background-color: #1b6ec2;
17 + border-color: #1861ac;
18 +}
19 +
20 +.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
21 + color: #fff;
22 + background-color: #1b6ec2;
23 + border-color: #1861ac;
24 +}
25 +
26 +.border-top {
27 + border-top: 1px solid #e5e5e5;
28 +}
29 +.border-bottom {
30 + border-bottom: 1px solid #e5e5e5;
31 +}
32 +
33 +.box-shadow {
34 + box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
35 +}
36 +
37 +button.accept-policy {
38 + font-size: 1rem;
39 + line-height: inherit;
40 +}
41 +
42 +.footer {
43 + position: absolute;
44 + bottom: 0;
45 + width: 100%;
46 + white-space: nowrap;
47 + line-height: 60px;
48 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LoginPartial.cshtml +45 −0
@@ -0,0 +1,45 @@
1 +@using System.Security.Claims
2 +
3 +@if (User.Identity?.IsAuthenticated == true)
4 +{
5 + var firstName = User.FindFirstValue(ClaimTypes.GivenName);
6 + var lastName = User.FindFirstValue(ClaimTypes.Surname);
7 + var initials = (firstName?.Length > 0 ? firstName[0].ToString() : "") +
8 + (lastName?.Length > 0 ? lastName[0].ToString() : "");
9 +
10 + <div class="dropdown">
11 + <button class="sa-nav-avatar-btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false"
12 + data-bs-auto-close="true" style="border: none;">
13 + <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border:none;">@initials</span>
14 + <span class="sa-hide-mobile">@User.Identity?.Name</span>
15 + </button>
16 + <ul class="dropdown-menu dropdown-menu-end">
17 + <li>
18 + <a class="dropdown-item" asp-controller="Account" asp-action="Manage" asp-area="">
19 + <i class="bi bi-person me-2"></i>@Localizer["Profile"]
20 + </a>
21 + </li>
22 + <li><hr class="dropdown-divider" /></li>
23 + <li>
24 + <form class="form-inline" asp-controller="Account" asp-action="Logout" asp-area=""
25 + asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })" method="post">
26 + @Html.AntiForgeryToken()
27 + <button type="submit" class="dropdown-item text-danger">
28 + <i class="bi bi-box-arrow-right me-2"></i>@Localizer["Logout"]
29 + </button>
30 + </form>
31 + </li>
32 + </ul>
33 + </div>
34 +}
35 +else
36 +{
37 + <div class="sa-navbar-auth-btns">
38 + <a class="sa-btn sa-btn-ghost sa-btn-sm" asp-controller="Account" asp-action="Login" asp-area="">
39 + @Localizer["Log in"]
40 + </a>
41 + <a class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill" asp-controller="Account" asp-action="Register" asp-area="">
42 + @Localizer["Sign up"]
43 + </a>
44 + </div>
45 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_ValidationScriptsPartial.cshtml +2 −0
@@ -0,0 +1,2 @@
1 +<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
2 +<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Create.cshtml +82 −0
@@ -0,0 +1,82 @@
1 +@model SplitApp.WebApp.Application.DTO.TripBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Create Trip"];
5 +}
6 +
7 +<div class="row justify-content-center">
8 + <div class="col-md-8 col-lg-6">
9 + <div class="sa-card-static sa-card-accent sa-card-accent-secondary">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center mb-4">
12 + <div style="font-size: 2.5rem; color: var(--sa-secondary); margin-bottom: var(--sa-space-2);">
13 + <i class="bi bi-airplane-fill"></i>
14 + </div>
15 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Create Trip"]</h1>
16 + <p class="sa-text-muted">@Localizer["Set up your next adventure"]</p>
17 + </div>
18 +
19 + <form asp-action="Create" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 +
22 + <div class="mb-3">
23 + <label asp-for="Name" class="form-label">@Localizer["Name"]</label>
24 + <input asp-for="Name" class="form-control" placeholder="e.g. Barcelona 2026" />
25 + <span asp-validation-for="Name" class="text-danger"></span>
26 + </div>
27 +
28 + <div class="mb-3">
29 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
30 + <textarea asp-for="Description" class="form-control" rows="3" placeholder="@Localizer["What's the plan?"]"></textarea>
31 + <span asp-validation-for="Description" class="text-danger"></span>
32 + </div>
33 +
34 + <div class="mb-3">
35 + <label asp-for="Destination" class="form-label">
36 + <i class="bi bi-geo-alt me-1"></i>@Localizer["Destination"]
37 + </label>
38 + <input asp-for="Destination" class="form-control" placeholder="e.g. Barcelona, Spain" />
39 + <span asp-validation-for="Destination" class="text-danger"></span>
40 + </div>
41 +
42 + <div class="row mb-3">
43 + <div class="col-6">
44 + <label asp-for="StartDate" class="form-label">
45 + <i class="bi bi-calendar-event me-1"></i>@Localizer["Start Date"]
46 + </label>
47 + <input asp-for="StartDate" type="date" class="form-control" />
48 + <span asp-validation-for="StartDate" class="text-danger"></span>
49 + </div>
50 + <div class="col-6">
51 + <label asp-for="EndDate" class="form-label">
52 + <i class="bi bi-calendar-check me-1"></i>@Localizer["End Date"]
53 + </label>
54 + <input asp-for="EndDate" type="date" class="form-control" />
55 + <span asp-validation-for="EndDate" class="text-danger"></span>
56 + </div>
57 + </div>
58 +
59 + <div class="mb-4">
60 + <label asp-for="DefaultCurrencyId" class="form-label">
61 + <i class="bi bi-currency-exchange me-1"></i>@Localizer["Default Currency"]
62 + </label>
63 + <select asp-for="DefaultCurrencyId" asp-items="@((SelectList)ViewData["DefaultCurrencyId"]!)" class="form-select">
64 + </select>
65 + <span asp-validation-for="DefaultCurrencyId" class="text-danger"></span>
66 + </div>
67 +
68 + <div class="d-flex gap-3">
69 + <button type="submit" class="sa-btn sa-btn-secondary flex-grow-1">
70 + <i class="bi bi-check-lg"></i> @Localizer["Create"]
71 + </button>
72 + <a asp-action="Index" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
73 + </div>
74 + </form>
75 + </div>
76 + </div>
77 + </div>
78 +</div>
79 +
80 +@section Scripts {
81 + <partial name="_ValidationScriptsPartial" />
82 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Delete.cshtml +56 −0
@@ -0,0 +1,56 @@
1 +@model SplitApp.WebApp.Application.DTO.TripBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Delete Trip"];
5 +}
6 +
7 +<div class="sa-invite-page">
8 + <div class="sa-card-static sa-confirm-card sa-card-accent sa-card-accent-danger">
9 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
10 + <div class="text-center">
11 + <div class="sa-confirm-icon sa-confirm-icon-danger">
12 + <i class="bi bi-exclamation-triangle-fill"></i>
13 + </div>
14 + <h2 style="font-size: 1.5rem; margin-bottom: var(--sa-space-2);">@Localizer["Delete Trip"]</h2>
15 + <p class="sa-text-muted" style="margin-bottom: var(--sa-space-6);">
16 + @Localizer["Are you sure you want to delete this trip? This action cannot be undone."]
17 + </p>
18 + </div>
19 +
20 + <div class="sa-card-static" style="margin-bottom: var(--sa-space-6);">
21 + <div class="sa-card-body" style="padding: var(--sa-space-4);">
22 + <h5 style="margin-bottom: var(--sa-space-2); font-weight: 700;">@Model.Name</h5>
23 + @if (!string.IsNullOrEmpty(Model.Destination))
24 + {
25 + <div style="color: var(--sa-gray-600); font-size: 0.9rem; margin-bottom: 4px;">
26 + <i class="bi bi-geo-alt-fill me-1" style="color: var(--sa-primary);"></i> @Model.Destination
27 + </div>
28 + }
29 + @if (Model.StartDate.HasValue || Model.EndDate.HasValue)
30 + {
31 + <div style="color: var(--sa-gray-500); font-size: 0.85rem; margin-bottom: 4px;">
32 + <i class="bi bi-calendar-event me-1"></i>
33 + @(Model.StartDate?.ToString("MMM dd, yyyy") ?? "?") — @(Model.EndDate?.ToString("MMM dd, yyyy") ?? "?")
34 + </div>
35 + }
36 + @if (Model.DefaultCurrency != null)
37 + {
38 + <div style="color: var(--sa-gray-500); font-size: 0.85rem;">
39 + <i class="bi bi-currency-exchange me-1"></i> @Model.DefaultCurrency.Code
40 + </div>
41 + }
42 + </div>
43 + </div>
44 +
45 + <form asp-action="Delete">
46 + <input type="hidden" asp-for="Id" />
47 + <div class="d-flex gap-3">
48 + <button type="submit" class="sa-btn sa-btn-danger flex-grow-1">
49 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
50 + </button>
51 + <a asp-action="Details" asp-route-id="@Model.Id" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
52 + </div>
53 + </form>
54 + </div>
55 + </div>
56 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Details.cshtml +291 −0
@@ -0,0 +1,291 @@
1 +@model SplitApp.WebApp.Application.DTO.TripBllDto
2 +
3 +@{
4 + ViewData["Title"] = Model.Name;
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var participantCount = (int)ViewData["ParticipantCount"]!;
7 + var recentExpenses = (List<SplitApp.WebApp.Application.DTO.ExpenseBllDto>)ViewData["RecentExpenses"]!;
8 + var totalExpenses = (decimal)ViewData["TotalExpenses"]!;
9 + var userRole = (EParticipantRole)ViewData["UserRole"]!;
10 + var balances = (List<SplitApp.WebApp.Controllers.SettlementBalanceViewModel>)ViewData["Balances"]!;
11 + var currentUserBalance = (decimal)ViewData["CurrentUserBalance"]!;
12 + var budgetUsedPct = (int)ViewData["BudgetUsedPct"]!;
13 + var totalPlanned = (decimal)ViewData["TotalPlanned"]!;
14 + var currencySymbol = (string)ViewData["CurrencySymbol"]!;
15 +}
16 +
17 +<!-- Hero Header -->
18 +<div class="sa-gradient-header">
19 + <div class="d-flex justify-content-between align-items-start flex-wrap gap-3">
20 + <div>
21 + <div class="d-flex align-items-center gap-3 mb-2">
22 + <h1 style="margin: 0;">@Model.Name</h1>
23 + @{
24 + var statusBadge = Model.Status switch
25 + {
26 + ETripStatus.Active => "sa-badge-success",
27 + ETripStatus.Finalizing => "sa-badge-warning",
28 + ETripStatus.Settled => "sa-badge-info",
29 + _ => "sa-badge-neutral"
30 + };
31 + }
32 + <span class="sa-badge @statusBadge">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(Model.Status)</span>
33 + </div>
34 + @if (!string.IsNullOrEmpty(Model.Destination))
35 + {
36 + <p class="text-muted mb-1" style="font-size: 1.05rem;">
37 + <i class="bi bi-geo-alt-fill me-1"></i> @Model.Destination
38 + </p>
39 + }
40 + @if (Model.StartDate.HasValue || Model.EndDate.HasValue)
41 + {
42 + <p class="text-muted mb-0" style="font-size: 0.9rem;">
43 + <i class="bi bi-calendar-event me-1"></i>
44 + @(Model.StartDate?.ToString("MMM dd, yyyy") ?? "?") — @(Model.EndDate?.ToString("MMM dd, yyyy") ?? "?")
45 + </p>
46 + }
47 + @if (Model.DefaultCurrency != null)
48 + {
49 + <p class="text-muted mb-0" style="font-size: 0.9rem; margin-top: 4px;">
50 + <i class="bi bi-currency-exchange me-1"></i> @Model.DefaultCurrency.Code (@Model.DefaultCurrency.Symbol)
51 + </p>
52 + }
53 + </div>
54 + <div class="d-flex gap-2">
55 + @if (userRole == EParticipantRole.Organizer)
56 + {
57 + <a asp-action="Edit" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff;">
58 + <i class="bi bi-pencil"></i> @Localizer["Edit"]
59 + </a>
60 + <a asp-action="Delete" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(220,38,38,0.3); color: #fff;">
61 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
62 + </a>
63 + }
64 + <a asp-action="Index" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9);">
65 + <i class="bi bi-arrow-left"></i> @Localizer["Back"]
66 + </a>
67 + </div>
68 + </div>
69 +
70 + <!-- Participant Avatars -->
71 + @if (Model.Participants != null && Model.Participants.Any())
72 + {
73 + <div class="sa-avatar-stack mt-3">
74 + @{ var pIndex = 0; }
75 + @foreach (var participant in Model.Participants.Where(p => p.IsActive).Take(6))
76 + {
77 + var name = participant.User != null ? $"{participant.User.FirstName} {participant.User.LastName}" : "?";
78 + var initial = participant.User != null ? $"{participant.User.FirstName?[0]}{participant.User.LastName?[0]}" : "?";
79 + <span class="sa-avatar sa-avatar-sm sa-avatar-@((pIndex % 8) + 1)" title="@name">@initial</span>
80 + pIndex++;
81 + }
82 + @if (Model.Participants.Count(p => p.IsActive) > 6)
83 + {
84 + <span class="sa-avatar sa-avatar-sm sa-avatar-overflow">+@(Model.Participants.Count(p => p.IsActive) - 6)</span>
85 + }
86 + </div>
87 + }
88 +</div>
89 +
90 +<!-- Stats Row -->
91 +<div class="row g-3 mb-4">
92 + <div class="col-6 col-md-3">
93 + <div class="sa-card-static">
94 + <div class="sa-stat">
95 + <div class="sa-stat-icon" style="color: var(--sa-primary);"><i class="bi bi-receipt"></i></div>
96 + <div class="sa-stat-value">@currencySymbol@totalExpenses.ToString("N0")</div>
97 + <div class="sa-stat-label">@Localizer["Total Expenses"]</div>
98 + </div>
99 + </div>
100 + </div>
101 + <div class="col-6 col-md-3">
102 + <div class="sa-card-static">
103 + <div class="sa-stat">
104 + <div class="sa-stat-icon" style="color: var(--sa-success);"><i class="bi bi-pie-chart-fill"></i></div>
105 + <div class="sa-stat-value" style="color: @(budgetUsedPct > 90 ? "var(--sa-danger)" : budgetUsedPct > 70 ? "var(--sa-accent)" : "var(--sa-success)");">
106 + @(totalPlanned > 0 ? $"{budgetUsedPct}%" : "—")
107 + </div>
108 + <div class="sa-stat-label">@Localizer["Budget Used"]</div>
109 + </div>
110 + </div>
111 + </div>
112 + <div class="col-6 col-md-3">
113 + <div class="sa-card-static" style="@(currentUserBalance >= 0 ? "border-bottom: 3px solid var(--sa-success);" : "border-bottom: 3px solid var(--sa-danger);")">
114 + <div class="sa-stat">
115 + <div class="sa-stat-icon" style="color: @(currentUserBalance >= 0 ? "var(--sa-success)" : "var(--sa-danger)");"><i class="bi bi-wallet2"></i></div>
116 + <div class="sa-stat-value" style="color: @(currentUserBalance >= 0 ? "var(--sa-success)" : "var(--sa-danger)");">
117 + @(currentUserBalance >= 0 ? "+" : "")@currencySymbol@currentUserBalance.ToString("N2")
118 + </div>
119 + <div class="sa-stat-label">@Localizer["Your Balance"]</div>
120 + </div>
121 + </div>
122 + </div>
123 + <div class="col-6 col-md-3">
124 + <div class="sa-card-static">
125 + <div class="sa-stat">
126 + <div class="sa-stat-icon" style="color: #6366f1;"><i class="bi bi-people-fill"></i></div>
127 + <div class="sa-stat-value">@participantCount</div>
128 + <div class="sa-stat-label">@Localizer["Members"]</div>
129 + </div>
130 + </div>
131 + </div>
132 +</div>
133 +
134 +<!-- Navigation Grid -->
135 +<div class="row g-3 mb-4">
136 + <div class="col-4 col-md-2">
137 + <a asp-controller="Expenses" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
138 + <div class="sa-nav-card-icon sa-nav-icon-expenses"><i class="bi bi-receipt-cutoff"></i></div>
139 + <span class="sa-nav-card-label">@Localizer["Expenses"]</span>
140 + </a>
141 + </div>
142 + <div class="col-4 col-md-2">
143 + <a asp-controller="Members" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
144 + <div class="sa-nav-card-icon sa-nav-icon-members"><i class="bi bi-people-fill"></i></div>
145 + <span class="sa-nav-card-label">@Localizer["Members"]</span>
146 + </a>
147 + </div>
148 + <div class="col-4 col-md-2">
149 + <a asp-controller="Budget" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
150 + <div class="sa-nav-card-icon sa-nav-icon-budget"><i class="bi bi-wallet2"></i></div>
151 + <span class="sa-nav-card-label">@Localizer["Budget"]</span>
152 + </a>
153 + </div>
154 + <div class="col-4 col-md-2">
155 + <a asp-controller="WishlistClient" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
156 + <div class="sa-nav-card-icon sa-nav-icon-wishlist"><i class="bi bi-star-fill"></i></div>
157 + <span class="sa-nav-card-label">@Localizer["Wishlist"]</span>
158 + </a>
159 + </div>
160 + <div class="col-4 col-md-2">
161 + <a asp-controller="PollsClient" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
162 + <div class="sa-nav-card-icon sa-nav-icon-polls"><i class="bi bi-bar-chart-fill"></i></div>
163 + <span class="sa-nav-card-label">@Localizer["Polls"]</span>
164 + </a>
165 + </div>
166 + <div class="col-4 col-md-2">
167 + <a asp-controller="Settlement" asp-action="Index" asp-route-tripId="@tripId" class="sa-nav-card">
168 + <div class="sa-nav-card-icon sa-nav-icon-settlement"><i class="bi bi-check2-circle"></i></div>
169 + <span class="sa-nav-card-label">@Localizer["Settlement"]</span>
170 + </a>
171 + </div>
172 +</div>
173 +
174 +<!-- Balances -->
175 +@if (balances.Any())
176 +{
177 + <div class="row g-3 mb-4">
178 + <!-- Budget Progress -->
179 + @if (totalPlanned > 0)
180 + {
181 + <div class="col-md-6">
182 + <div class="sa-card-static h-100">
183 + <div class="sa-card-header">
184 + <i class="bi bi-pie-chart-fill me-2"></i>@Localizer["Budget Progress"]
185 + </div>
186 + <div class="sa-card-body">
187 + @{
188 + var budgetCategories = balances; // just for the section - actual categories loaded separately
189 + }
190 + <div class="d-flex justify-content-between mb-2">
191 + <span style="font-weight: 600; font-size: 0.9rem;">@currencySymbol@totalExpenses.ToString("N0") / @currencySymbol@totalPlanned.ToString("N0")</span>
192 + <span class="@(budgetUsedPct > 100 ? "text-danger fw-bold" : "sa-text-muted")" style="font-size: 0.85rem;">@budgetUsedPct%</span>
193 + </div>
194 + @{
195 + var overallBarClass = budgetUsedPct <= 60 ? "sa-progress-bar-success" : budgetUsedPct <= 85 ? "sa-progress-bar-warning" : "sa-progress-bar-danger";
196 + }
197 + <div class="sa-progress sa-progress-lg">
198 + <div class="sa-progress-bar @overallBarClass" data-width="@Math.Min(budgetUsedPct, 100)%" style="width: 0%;"></div>
199 + </div>
200 + </div>
201 + </div>
202 + </div>
203 + }
204 +
205 + <!-- Balances -->
206 + <div class="@(totalPlanned > 0 ? "col-md-6" : "col-12")">
207 + <div class="sa-card-static h-100">
208 + <div class="sa-card-header">
209 + <i class="bi bi-people-fill me-2"></i>@Localizer["Balances"]
210 + </div>
211 + <div class="sa-card-body" style="padding: var(--sa-space-3) var(--sa-space-5);">
212 + @{ var bIdx = 0; }
213 + @foreach (var balance in balances)
214 + {
215 + var initial = balance.UserName.Length >= 2
216 + ? $"{balance.UserName.Split(' ')[0][0]}{(balance.UserName.Split(' ').Length > 1 ? balance.UserName.Split(' ')[1][0] : ' ')}"
217 + : balance.UserName[0].ToString();
218 + <div class="d-flex align-items-center gap-3 py-2">
219 + <span class="sa-avatar sa-avatar-sm sa-avatar-@((bIdx % 8) + 1)" style="border: none; font-size: 0.65rem;">@initial.Trim()</span>
220 + <span style="flex: 1; font-weight: 500; font-size: 0.9rem;">@balance.UserName</span>
221 + <span class="sa-amount @(balance.NetBalance >= 0 ? "sa-amount-positive" : "sa-amount-negative")" style="font-size: 0.95rem;">
222 + @(balance.NetBalance >= 0 ? "+" : "")@currencySymbol@balance.NetBalance.ToString("N2")
223 + </span>
224 + </div>
225 + bIdx++;
226 + }
227 + </div>
228 + </div>
229 + </div>
230 + </div>
231 +}
232 +
233 +<!-- Recent Expenses -->
234 +@if (recentExpenses.Any())
235 +{
236 + <div class="sa-card-static mb-4">
237 + <div class="sa-card-header d-flex justify-content-between align-items-center">
238 + <span><i class="bi bi-receipt me-2"></i>@Localizer["Recent Expenses"]</span>
239 + <a asp-controller="Expenses" asp-action="Index" asp-route-tripId="@tripId"
240 + class="sa-btn sa-btn-ghost sa-btn-sm">
241 + @Localizer["View All"] <i class="bi bi-arrow-right ms-1"></i>
242 + </a>
243 + </div>
244 + <div>
245 + @foreach (var expense in recentExpenses)
246 + {
247 + <div class="sa-expense-item">
248 + <div class="sa-expense-icon sa-category-default">
249 + <i class="bi bi-receipt"></i>
250 + </div>
251 + <div class="sa-expense-details">
252 + <div class="sa-expense-desc">@(expense.Description ?? Localizer["No description"].Value)</div>
253 + <div class="sa-expense-meta">
254 + @(expense.PaidByUser != null ? $"{expense.PaidByUser.FirstName} {expense.PaidByUser.LastName}" : "?")
255 + &middot; @expense.ExpenseDate.ToString("MMM dd")
256 + </div>
257 + </div>
258 + <div class="sa-expense-amount">
259 + @expense.Amount.ToString("N2")
260 + </div>
261 + </div>
262 + }
263 + </div>
264 + </div>
265 +}
266 +else
267 +{
268 + <div class="sa-card-static mb-4">
269 + <div class="sa-empty" style="padding: var(--sa-space-8) var(--sa-space-4);">
270 + <div class="sa-empty-icon" style="font-size: 2.5rem;"><i class="bi bi-receipt"></i></div>
271 + <div class="sa-empty-title" style="font-size: 1rem;">@Localizer["No expenses yet"]</div>
272 + <p class="sa-empty-text" style="font-size: 0.875rem;">@Localizer["Start tracking your group expenses."]</p>
273 + <a asp-controller="Expenses" asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill">
274 + <i class="bi bi-plus-lg"></i> @Localizer["Add Expense"]
275 + </a>
276 + </div>
277 + </div>
278 +}
279 +
280 +<!-- Description -->
281 +@if (!string.IsNullOrEmpty(Model.Description))
282 +{
283 + <div class="sa-card-static">
284 + <div class="sa-card-header">
285 + <i class="bi bi-text-paragraph me-2"></i>@Localizer["Description"]
286 + </div>
287 + <div class="sa-card-body">
288 + <p style="margin: 0; color: var(--sa-gray-600); line-height: 1.7;">@Model.Description</p>
289 + </div>
290 + </div>
291 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Edit.cshtml +87 −0
@@ -0,0 +1,87 @@
1 +@model SplitApp.WebApp.Application.DTO.TripBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Edit Trip"];
5 +}
6 +
7 +<div class="row justify-content-center">
8 + <div class="col-md-8 col-lg-6">
9 + <div class="sa-card-static sa-card-accent sa-card-accent-secondary">
10 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
11 + <div class="text-center mb-4">
12 + <div style="font-size: 2.5rem; color: var(--sa-secondary); margin-bottom: var(--sa-space-2);">
13 + <i class="bi bi-pencil-square"></i>
14 + </div>
15 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Edit Trip"]</h1>
16 + <p class="sa-text-muted">@Model.Name</p>
17 + </div>
18 +
19 + <form asp-action="Edit" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="Id" />
22 + <input type="hidden" asp-for="CreatedById" />
23 +
24 + <div class="mb-3">
25 + <label asp-for="Name" class="form-label">@Localizer["Name"]</label>
26 + <input asp-for="Name" class="form-control" />
27 + <span asp-validation-for="Name" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="mb-3">
31 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
32 + <textarea asp-for="Description" class="form-control" rows="3"></textarea>
33 + <span asp-validation-for="Description" class="text-danger"></span>
34 + </div>
35 +
36 + <div class="mb-3">
37 + <label asp-for="Destination" class="form-label">
38 + <i class="bi bi-geo-alt me-1"></i>@Localizer["Destination"]
39 + </label>
40 + <input asp-for="Destination" class="form-control" />
41 + <span asp-validation-for="Destination" class="text-danger"></span>
42 + </div>
43 +
44 + <div class="row mb-3">
45 + <div class="col-6">
46 + <label asp-for="StartDate" class="form-label">@Localizer["Start Date"]</label>
47 + <input asp-for="StartDate" type="date" class="form-control" />
48 + <span asp-validation-for="StartDate" class="text-danger"></span>
49 + </div>
50 + <div class="col-6">
51 + <label asp-for="EndDate" class="form-label">@Localizer["End Date"]</label>
52 + <input asp-for="EndDate" type="date" class="form-control" />
53 + <span asp-validation-for="EndDate" class="text-danger"></span>
54 + </div>
55 + </div>
56 +
57 + <div class="mb-3">
58 + <label asp-for="DefaultCurrencyId" class="form-label">
59 + <i class="bi bi-currency-exchange me-1"></i>@Localizer["Default Currency"]
60 + </label>
61 + <select asp-for="DefaultCurrencyId" asp-items="@((SelectList)ViewData["DefaultCurrencyId"]!)" class="form-select">
62 + <option value="">@Localizer["Select currency..."]</option>
63 + </select>
64 + <span asp-validation-for="DefaultCurrencyId" class="text-danger"></span>
65 + </div>
66 +
67 + <div class="mb-4">
68 + <label asp-for="Status" class="form-label">@Localizer["Status"]</label>
69 + <select asp-for="Status" asp-items="Html.GetEnumSelectList<ETripStatus>()" class="form-select"></select>
70 + <span asp-validation-for="Status" class="text-danger"></span>
71 + </div>
72 +
73 + <div class="d-flex gap-3">
74 + <button type="submit" class="sa-btn sa-btn-secondary flex-grow-1">
75 + <i class="bi bi-check-lg"></i> @Localizer["Save"]
76 + </button>
77 + <a asp-action="Details" asp-route-id="@Model.Id" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
78 + </div>
79 + </form>
80 + </div>
81 + </div>
82 + </div>
83 +</div>
84 +
85 +@section Scripts {
86 + <partial name="_ValidationScriptsPartial" />
87 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Index.cshtml +104 −0
@@ -0,0 +1,104 @@
1 +@model List<SplitApp.WebApp.Controllers.TripIndexViewModel>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["My Trips"];
5 +}
6 +
7 +<!-- Page Header -->
8 +<div class="sa-gradient-header">
9 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
10 + <div>
11 + <h1 style="margin-bottom: 4px;">@Localizer["My Trips"]</h1>
12 + <p class="text-muted mb-0">
13 + @Model.Count @Localizer["trip(s)"]
14 + </p>
15 + </div>
16 + <a asp-action="Create" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
17 + <i class="bi bi-plus-lg"></i> @Localizer["Create Trip"]
18 + </a>
19 + </div>
20 +</div>
21 +
22 +@if (!Model.Any())
23 +{
24 + <div class="sa-empty">
25 + <div class="sa-empty-icon">
26 + <i class="bi bi-luggage"></i>
27 + </div>
28 + <div class="sa-empty-title">@Localizer["No trips yet"]</div>
29 + <p class="sa-empty-text">@Localizer["Create your first trip to get started!"]</p>
30 + <a asp-action="Create" class="sa-btn sa-btn-primary sa-btn-pill">
31 + <i class="bi bi-plus-lg"></i> @Localizer["Create Trip"]
32 + </a>
33 + </div>
34 +}
35 +else
36 +{
37 + <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
38 + @{ var index = 0; }
39 + @foreach (var trip in Model)
40 + {
41 + <div class="col sa-animate-slide-up sa-stagger-@(Math.Min(index + 1, 6))">
42 + <a asp-action="Details" asp-route-id="@trip.Id" class="text-decoration-none">
43 + <div class="sa-card sa-trip-card h-100">
44 + <div class="sa-trip-card-gradient"></div>
45 + <div class="sa-card-body">
46 + <div class="d-flex justify-content-between align-items-start mb-3">
47 + <h5 style="font-weight: 700; color: var(--sa-gray-900); margin: 0;">@trip.Name</h5>
48 + @{
49 + var statusClass = trip.Status switch
50 + {
51 + ETripStatus.Active => "sa-badge-success",
52 + ETripStatus.Finalizing => "sa-badge-warning",
53 + ETripStatus.Settled => "sa-badge-info",
54 + _ => "sa-badge-neutral"
55 + };
56 + }
57 + <span class="sa-badge @statusClass">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(trip.Status)</span>
58 + </div>
59 +
60 + @if (!string.IsNullOrEmpty(trip.Destination))
61 + {
62 + <div class="d-flex align-items-center gap-2 mb-2" style="color: var(--sa-gray-600); font-size: 0.9rem;">
63 + <i class="bi bi-geo-alt-fill" style="color: var(--sa-primary);"></i>
64 + @trip.Destination
65 + </div>
66 + }
67 +
68 + @if (trip.StartDate.HasValue || trip.EndDate.HasValue)
69 + {
70 + <div class="d-flex align-items-center gap-2 mb-3" style="color: var(--sa-gray-500); font-size: 0.85rem;">
71 + <i class="bi bi-calendar-event"></i>
72 + @(trip.StartDate?.ToString("MMM dd") ?? "?") — @(trip.EndDate?.ToString("MMM dd, yyyy") ?? "?")
73 + </div>
74 + }
75 +
76 + <div class="d-flex align-items-center gap-2 mt-auto">
77 + @if (trip.Role == EParticipantRole.Organizer)
78 + {
79 + <span class="sa-badge sa-badge-accent">
80 + <i class="bi bi-star-fill" style="font-size: 0.65rem;"></i> @Localizer["Organizer"]
81 + </span>
82 + }
83 + else
84 + {
85 + <span class="sa-badge sa-badge-neutral">@Localizer["Participant"]</span>
86 + }
87 + @if (!string.IsNullOrEmpty(trip.CurrencyCode))
88 + {
89 + <span class="sa-badge sa-badge-neutral">@trip.CurrencyCode</span>
90 + }
91 + </div>
92 + </div>
93 + </div>
94 + </a>
95 + </div>
96 + index++;
97 + }
98 + </div>
99 +}
100 +
101 +<!-- Mobile FAB -->
102 +<a asp-action="Create" class="sa-fab sa-hide-desktop" title="@Localizer["Create Trip"]">
103 + <i class="bi bi-plus-lg"></i>
104 +</a>
added SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Create.cshtml +85 −0
@@ -0,0 +1,85 @@
1 +@model SplitApp.WebApp.Application.DTO.TripWishlistItemBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Add Wishlist Item"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-accent">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-accent); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-star-fill"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Add Wishlist Item"]</h1>
17 + <p class="sa-text-muted">@Localizer["Share what you want to experience"]</p>
18 + </div>
19 +
20 + <form asp-action="Create" data-sa-loading>
21 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
22 + <input type="hidden" asp-for="TripId" />
23 +
24 + <div class="mb-3">
25 + <label asp-for="Title" class="form-label">@Localizer["Title"]</label>
26 + <input asp-for="Title" class="form-control" placeholder="@Localizer["e.g. Sagrada Familia, Local tapas bar"]" />
27 + <span asp-validation-for="Title" class="text-danger"></span>
28 + </div>
29 +
30 + <div class="mb-3">
31 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
32 + <textarea asp-for="Description" class="form-control" rows="3" placeholder="@Localizer["Why do you want to go here?"]"></textarea>
33 + <span asp-validation-for="Description" class="text-danger"></span>
34 + </div>
35 +
36 + <div class="row mb-3">
37 + <div class="col-md-6 mb-3 mb-md-0">
38 + <label asp-for="Category" class="form-label">@Localizer["Category"]</label>
39 + <select asp-for="Category" asp-items="@((SelectList)ViewData["Categories"]!)" class="form-select"></select>
40 + <span asp-validation-for="Category" class="text-danger"></span>
41 + </div>
42 + <div class="col-md-6">
43 + <label asp-for="Priority" class="form-label">@Localizer["Priority"]</label>
44 + <select asp-for="Priority" asp-items="@((SelectList)ViewData["Priorities"]!)" class="form-select"></select>
45 + <span asp-validation-for="Priority" class="text-danger"></span>
46 + </div>
47 + </div>
48 +
49 + <div class="mb-3">
50 + <label asp-for="EstimatedCost" class="form-label">@Localizer["Estimated Cost"]</label>
51 + <input asp-for="EstimatedCost" type="number" step="0.01" min="0" class="form-control" placeholder="0.00" />
52 + <span asp-validation-for="EstimatedCost" class="text-danger"></span>
53 + </div>
54 +
55 + <div class="mb-3">
56 + <label asp-for="Location" class="form-label">
57 + <i class="bi bi-geo-alt me-1"></i>@Localizer["Location"]
58 + </label>
59 + <input asp-for="Location" class="form-control" placeholder="@Localizer["e.g. Old Town, City Center"]" />
60 + <span asp-validation-for="Location" class="text-danger"></span>
61 + </div>
62 +
63 + <div class="mb-4">
64 + <label asp-for="Url" class="form-label">
65 + <i class="bi bi-link-45deg me-1"></i>@Localizer["URL"]
66 + </label>
67 + <input asp-for="Url" type="url" class="form-control" placeholder="https://..." />
68 + <span asp-validation-for="Url" class="text-danger"></span>
69 + </div>
70 +
71 + <div class="d-flex gap-3">
72 + <button type="submit" class="sa-btn sa-btn-accent flex-grow-1">
73 + <i class="bi bi-check-lg"></i> @Localizer["Add Item"]
74 + </button>
75 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
76 + </div>
77 + </form>
78 + </div>
79 + </div>
80 + </div>
81 +</div>
82 +
83 +@section Scripts {
84 + <partial name="_ValidationScriptsPartial" />
85 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Delete.cshtml +45 −0
@@ -0,0 +1,45 @@
1 +@model SplitApp.WebApp.Application.DTO.TripWishlistItemBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Delete Wishlist Item"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-danger); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-trash3"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Delete Wishlist Item"]</h1>
17 + <p class="sa-text-muted">@Localizer["AreYouSure"]</p>
18 + </div>
19 +
20 + <div class="sa-card-static mb-4">
21 + <div class="sa-card-body">
22 + <h5>@Model.Title</h5>
23 + @if (!string.IsNullOrEmpty(Model.Description))
24 + {
25 + <p class="sa-text-muted">@Model.Description</p>
26 + }
27 + @if (Model.EstimatedCost.HasValue)
28 + {
29 + <p>@Localizer["Estimated Cost"]: @Model.EstimatedCost.Value.ToString("N2")</p>
30 + }
31 + </div>
32 + </div>
33 +
34 + <form asp-action="Delete" data-sa-loading>
35 + <div class="d-flex gap-3">
36 + <button type="submit" class="sa-btn flex-grow-1" style="background: var(--sa-danger); color: #fff;">
37 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
38 + </button>
39 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
40 + </div>
41 + </form>
42 + </div>
43 + </div>
44 + </div>
45 +</div>
added SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Edit.cshtml +77 −0
@@ -0,0 +1,77 @@
1 +@model SplitApp.WebApp.Application.DTO.TripWishlistItemBllDto
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Edit Wishlist Item"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 +}
7 +
8 +<div class="row justify-content-center">
9 + <div class="col-md-8 col-lg-6">
10 + <div class="sa-card-static sa-card-accent sa-card-accent-accent">
11 + <div class="sa-card-body" style="padding: var(--sa-space-8);">
12 + <div class="text-center mb-4">
13 + <div style="font-size: 2rem; color: var(--sa-accent); margin-bottom: var(--sa-space-2);">
14 + <i class="bi bi-pencil-square"></i>
15 + </div>
16 + <h1 style="font-size: 1.5rem; margin-bottom: 4px;">@Localizer["Edit Wishlist Item"]</h1>
17 + </div>
18 +
19 + <form asp-action="Edit" data-sa-loading>
20 + <div asp-validation-summary="ModelOnly" class="text-danger"></div>
21 + <input type="hidden" asp-for="Id" />
22 + <input type="hidden" asp-for="TripId" />
23 + <input type="hidden" asp-for="AddedByUserId" />
24 +
25 + <div class="mb-3">
26 + <label asp-for="Title" class="form-label">@Localizer["Title"]</label>
27 + <input asp-for="Title" class="form-control" />
28 + <span asp-validation-for="Title" class="text-danger"></span>
29 + </div>
30 +
31 + <div class="mb-3">
32 + <label asp-for="Description" class="form-label">@Localizer["Description"]</label>
33 + <textarea asp-for="Description" class="form-control" rows="3"></textarea>
34 + <span asp-validation-for="Description" class="text-danger"></span>
35 + </div>
36 +
37 + <div class="row mb-3">
38 + <div class="col-md-6 mb-3 mb-md-0">
39 + <label asp-for="Category" class="form-label">@Localizer["Category"]</label>
40 + <select asp-for="Category" asp-items="@((SelectList)ViewData["Categories"]!)" class="form-select"></select>
41 + </div>
42 + <div class="col-md-6">
43 + <label asp-for="Priority" class="form-label">@Localizer["Priority"]</label>
44 + <select asp-for="Priority" asp-items="@((SelectList)ViewData["Priorities"]!)" class="form-select"></select>
45 + </div>
46 + </div>
47 +
48 + <div class="mb-3">
49 + <label asp-for="EstimatedCost" class="form-label">@Localizer["Estimated Cost"]</label>
50 + <input asp-for="EstimatedCost" type="number" step="0.01" min="0" class="form-control" />
51 + </div>
52 +
53 + <div class="mb-3">
54 + <label asp-for="Location" class="form-label"><i class="bi bi-geo-alt me-1"></i>@Localizer["Location"]</label>
55 + <input asp-for="Location" class="form-control" />
56 + </div>
57 +
58 + <div class="mb-4">
59 + <label asp-for="Url" class="form-label"><i class="bi bi-link-45deg me-1"></i>@Localizer["URL"]</label>
60 + <input asp-for="Url" type="url" class="form-control" />
61 + </div>
62 +
63 + <div class="d-flex gap-3">
64 + <button type="submit" class="sa-btn sa-btn-accent flex-grow-1">
65 + <i class="bi bi-check-lg"></i> @Localizer["Save"]
66 + </button>
67 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
68 + </div>
69 + </form>
70 + </div>
71 + </div>
72 + </div>
73 +</div>
74 +
75 +@section Scripts {
76 + <partial name="_ValidationScriptsPartial" />
77 +}
added SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Index.cshtml +147 −0
@@ -0,0 +1,147 @@
1 +@model List<SplitApp.WebApp.Controllers.WishlistItemViewModel>
2 +
3 +@{
4 + ViewData["Title"] = Localizer["Wishlist"];
5 + var tripId = (Guid)ViewData["TripId"]!;
6 + var tripName = (string)ViewData["TripName"]!;
7 + var currentUserId = (Guid)ViewData["CurrentUserId"]!;
8 +}
9 +
10 +<!-- Page Header -->
11 +<div class="sa-gradient-header" style="background: linear-gradient(135deg, var(--sa-accent) 0%, #f97316 100%);">
12 + <div class="d-flex justify-content-between align-items-center flex-wrap gap-3">
13 + <div>
14 + <h1 style="margin-bottom: 4px;">@Localizer["Wishlist"]</h1>
15 + <p class="text-muted mb-0">@tripName</p>
16 + </div>
17 + <div class="d-flex gap-2">
18 + <a asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
19 + <i class="bi bi-plus-lg"></i> @Localizer["Add Item"]
20 + </a>
21 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
22 + <i class="bi bi-arrow-left"></i>
23 + </a>
24 + </div>
25 + </div>
26 +</div>
27 +
28 +@if (!Model.Any())
29 +{
30 + <div class="sa-empty">
31 + <div class="sa-empty-icon"><i class="bi bi-star"></i></div>
32 + <div class="sa-empty-title">@Localizer["No wishlist items yet"]</div>
33 + <p class="sa-empty-text">@Localizer["Add places, activities, or restaurants you want to visit."]</p>
34 + <a asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-accent sa-btn-pill">
35 + <i class="bi bi-plus-lg"></i> @Localizer["Add Item"]
36 + </a>
37 + </div>
38 +}
39 +else
40 +{
41 + <div class="row g-3">
42 + @{ var idx = 0; }
43 + @foreach (var item in Model.OrderByDescending(i => i.VoteCount).ThenBy(i => i.IsCompleted))
44 + {
45 + var categoryColor = item.Category switch
46 + {
47 + EWishlistCategory.Place => "var(--sa-info)",
48 + EWishlistCategory.Activity => "var(--sa-success)",
49 + EWishlistCategory.Restaurant => "var(--sa-accent)",
50 + _ => "var(--sa-gray-400)"
51 + };
52 + var categoryBadge = item.Category switch
53 + {
54 + EWishlistCategory.Place => "sa-badge-info",
55 + EWishlistCategory.Activity => "sa-badge-success",
56 + EWishlistCategory.Restaurant => "sa-badge-accent",
57 + _ => "sa-badge-neutral"
58 + };
59 + var priorityBadge = item.Priority switch
60 + {
61 + EWishlistPriority.MustDo => "sa-badge-danger",
62 + EWishlistPriority.NiceToHave => "sa-badge-info",
63 + _ => "sa-badge-neutral"
64 + };
65 +
66 + <div class="col-md-6 col-lg-4 sa-animate-slide-up sa-stagger-@(Math.Min(idx + 1, 6))">
67 + <div class="sa-card h-100 @(item.IsCompleted ? "" : "")" style="@(item.IsCompleted ? "opacity: 0.7;" : "")">
68 + <div style="height: 4px; background: @categoryColor;"></div>
69 + <div class="sa-card-body">
70 + <div class="d-flex justify-content-between align-items-start mb-2">
71 + <h5 style="font-weight: 700; font-size: 1rem; margin: 0; @(item.IsCompleted ? "text-decoration: line-through; color: var(--sa-gray-400);" : "")">
72 + @if (item.IsCompleted) { <i class="bi bi-check-circle-fill me-1" style="color: var(--sa-success);"></i> }
73 + @item.Title
74 + </h5>
75 + <span class="sa-badge @categoryBadge" style="flex-shrink: 0;">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Category)</span>
76 + </div>
77 +
78 + @if (!string.IsNullOrEmpty(item.Description))
79 + {
80 + <p style="font-size: 0.875rem; color: var(--sa-gray-600); margin-bottom: var(--sa-space-3); line-height: 1.5;">
81 + @item.Description
82 + </p>
83 + }
84 +
85 + <div class="d-flex flex-wrap gap-2 mb-3">
86 + <span class="sa-badge @priorityBadge">@SplitApp.WebApp.Hosting.Helpers.EnumHelper.GetDisplayName(item.Priority)</span>
87 + @if (item.EstimatedCost.HasValue)
88 + {
89 + <span class="sa-badge sa-badge-neutral">~@item.EstimatedCost.Value.ToString("N2")</span>
90 + }
91 + </div>
92 +
93 + @if (!string.IsNullOrEmpty(item.Location))
94 + {
95 + <div style="font-size: 0.85rem; color: var(--sa-gray-500); margin-bottom: 4px;">
96 + <i class="bi bi-geo-alt-fill me-1" style="color: var(--sa-primary);"></i> @item.Location
97 + </div>
98 + }
99 +
100 + @if (!string.IsNullOrEmpty(item.Url))
101 + {
102 + <div style="font-size: 0.85rem; margin-bottom: 4px;">
103 + <a href="@item.Url" target="_blank" rel="noopener" style="color: var(--sa-secondary);">
104 + <i class="bi bi-link-45deg me-1"></i>@Localizer["View Link"]
105 + </a>
106 + </div>
107 + }
108 +
109 + <div style="font-size: 0.8rem; color: var(--sa-gray-400); margin-top: var(--sa-space-2);">
110 + @Localizer["Added by"] @item.AddedByName
111 + </div>
112 + </div>
113 + <div class="sa-card-footer d-flex justify-content-between align-items-center">
114 + <form asp-action="Vote" asp-route-id="@item.Id" method="post" style="display:inline">
115 + <button type="submit" class="sa-vote-btn @(item.UserHasVoted ? "sa-vote-btn-active" : "")">
116 + <i class="bi @(item.UserHasVoted ? "bi-heart-fill" : "bi-heart")"></i>
117 + @item.VoteCount
118 + </button>
119 + </form>
120 + <div class="d-flex align-items-center gap-2">
121 + <form asp-action="Complete" asp-route-id="@item.Id" method="post" style="display:inline">
122 + <button type="submit" class="sa-btn sa-btn-sm sa-btn-pill @(item.IsCompleted ? "sa-btn-success" : "sa-btn-ghost")">
123 + <i class="bi @(item.IsCompleted ? "bi-check-circle-fill" : "bi-circle")"></i>
124 + @(item.IsCompleted ? Localizer["Done"] : Localizer["Mark Done"])
125 + </button>
126 + </form>
127 + @if (item.AddedByUserId == currentUserId)
128 + {
129 + <a asp-action="Edit" asp-route-id="@item.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Edit"]">
130 + <i class="bi bi-pencil"></i>
131 + </a>
132 + <a asp-action="Delete" asp-route-id="@item.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Delete"]" style="color: var(--sa-danger);">
133 + <i class="bi bi-trash3"></i>
134 + </a>
135 + }
136 + </div>
137 + </div>
138 + </div>
139 + </div>
140 + idx++;
141 + }
142 + </div>
143 +}
144 +
145 +<a asp-action="Create" asp-route-tripId="@tripId" class="sa-fab sa-hide-desktop" title="@Localizer["Add Item"]">
146 + <i class="bi bi-plus-lg"></i>
147 +</a>
added SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewImports.cshtml +14 −0
@@ -0,0 +1,14 @@
1 +@using SplitApp.WebApp
2 +@using SplitApp.WebApp.Models
3 +@using SplitApp.WebApp.Hosting.Helpers
4 +@using SplitApp.WebApp.Application.DTO
5 +@using SplitApp.WebApp.Controllers
6 +@using SplitApp.Modules.Trips.Domain.Entities
7 +@using SplitApp.Modules.Trips.Domain.Enums
8 +@using SplitApp.Modules.Expenses.Domain.Entities
9 +@using SplitApp.Modules.Expenses.Domain.Enums
10 +@using SplitApp.Shared.Kernel.Localization
11 +@using Microsoft.Extensions.Localization
12 +@using Microsoft.AspNetCore.Mvc.Localization
13 +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
14 +@inject IStringLocalizer<App.Resources.Views.Shared> Localizer
added SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewStart.cshtml +3 −0
@@ -0,0 +1,3 @@
1 +@{
2 + Layout = "_Layout";
3 +}
added SplitApp.Modular/src/SplitApp.WebApp/appsettings.json +39 −0
@@ -0,0 +1,39 @@
1 +{
2 + "ConnectionStrings": {
3 + "DefaultConnection": "Host=localhost;Port=5432;Database=splitapp;Username=postgres;Password=postgres"
4 + },
5 + "DataInitialization": {
6 + "DropDatabase": false,
7 + "MigrateDatabase": true,
8 + "SeedData": true
9 + },
10 + "SupportedCultures": ["en", "et"],
11 + "DefaultCulture": "en",
12 + "LangStrDefaultCulture": "en",
13 + "JWT": {
14 + "Key": "dev-only-signing-key-override-in-production-0123456789",
15 + "Issuer": "splitapp",
16 + "Audience": "splitapp",
17 + "ExpiresInSeconds": 1800
18 + },
19 + "UsersService": {
20 + "BaseUrl": "http://localhost:98"
21 + },
22 + "Messaging": {
23 + "RabbitMq": {
24 + "HostName": "localhost",
25 + "Port": 5672,
26 + "UserName": "guest",
27 + "Password": "guest",
28 + "VirtualHost": "/",
29 + "RpcReplyTimeoutSeconds": 10
30 + }
31 + },
32 + "Logging": {
33 + "LogLevel": {
34 + "Default": "Information",
35 + "Microsoft.AspNetCore": "Warning"
36 + }
37 + },
38 + "AllowedHosts": "*"
39 +}
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/admin.css +439 −0
@@ -0,0 +1,439 @@
1 +/* Admin area styles — sidebar layout, metric cards, badges */
2 +
3 +:root {
4 + --admin-sidebar-width: 256px;
5 + --admin-sidebar-bg: #1f2430;
6 + --admin-sidebar-bg-hover: #2a3040;
7 + --admin-sidebar-text: #cbd1dc;
8 + --admin-sidebar-text-muted: #7b8596;
9 + --admin-sidebar-active: #e8604c;
10 + --admin-topbar-height: 62px;
11 + --admin-bg: #f5f6fa;
12 + --admin-card-border: #e6e8ef;
13 + --admin-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
14 + --admin-shadow-md: 0 6px 24px -8px rgba(15, 23, 42, 0.12);
15 +}
16 +
17 +.admin-body {
18 + background: var(--admin-bg);
19 + font-family: 'Inter', system-ui, sans-serif;
20 +}
21 +
22 +.admin-shell {
23 + display: flex;
24 + min-height: 100vh;
25 +}
26 +
27 +/* === SIDEBAR === */
28 +.admin-sidebar {
29 + width: var(--admin-sidebar-width);
30 + background: var(--admin-sidebar-bg);
31 + color: var(--admin-sidebar-text);
32 + display: flex;
33 + flex-direction: column;
34 + position: fixed;
35 + top: 0;
36 + left: 0;
37 + bottom: 0;
38 + z-index: 1040;
39 + transition: transform 0.25s ease;
40 +}
41 +
42 +.admin-sidebar-brand {
43 + padding: 1.25rem 1.25rem 1rem;
44 + border-bottom: 1px solid rgba(255, 255, 255, 0.06);
45 + display: flex;
46 + align-items: center;
47 + gap: 0.55rem;
48 + color: #fff;
49 + font-weight: 700;
50 + font-size: 1.15rem;
51 +}
52 +
53 +.admin-sidebar-brand i {
54 + color: var(--admin-sidebar-active);
55 + font-size: 1.35rem;
56 +}
57 +
58 +.admin-sidebar-brand-sub {
59 + margin-left: auto;
60 + font-size: 0.65rem;
61 + letter-spacing: 0.18em;
62 + text-transform: uppercase;
63 + color: var(--admin-sidebar-text-muted);
64 + font-weight: 600;
65 +}
66 +
67 +.admin-sidebar-nav {
68 + flex: 1;
69 + overflow-y: auto;
70 + padding: 0.75rem 0.5rem 1rem;
71 +}
72 +
73 +.admin-nav-section {
74 + font-size: 0.68rem;
75 + letter-spacing: 0.16em;
76 + text-transform: uppercase;
77 + color: var(--admin-sidebar-text-muted);
78 + padding: 1rem 0.8rem 0.35rem;
79 + font-weight: 600;
80 +}
81 +
82 +.admin-nav-link {
83 + display: flex;
84 + align-items: center;
85 + gap: 0.7rem;
86 + padding: 0.55rem 0.8rem;
87 + margin: 0.1rem 0;
88 + color: var(--admin-sidebar-text);
89 + text-decoration: none;
90 + border-radius: 8px;
91 + font-size: 0.92rem;
92 + transition: background 0.15s, color 0.15s;
93 +}
94 +
95 +.admin-nav-link i {
96 + font-size: 1.05rem;
97 + width: 1.1rem;
98 + text-align: center;
99 + color: var(--admin-sidebar-text-muted);
100 +}
101 +
102 +.admin-nav-link:hover {
103 + background: var(--admin-sidebar-bg-hover);
104 + color: #fff;
105 + text-decoration: none;
106 +}
107 +
108 +.admin-nav-link:hover i {
109 + color: #fff;
110 +}
111 +
112 +.admin-nav-link.active {
113 + background: var(--admin-sidebar-active);
114 + color: #fff;
115 + font-weight: 600;
116 +}
117 +
118 +.admin-nav-link.active i {
119 + color: #fff;
120 +}
121 +
122 +.admin-sidebar-footer {
123 + padding: 0.9rem 1rem 1.2rem;
124 + border-top: 1px solid rgba(255, 255, 255, 0.06);
125 +}
126 +
127 +.admin-back-link {
128 + color: var(--admin-sidebar-text-muted);
129 + text-decoration: none;
130 + font-size: 0.88rem;
131 + display: inline-flex;
132 + align-items: center;
133 + gap: 0.4rem;
134 +}
135 +
136 +.admin-back-link:hover {
137 + color: #fff;
138 +}
139 +
140 +/* === MAIN AREA === */
141 +.admin-main {
142 + margin-left: var(--admin-sidebar-width);
143 + flex: 1;
144 + display: flex;
145 + flex-direction: column;
146 + min-width: 0;
147 +}
148 +
149 +.admin-topbar {
150 + height: var(--admin-topbar-height);
151 + background: #fff;
152 + border-bottom: 1px solid var(--admin-card-border);
153 + display: flex;
154 + align-items: center;
155 + padding: 0 1.5rem;
156 + gap: 1rem;
157 + position: sticky;
158 + top: 0;
159 + z-index: 1030;
160 +}
161 +
162 +.admin-topbar-toggle {
163 + background: transparent;
164 + border: none;
165 + font-size: 1.4rem;
166 + color: #334155;
167 +}
168 +
169 +.admin-topbar-title {
170 + font-size: 1.05rem;
171 + font-weight: 600;
172 + color: #334155;
173 + display: flex;
174 + align-items: center;
175 + gap: 0.5rem;
176 +}
177 +
178 +.admin-topbar-title i {
179 + color: var(--admin-sidebar-active);
180 +}
181 +
182 +.admin-topbar-actions {
183 + margin-left: auto;
184 + display: flex;
185 + align-items: center;
186 + gap: 1rem;
187 +}
188 +
189 +.admin-content {
190 + padding: 1.5rem 1.75rem 2.5rem;
191 + flex: 1;
192 +}
193 +
194 +.admin-footer {
195 + padding: 1rem 1.75rem;
196 + font-size: 0.82rem;
197 + color: #64748b;
198 + border-top: 1px solid var(--admin-card-border);
199 + background: #fff;
200 + display: flex;
201 + justify-content: space-between;
202 + align-items: center;
203 + flex-wrap: wrap;
204 + gap: 0.5rem;
205 +}
206 +
207 +/* === PAGE HEADERS === */
208 +.admin-page-header {
209 + display: flex;
210 + justify-content: space-between;
211 + align-items: flex-start;
212 + gap: 1rem;
213 + margin-bottom: 1.5rem;
214 + flex-wrap: wrap;
215 +}
216 +
217 +.admin-page-header h1 {
218 + margin: 0 0 0.25rem;
219 + font-size: 1.65rem;
220 + font-weight: 700;
221 + color: #0f172a;
222 +}
223 +
224 +.admin-page-header .lead {
225 + margin: 0;
226 + color: #64748b;
227 + font-size: 0.95rem;
228 +}
229 +
230 +/* === CARDS === */
231 +.admin-card {
232 + background: #fff;
233 + border: 1px solid var(--admin-card-border);
234 + border-radius: 12px;
235 + box-shadow: var(--admin-shadow-sm);
236 + margin-bottom: 1rem;
237 + overflow: hidden;
238 +}
239 +
240 +.admin-card-header {
241 + padding: 0.9rem 1.15rem;
242 + border-bottom: 1px solid var(--admin-card-border);
243 + display: flex;
244 + align-items: center;
245 + justify-content: space-between;
246 + gap: 0.5rem;
247 + font-weight: 600;
248 + color: #334155;
249 +}
250 +
251 +.admin-card-header i {
252 + color: var(--admin-sidebar-active);
253 + margin-right: 0.4rem;
254 +}
255 +
256 +.admin-card-body {
257 + padding: 1.15rem;
258 +}
259 +
260 +/* === METRIC CARDS === */
261 +.admin-metric {
262 + background: #fff;
263 + border: 1px solid var(--admin-card-border);
264 + border-radius: 12px;
265 + padding: 1.1rem 1.25rem;
266 + box-shadow: var(--admin-shadow-sm);
267 + display: flex;
268 + align-items: center;
269 + gap: 1rem;
270 + height: 100%;
271 + transition: transform 0.15s, box-shadow 0.15s;
272 +}
273 +
274 +.admin-metric:hover {
275 + transform: translateY(-2px);
276 + box-shadow: var(--admin-shadow-md);
277 +}
278 +
279 +.admin-metric-icon {
280 + width: 48px;
281 + height: 48px;
282 + border-radius: 10px;
283 + display: flex;
284 + align-items: center;
285 + justify-content: center;
286 + font-size: 1.4rem;
287 + color: #fff;
288 + flex-shrink: 0;
289 +}
290 +
291 +.admin-metric-icon.bg-primary { background: #3b82f6; }
292 +.admin-metric-icon.bg-success { background: #10b981; }
293 +.admin-metric-icon.bg-warning { background: #f59e0b; }
294 +.admin-metric-icon.bg-danger { background: #ef4444; }
295 +.admin-metric-icon.bg-info { background: #06b6d4; }
296 +.admin-metric-icon.bg-purple { background: #8b5cf6; }
297 +
298 +.admin-metric-value {
299 + font-size: 1.65rem;
300 + font-weight: 700;
301 + color: #0f172a;
302 + line-height: 1.1;
303 +}
304 +
305 +.admin-metric-label {
306 + color: #64748b;
307 + font-size: 0.85rem;
308 + margin-top: 0.15rem;
309 +}
310 +
311 +/* === TABLES === */
312 +.admin-table {
313 + margin-bottom: 0;
314 +}
315 +
316 +.admin-table thead th {
317 + background: #f8fafc;
318 + border-bottom: 2px solid var(--admin-card-border);
319 + color: #475569;
320 + font-size: 0.82rem;
321 + text-transform: uppercase;
322 + letter-spacing: 0.04em;
323 + font-weight: 600;
324 + padding: 0.75rem 1rem;
325 +}
326 +
327 +.admin-table tbody td {
328 + padding: 0.75rem 1rem;
329 + vertical-align: middle;
330 + border-color: #f1f5f9;
331 +}
332 +
333 +.admin-table tbody tr:hover {
334 + background: #f8fafc;
335 +}
336 +
337 +/* === BADGES === */
338 +.badge.status-active { background-color: #10b981; }
339 +.badge.status-settled { background-color: #3b82f6; }
340 +.badge.status-archived { background-color: #64748b; }
341 +.badge.status-pending { background-color: #f59e0b; color: #fff; }
342 +.badge.status-inprogress { background-color: #06b6d4; color: #fff; }
343 +.badge.status-completed { background-color: #10b981; }
344 +.badge.status-accepted { background-color: #10b981; }
345 +.badge.status-declined { background-color: #ef4444; }
346 +.badge.status-expired { background-color: #64748b; }
347 +.badge.status-markedpaid { background-color: #06b6d4; color: #fff; }
348 +.badge.status-confirmed { background-color: #10b981; }
349 +
350 +/* === ACTION BUTTONS === */
351 +.admin-action-group {
352 + display: inline-flex;
353 + gap: 0.25rem;
354 +}
355 +
356 +.admin-action-group .btn {
357 + padding: 0.3rem 0.55rem;
358 + border-radius: 6px;
359 + font-size: 0.82rem;
360 +}
361 +
362 +/* === TIMELINE / FEED === */
363 +.admin-feed {
364 + list-style: none;
365 + margin: 0;
366 + padding: 0;
367 +}
368 +
369 +.admin-feed-item {
370 + display: flex;
371 + gap: 0.85rem;
372 + padding: 0.7rem 0;
373 + border-bottom: 1px solid #f1f5f9;
374 +}
375 +
376 +.admin-feed-item:last-child {
377 + border-bottom: none;
378 +}
379 +
380 +.admin-feed-icon {
381 + width: 34px;
382 + height: 34px;
383 + border-radius: 50%;
384 + display: flex;
385 + align-items: center;
386 + justify-content: center;
387 + color: #fff;
388 + font-size: 0.9rem;
389 + flex-shrink: 0;
390 +}
391 +
392 +.admin-feed-body {
393 + flex: 1;
394 + min-width: 0;
395 +}
396 +
397 +.admin-feed-message {
398 + color: #334155;
399 + font-size: 0.9rem;
400 +}
401 +
402 +.admin-feed-date {
403 + color: #94a3b8;
404 + font-size: 0.78rem;
405 + margin-top: 0.1rem;
406 +}
407 +
408 +/* === EMPTY STATE === */
409 +.admin-empty {
410 + text-align: center;
411 + padding: 2.5rem 1rem;
412 + color: #94a3b8;
413 +}
414 +
415 +.admin-empty i {
416 + font-size: 2.4rem;
417 + display: block;
418 + margin-bottom: 0.5rem;
419 + color: #cbd5e1;
420 +}
421 +
422 +/* === RESPONSIVE === */
423 +@media (max-width: 767.98px) {
424 + .admin-sidebar {
425 + transform: translateX(-100%);
426 + }
427 +
428 + body.admin-sidebar-open .admin-sidebar {
429 + transform: translateX(0);
430 + }
431 +
432 + .admin-main {
433 + margin-left: 0;
434 + }
435 +
436 + .admin-content {
437 + padding: 1rem;
438 + }
439 +}
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/site.css +31 −0
@@ -0,0 +1,31 @@
1 +html {
2 + font-size: 14px;
3 +}
4 +
5 +@media (min-width: 768px) {
6 + html {
7 + font-size: 16px;
8 + }
9 +}
10 +
11 +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
12 + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
13 +}
14 +
15 +html {
16 + position: relative;
17 + min-height: 100%;
18 +}
19 +
20 +body {
21 + margin-bottom: 60px;
22 +}
23 +
24 +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
25 + color: var(--bs-secondary-color);
26 + text-align: end;
27 +}
28 +
29 +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
30 + text-align: start;
31 +}
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/splitapp-design.css +1605 −0
@@ -0,0 +1,1605 @@
1 +/* ============================================================
2 + SplitApp Design System
3 + A premium travel app design layer on top of Bootstrap 5
4 + ============================================================ */
5 +
6 +/* ---------- CSS Custom Properties ---------- */
7 +:root {
8 + /* Primary: Coral-to-Rose */
9 + --sa-primary: #e8604c;
10 + --sa-primary-light: #ff7e6b;
11 + --sa-primary-dark: #c94535;
12 + --sa-primary-gradient: linear-gradient(135deg, #e8604c 0%, #d4456a 100%);
13 +
14 + /* Secondary: Ocean Teal */
15 + --sa-secondary: #1a9e8f;
16 + --sa-secondary-light: #2ec4b6;
17 + --sa-secondary-dark: #147a6e;
18 + --sa-secondary-gradient: linear-gradient(135deg, #1a9e8f 0%, #2176ae 100%);
19 +
20 + /* Accent: Golden Amber */
21 + --sa-accent: #f4a623;
22 + --sa-accent-light: #ffc857;
23 + --sa-accent-dark: #d48e15;
24 +
25 + /* Neutrals: Warm Grays */
26 + --sa-gray-50: #faf9f7;
27 + --sa-gray-100: #f3f1ed;
28 + --sa-gray-200: #e8e5df;
29 + --sa-gray-300: #d4d0c8;
30 + --sa-gray-400: #a8a29e;
31 + --sa-gray-500: #78716c;
32 + --sa-gray-600: #57534e;
33 + --sa-gray-700: #44403c;
34 + --sa-gray-800: #292524;
35 + --sa-gray-900: #1c1917;
36 +
37 + /* Semantic */
38 + --sa-success: #22c55e;
39 + --sa-success-light: #dcfce7;
40 + --sa-warning: #f59e0b;
41 + --sa-warning-light: #fef3c7;
42 + --sa-danger: #ef4444;
43 + --sa-danger-light: #fee2e2;
44 + --sa-info: #3b82f6;
45 + --sa-info-light: #dbeafe;
46 +
47 + /* Spacing (4px base) */
48 + --sa-space-1: 4px;
49 + --sa-space-2: 8px;
50 + --sa-space-3: 12px;
51 + --sa-space-4: 16px;
52 + --sa-space-5: 20px;
53 + --sa-space-6: 24px;
54 + --sa-space-8: 32px;
55 + --sa-space-10: 40px;
56 + --sa-space-12: 48px;
57 + --sa-space-16: 64px;
58 +
59 + /* Border Radius */
60 + --sa-radius-sm: 6px;
61 + --sa-radius-md: 12px;
62 + --sa-radius-lg: 16px;
63 + --sa-radius-xl: 24px;
64 + --sa-radius-full: 9999px;
65 +
66 + /* Shadows (warm-tinted) */
67 + --sa-shadow-sm: 0 1px 3px rgba(28, 25, 23, 0.06), 0 1px 2px rgba(28, 25, 23, 0.04);
68 + --sa-shadow-md: 0 4px 12px rgba(28, 25, 23, 0.08), 0 2px 4px rgba(28, 25, 23, 0.04);
69 + --sa-shadow-lg: 0 12px 32px rgba(28, 25, 23, 0.1), 0 4px 8px rgba(28, 25, 23, 0.06);
70 + --sa-shadow-xl: 0 20px 48px rgba(28, 25, 23, 0.14), 0 8px 16px rgba(28, 25, 23, 0.06);
71 +
72 + /* Transitions */
73 + --sa-transition-fast: 150ms ease;
74 + --sa-transition-normal: 250ms ease;
75 + --sa-transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
76 +
77 + /* Typography */
78 + --sa-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
79 + --sa-font-mono: 'JetBrains Mono', 'Fira Code', monospace;
80 +}
81 +
82 +/* ---------- Base Overrides ---------- */
83 +html {
84 + scroll-behavior: smooth;
85 +}
86 +
87 +body {
88 + font-family: var(--sa-font-sans);
89 + background-color: var(--sa-gray-50);
90 + color: var(--sa-gray-800);
91 + -webkit-font-smoothing: antialiased;
92 + -moz-osx-font-smoothing: grayscale;
93 + margin-bottom: 0;
94 +}
95 +
96 +h1, h2, h3, h4, h5, h6 {
97 + font-weight: 700;
98 + color: var(--sa-gray-900);
99 + letter-spacing: -0.02em;
100 +}
101 +
102 +h1 { font-size: 2rem; }
103 +h2 { font-size: 1.5rem; }
104 +h3 { font-size: 1.25rem; }
105 +
106 +a {
107 + color: var(--sa-secondary);
108 + text-decoration: none;
109 + transition: color var(--sa-transition-fast);
110 +}
111 +
112 +a:hover {
113 + color: var(--sa-secondary-dark);
114 +}
115 +
116 +/* Focus rings */
117 +:focus-visible {
118 + outline: 2px solid var(--sa-secondary);
119 + outline-offset: 2px;
120 + box-shadow: none;
121 +}
122 +
123 +.btn:focus-visible, .form-control:focus-visible, .form-select:focus-visible, .form-check-input:focus-visible {
124 + box-shadow: 0 0 0 3px rgba(26, 158, 143, 0.25);
125 + border-color: var(--sa-secondary);
126 +}
127 +
128 +/* ---------- Form Enhancements ---------- */
129 +.form-control, .form-select {
130 + border-radius: var(--sa-radius-sm);
131 + border: 1.5px solid var(--sa-gray-200);
132 + padding: 10px 14px;
133 + font-size: 0.938rem;
134 + transition: border-color var(--sa-transition-fast), box-shadow var(--sa-transition-fast);
135 + background-color: #fff;
136 +}
137 +
138 +.form-control:focus, .form-select:focus {
139 + border-color: var(--sa-secondary);
140 + box-shadow: 0 0 0 3px rgba(26, 158, 143, 0.15);
141 +}
142 +
143 +.form-label {
144 + font-weight: 600;
145 + font-size: 0.85rem;
146 + color: var(--sa-gray-600);
147 + text-transform: uppercase;
148 + letter-spacing: 0.04em;
149 + margin-bottom: 6px;
150 +}
151 +
152 +.form-check-input:checked {
153 + background-color: var(--sa-secondary);
154 + border-color: var(--sa-secondary);
155 +}
156 +
157 +/* ---------- sa-card ---------- */
158 +.sa-card {
159 + background: #fff;
160 + border-radius: var(--sa-radius-md);
161 + box-shadow: var(--sa-shadow-sm);
162 + border: 1px solid var(--sa-gray-100);
163 + overflow: hidden;
164 + transition: transform var(--sa-transition-normal), box-shadow var(--sa-transition-normal);
165 +}
166 +
167 +.sa-card:hover {
168 + transform: translateY(-2px);
169 + box-shadow: var(--sa-shadow-md);
170 +}
171 +
172 +.sa-card-static {
173 + background: #fff;
174 + border-radius: var(--sa-radius-md);
175 + box-shadow: var(--sa-shadow-sm);
176 + border: 1px solid var(--sa-gray-100);
177 + overflow: hidden;
178 +}
179 +
180 +.sa-card-body {
181 + padding: var(--sa-space-6);
182 +}
183 +
184 +.sa-card-header {
185 + padding: var(--sa-space-5) var(--sa-space-6);
186 + border-bottom: 1px solid var(--sa-gray-100);
187 + font-weight: 600;
188 +}
189 +
190 +.sa-card-footer {
191 + padding: var(--sa-space-4) var(--sa-space-6);
192 + border-top: 1px solid var(--sa-gray-100);
193 + background: var(--sa-gray-50);
194 +}
195 +
196 +.sa-card-accent {
197 + border-top: 3px solid;
198 +}
199 +
200 +.sa-card-accent-primary {
201 + border-top-color: var(--sa-primary);
202 +}
203 +
204 +.sa-card-accent-secondary {
205 + border-top-color: var(--sa-secondary);
206 +}
207 +
208 +.sa-card-accent-accent {
209 + border-top-color: var(--sa-accent);
210 +}
211 +
212 +.sa-card-accent-danger {
213 + border-top-color: var(--sa-danger);
214 +}
215 +
216 +/* Colored gradient strip on top */
217 +.sa-card-gradient-strip {
218 + height: 4px;
219 + background: var(--sa-primary-gradient);
220 +}
221 +
222 +.sa-card-gradient-strip-teal {
223 + background: var(--sa-secondary-gradient);
224 +}
225 +
226 +.sa-card-gradient-strip-amber {
227 + background: linear-gradient(135deg, var(--sa-accent) 0%, var(--sa-primary) 100%);
228 +}
229 +
230 +/* ---------- Buttons ---------- */
231 +.sa-btn {
232 + display: inline-flex;
233 + align-items: center;
234 + justify-content: center;
235 + gap: 8px;
236 + padding: 10px 20px;
237 + font-weight: 600;
238 + font-size: 0.938rem;
239 + border-radius: var(--sa-radius-sm);
240 + border: none;
241 + cursor: pointer;
242 + transition: all var(--sa-transition-fast);
243 + text-decoration: none;
244 + line-height: 1.4;
245 +}
246 +
247 +.sa-btn:hover {
248 + transform: translateY(-1px);
249 + text-decoration: none;
250 +}
251 +
252 +.sa-btn:active {
253 + transform: translateY(0);
254 +}
255 +
256 +.sa-btn-primary {
257 + background: var(--sa-primary-gradient);
258 + color: #fff;
259 + box-shadow: 0 2px 8px rgba(232, 96, 76, 0.3);
260 +}
261 +
262 +.sa-btn-primary:hover {
263 + box-shadow: 0 4px 16px rgba(232, 96, 76, 0.4);
264 + color: #fff;
265 +}
266 +
267 +.sa-btn-secondary {
268 + background: var(--sa-secondary-gradient);
269 + color: #fff;
270 + box-shadow: 0 2px 8px rgba(26, 158, 143, 0.3);
271 +}
272 +
273 +.sa-btn-secondary:hover {
274 + box-shadow: 0 4px 16px rgba(26, 158, 143, 0.4);
275 + color: #fff;
276 +}
277 +
278 +.sa-btn-accent {
279 + background: linear-gradient(135deg, var(--sa-accent) 0%, var(--sa-accent-dark) 100%);
280 + color: #fff;
281 + box-shadow: 0 2px 8px rgba(244, 166, 35, 0.3);
282 +}
283 +
284 +.sa-btn-ghost {
285 + background: transparent;
286 + color: var(--sa-gray-700);
287 + border: 1.5px solid var(--sa-gray-300);
288 +}
289 +
290 +.sa-btn-ghost:hover {
291 + background: var(--sa-gray-100);
292 + color: var(--sa-gray-900);
293 + border-color: var(--sa-gray-400);
294 +}
295 +
296 +.sa-btn-danger {
297 + background: linear-gradient(135deg, var(--sa-danger) 0%, #dc2626 100%);
298 + color: #fff;
299 +}
300 +
301 +.sa-btn-success {
302 + background: linear-gradient(135deg, var(--sa-success) 0%, #16a34a 100%);
303 + color: #fff;
304 +}
305 +
306 +.sa-btn-sm {
307 + padding: 6px 14px;
308 + font-size: 0.813rem;
309 +}
310 +
311 +.sa-btn-lg {
312 + padding: 14px 28px;
313 + font-size: 1.063rem;
314 +}
315 +
316 +.sa-btn-pill {
317 + border-radius: var(--sa-radius-full);
318 +}
319 +
320 +.sa-btn-icon {
321 + width: 40px;
322 + height: 40px;
323 + padding: 0;
324 + border-radius: var(--sa-radius-full);
325 + font-size: 1.1rem;
326 +}
327 +
328 +.sa-btn-icon.sa-btn-sm {
329 + width: 32px;
330 + height: 32px;
331 + font-size: 0.9rem;
332 +}
333 +
334 +/* Loading state */
335 +.sa-btn-loading {
336 + position: relative;
337 + pointer-events: none;
338 + opacity: 0.75;
339 +}
340 +
341 +.sa-btn-loading::after {
342 + content: '';
343 + position: absolute;
344 + width: 16px;
345 + height: 16px;
346 + border: 2px solid transparent;
347 + border-top-color: currentColor;
348 + border-radius: 50%;
349 + animation: sa-spin 0.6s linear infinite;
350 +}
351 +
352 +/* ---------- Badges ---------- */
353 +.sa-badge {
354 + display: inline-flex;
355 + align-items: center;
356 + gap: 4px;
357 + padding: 3px 10px;
358 + font-size: 0.75rem;
359 + font-weight: 600;
360 + border-radius: var(--sa-radius-full);
361 + letter-spacing: 0.02em;
362 +}
363 +
364 +.sa-badge-primary { background: rgba(232, 96, 76, 0.12); color: var(--sa-primary-dark); }
365 +.sa-badge-secondary { background: rgba(26, 158, 143, 0.12); color: var(--sa-secondary-dark); }
366 +.sa-badge-accent { background: rgba(244, 166, 35, 0.12); color: var(--sa-accent-dark); }
367 +.sa-badge-success { background: var(--sa-success-light); color: #15803d; }
368 +.sa-badge-warning { background: var(--sa-warning-light); color: #92400e; }
369 +.sa-badge-danger { background: var(--sa-danger-light); color: #dc2626; }
370 +.sa-badge-info { background: var(--sa-info-light); color: #1d4ed8; }
371 +.sa-badge-neutral { background: var(--sa-gray-100); color: var(--sa-gray-600); }
372 +
373 +.sa-badge-solid-success { background: var(--sa-success); color: #fff; }
374 +.sa-badge-solid-warning { background: var(--sa-warning); color: #fff; }
375 +.sa-badge-solid-danger { background: var(--sa-danger); color: #fff; }
376 +.sa-badge-solid-info { background: var(--sa-info); color: #fff; }
377 +.sa-badge-solid-secondary { background: var(--sa-secondary); color: #fff; }
378 +.sa-badge-solid-neutral { background: var(--sa-gray-400); color: #fff; }
379 +
380 +/* ---------- Progress Bar ---------- */
381 +.sa-progress {
382 + height: 10px;
383 + background: var(--sa-gray-100);
384 + border-radius: var(--sa-radius-full);
385 + overflow: hidden;
386 +}
387 +
388 +.sa-progress-bar {
389 + height: 100%;
390 + border-radius: var(--sa-radius-full);
391 + transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
392 + background: var(--sa-secondary-gradient);
393 +}
394 +
395 +.sa-progress-bar-success { background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%); }
396 +.sa-progress-bar-warning { background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); }
397 +.sa-progress-bar-danger { background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); }
398 +.sa-progress-bar-primary { background: var(--sa-primary-gradient); }
399 +
400 +.sa-progress-lg {
401 + height: 16px;
402 +}
403 +
404 +.sa-progress-sm {
405 + height: 6px;
406 +}
407 +
408 +/* ---------- Avatar ---------- */
409 +.sa-avatar {
410 + display: inline-flex;
411 + align-items: center;
412 + justify-content: center;
413 + width: 40px;
414 + height: 40px;
415 + border-radius: 50%;
416 + font-weight: 700;
417 + font-size: 0.875rem;
418 + color: #fff;
419 + text-transform: uppercase;
420 + flex-shrink: 0;
421 + border: 2px solid #fff;
422 + box-shadow: 0 1px 3px rgba(0,0,0,0.1);
423 +}
424 +
425 +.sa-avatar-sm { width: 32px; height: 32px; font-size: 0.75rem; }
426 +.sa-avatar-lg { width: 52px; height: 52px; font-size: 1.1rem; }
427 +.sa-avatar-xl { width: 64px; height: 64px; font-size: 1.3rem; }
428 +
429 +/* Avatar color palette */
430 +.sa-avatar-1 { background: #e8604c; }
431 +.sa-avatar-2 { background: #1a9e8f; }
432 +.sa-avatar-3 { background: #6366f1; }
433 +.sa-avatar-4 { background: #f59e0b; }
434 +.sa-avatar-5 { background: #ec4899; }
435 +.sa-avatar-6 { background: #14b8a6; }
436 +.sa-avatar-7 { background: #8b5cf6; }
437 +.sa-avatar-8 { background: #f97316; }
438 +
439 +/* Avatar stack (overlapping) */
440 +.sa-avatar-stack {
441 + display: flex;
442 +}
443 +
444 +.sa-avatar-stack .sa-avatar {
445 + margin-left: -10px;
446 +}
447 +
448 +.sa-avatar-stack .sa-avatar:first-child {
449 + margin-left: 0;
450 +}
451 +
452 +.sa-avatar-overflow {
453 + background: var(--sa-gray-200);
454 + color: var(--sa-gray-600);
455 + font-size: 0.7rem;
456 +}
457 +
458 +/* ---------- Stats ---------- */
459 +.sa-stat {
460 + text-align: center;
461 + padding: var(--sa-space-5);
462 +}
463 +
464 +.sa-stat-value {
465 + font-size: 1.75rem;
466 + font-weight: 800;
467 + line-height: 1.2;
468 + color: var(--sa-gray-900);
469 + letter-spacing: -0.03em;
470 +}
471 +
472 +.sa-stat-label {
473 + font-size: 0.8rem;
474 + font-weight: 500;
475 + color: var(--sa-gray-500);
476 + text-transform: uppercase;
477 + letter-spacing: 0.06em;
478 + margin-top: 4px;
479 +}
480 +
481 +.sa-stat-icon {
482 + font-size: 1.5rem;
483 + margin-bottom: 8px;
484 + opacity: 0.8;
485 +}
486 +
487 +/* ---------- Empty State ---------- */
488 +.sa-empty {
489 + text-align: center;
490 + padding: var(--sa-space-16) var(--sa-space-6);
491 +}
492 +
493 +.sa-empty-icon {
494 + font-size: 3.5rem;
495 + color: var(--sa-gray-300);
496 + margin-bottom: var(--sa-space-4);
497 +}
498 +
499 +.sa-empty-title {
500 + font-size: 1.25rem;
501 + font-weight: 700;
502 + color: var(--sa-gray-700);
503 + margin-bottom: var(--sa-space-2);
504 +}
505 +
506 +.sa-empty-text {
507 + font-size: 0.938rem;
508 + color: var(--sa-gray-500);
509 + margin-bottom: var(--sa-space-6);
510 + max-width: 360px;
511 + margin-left: auto;
512 + margin-right: auto;
513 +}
514 +
515 +/* ---------- Gradient Header ---------- */
516 +.sa-gradient-header {
517 + background: var(--sa-secondary-gradient);
518 + color: #fff;
519 + padding: var(--sa-space-8) 0;
520 + margin: -1rem calc(-1 * (50vw - 50%)) var(--sa-space-8);
521 + padding-left: calc(50vw - 50%);
522 + padding-right: calc(50vw - 50%);
523 +}
524 +
525 +.sa-gradient-header-coral {
526 + background: var(--sa-primary-gradient);
527 +}
528 +
529 +.sa-gradient-header h1,
530 +.sa-gradient-header h2,
531 +.sa-gradient-header h3 {
532 + color: #fff;
533 +}
534 +
535 +.sa-gradient-header .sa-badge {
536 + background: rgba(255,255,255,0.2);
537 + color: #fff;
538 +}
539 +
540 +.sa-gradient-header .text-muted {
541 + color: rgba(255,255,255,0.8) !important;
542 +}
543 +
544 +/* ---------- Navigation Card ---------- */
545 +.sa-nav-card {
546 + display: flex;
547 + flex-direction: column;
548 + align-items: center;
549 + gap: 8px;
550 + padding: var(--sa-space-5) var(--sa-space-4);
551 + background: #fff;
552 + border-radius: var(--sa-radius-md);
553 + box-shadow: var(--sa-shadow-sm);
554 + border: 1px solid var(--sa-gray-100);
555 + text-decoration: none;
556 + color: var(--sa-gray-700);
557 + transition: all var(--sa-transition-normal);
558 + text-align: center;
559 +}
560 +
561 +.sa-nav-card:hover {
562 + transform: translateY(-3px);
563 + box-shadow: var(--sa-shadow-md);
564 + color: var(--sa-gray-900);
565 + text-decoration: none;
566 +}
567 +
568 +.sa-nav-card-icon {
569 + width: 48px;
570 + height: 48px;
571 + border-radius: var(--sa-radius-md);
572 + display: flex;
573 + align-items: center;
574 + justify-content: center;
575 + font-size: 1.4rem;
576 +}
577 +
578 +.sa-nav-card-label {
579 + font-weight: 600;
580 + font-size: 0.875rem;
581 +}
582 +
583 +.sa-nav-card-count {
584 + font-size: 0.75rem;
585 + color: var(--sa-gray-500);
586 +}
587 +
588 +/* Nav card icon themes */
589 +.sa-nav-icon-expenses { background: rgba(232, 96, 76, 0.1); color: var(--sa-primary); }
590 +.sa-nav-icon-members { background: rgba(99, 102, 241, 0.1); color: #6366f1; }
591 +.sa-nav-icon-budget { background: rgba(34, 197, 94, 0.1); color: var(--sa-success); }
592 +.sa-nav-icon-wishlist { background: rgba(244, 166, 35, 0.1); color: var(--sa-accent); }
593 +.sa-nav-icon-polls { background: rgba(59, 130, 246, 0.1); color: var(--sa-info); }
594 +.sa-nav-icon-settlement { background: rgba(26, 158, 143, 0.1); color: var(--sa-secondary); }
595 +
596 +/* ---------- Floating Action Button ---------- */
597 +.sa-fab {
598 + position: fixed;
599 + bottom: 24px;
600 + right: 24px;
601 + width: 56px;
602 + height: 56px;
603 + border-radius: 50%;
604 + background: var(--sa-primary-gradient);
605 + color: #fff;
606 + display: flex;
607 + align-items: center;
608 + justify-content: center;
609 + font-size: 1.5rem;
610 + border: none;
611 + cursor: pointer;
612 + box-shadow: 0 4px 16px rgba(232, 96, 76, 0.4);
613 + transition: all var(--sa-transition-normal);
614 + z-index: 1000;
615 + text-decoration: none;
616 +}
617 +
618 +.sa-fab:hover {
619 + transform: scale(1.1);
620 + box-shadow: 0 6px 24px rgba(232, 96, 76, 0.5);
621 + color: #fff;
622 +}
623 +
624 +@media (min-width: 768px) {
625 + .sa-fab {
626 + display: none;
627 + }
628 +}
629 +
630 +/* ---------- Amount Display ---------- */
631 +.sa-amount {
632 + font-variant-numeric: tabular-nums;
633 + font-weight: 700;
634 +}
635 +
636 +.sa-amount-lg {
637 + font-size: 2rem;
638 + letter-spacing: -0.02em;
639 +}
640 +
641 +.sa-amount-positive { color: var(--sa-success); }
642 +.sa-amount-negative { color: var(--sa-danger); }
643 +
644 +/* ---------- Donut Chart (CSS) ---------- */
645 +.sa-donut {
646 + width: 100px;
647 + height: 100px;
648 + border-radius: 50%;
649 + display: flex;
650 + align-items: center;
651 + justify-content: center;
652 + position: relative;
653 + margin: 0 auto;
654 +}
655 +
656 +.sa-donut-label {
657 + font-weight: 800;
658 + font-size: 1.1rem;
659 + z-index: 1;
660 +}
661 +
662 +/* ---------- Balance Bar ---------- */
663 +.sa-balance-bar-container {
664 + display: flex;
665 + align-items: center;
666 + gap: 8px;
667 + height: 28px;
668 +}
669 +
670 +.sa-balance-bar-track {
671 + flex: 1;
672 + display: flex;
673 + align-items: center;
674 + justify-content: center;
675 + position: relative;
676 + height: 8px;
677 + background: var(--sa-gray-100);
678 + border-radius: var(--sa-radius-full);
679 +}
680 +
681 +.sa-balance-bar-fill {
682 + position: absolute;
683 + height: 100%;
684 + border-radius: var(--sa-radius-full);
685 + transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
686 +}
687 +
688 +.sa-balance-bar-positive {
689 + right: 50%;
690 + left: auto;
691 + background: var(--sa-success);
692 +}
693 +
694 +.sa-balance-bar-negative {
695 + left: 50%;
696 + right: auto;
697 + background: var(--sa-danger);
698 +}
699 +
700 +.sa-balance-bar-center {
701 + position: absolute;
702 + width: 2px;
703 + height: 16px;
704 + background: var(--sa-gray-400);
705 + left: 50%;
706 + transform: translateX(-50%);
707 +}
708 +
709 +/* ---------- Settlement Flow Card ---------- */
710 +.sa-settlement-card {
711 + display: flex;
712 + align-items: center;
713 + gap: var(--sa-space-4);
714 + padding: var(--sa-space-4) var(--sa-space-5);
715 + background: #fff;
716 + border-radius: var(--sa-radius-md);
717 + box-shadow: var(--sa-shadow-sm);
718 + border: 1px solid var(--sa-gray-100);
719 +}
720 +
721 +.sa-settlement-arrow {
722 + color: var(--sa-gray-400);
723 + font-size: 1.2rem;
724 + flex-shrink: 0;
725 +}
726 +
727 +.sa-settlement-amount {
728 + font-size: 1.1rem;
729 + font-weight: 700;
730 + color: var(--sa-gray-900);
731 + flex-shrink: 0;
732 +}
733 +
734 +/* Status dots */
735 +.sa-status-dot {
736 + width: 10px;
737 + height: 10px;
738 + border-radius: 50%;
739 + display: inline-block;
740 +}
741 +
742 +.sa-status-dot-pending { background: var(--sa-gray-300); }
743 +.sa-status-dot-active { background: var(--sa-success); }
744 +.sa-status-dot-warning { background: var(--sa-warning); }
745 +.sa-status-dot-pulse {
746 + animation: sa-pulse 2s ease-in-out infinite;
747 +}
748 +
749 +/* ---------- Toggle Switch ---------- */
750 +.sa-toggle {
751 + position: relative;
752 + width: 44px;
753 + height: 24px;
754 + appearance: none;
755 + -webkit-appearance: none;
756 + background: var(--sa-gray-300);
757 + border-radius: var(--sa-radius-full);
758 + outline: none;
759 + cursor: pointer;
760 + transition: background var(--sa-transition-fast);
761 +}
762 +
763 +.sa-toggle:checked {
764 + background: var(--sa-secondary);
765 +}
766 +
767 +.sa-toggle::before {
768 + content: '';
769 + position: absolute;
770 + top: 2px;
771 + left: 2px;
772 + width: 20px;
773 + height: 20px;
774 + border-radius: 50%;
775 + background: #fff;
776 + box-shadow: 0 1px 3px rgba(0,0,0,0.2);
777 + transition: transform var(--sa-transition-fast);
778 +}
779 +
780 +.sa-toggle:checked::before {
781 + transform: translateX(20px);
782 +}
783 +
784 +/* ---------- Icon Picker ---------- */
785 +.sa-icon-picker {
786 + display: grid;
787 + grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
788 + gap: 8px;
789 +}
790 +
791 +.sa-icon-option {
792 + width: 48px;
793 + height: 48px;
794 + display: flex;
795 + align-items: center;
796 + justify-content: center;
797 + border: 2px solid var(--sa-gray-200);
798 + border-radius: var(--sa-radius-sm);
799 + cursor: pointer;
800 + transition: all var(--sa-transition-fast);
801 + font-size: 1.2rem;
802 + color: var(--sa-gray-600);
803 + background: #fff;
804 +}
805 +
806 +.sa-icon-option:hover {
807 + border-color: var(--sa-secondary-light);
808 + color: var(--sa-secondary);
809 +}
810 +
811 +.sa-icon-option.active,
812 +.sa-icon-option input:checked + & {
813 + border-color: var(--sa-secondary);
814 + background: rgba(26, 158, 143, 0.08);
815 + color: var(--sa-secondary);
816 +}
817 +
818 +/* ---------- Toast ---------- */
819 +.sa-toast-container {
820 + position: fixed;
821 + top: 20px;
822 + right: 20px;
823 + z-index: 9999;
824 + display: flex;
825 + flex-direction: column;
826 + gap: 8px;
827 + pointer-events: none;
828 +}
829 +
830 +.sa-toast {
831 + display: flex;
832 + align-items: center;
833 + gap: 10px;
834 + padding: 12px 20px;
835 + background: #fff;
836 + border-radius: var(--sa-radius-md);
837 + box-shadow: var(--sa-shadow-lg);
838 + border-left: 4px solid var(--sa-secondary);
839 + font-size: 0.875rem;
840 + font-weight: 500;
841 + pointer-events: auto;
842 + animation: sa-slide-in-right 0.3s ease-out;
843 + max-width: 380px;
844 +}
845 +
846 +.sa-toast-success { border-left-color: var(--sa-success); }
847 +.sa-toast-error { border-left-color: var(--sa-danger); }
848 +.sa-toast-warning { border-left-color: var(--sa-warning); }
849 +
850 +.sa-toast-dismiss {
851 + animation: sa-fade-out 0.3s ease-in forwards;
852 +}
853 +
854 +/* ---------- Expense List Item ---------- */
855 +.sa-expense-item {
856 + display: flex;
857 + align-items: center;
858 + gap: var(--sa-space-4);
859 + padding: var(--sa-space-4) var(--sa-space-5);
860 + border-bottom: 1px solid var(--sa-gray-100);
861 + transition: background var(--sa-transition-fast);
862 +}
863 +
864 +.sa-expense-item:last-child {
865 + border-bottom: none;
866 +}
867 +
868 +.sa-expense-item:hover {
869 + background: var(--sa-gray-50);
870 +}
871 +
872 +.sa-expense-icon {
873 + width: 40px;
874 + height: 40px;
875 + border-radius: var(--sa-radius-sm);
876 + display: flex;
877 + align-items: center;
878 + justify-content: center;
879 + font-size: 1rem;
880 + flex-shrink: 0;
881 +}
882 +
883 +.sa-expense-details {
884 + flex: 1;
885 + min-width: 0;
886 +}
887 +
888 +.sa-expense-desc {
889 + font-weight: 600;
890 + font-size: 0.938rem;
891 + color: var(--sa-gray-800);
892 + white-space: nowrap;
893 + overflow: hidden;
894 + text-overflow: ellipsis;
895 +}
896 +
897 +.sa-expense-meta {
898 + font-size: 0.8rem;
899 + color: var(--sa-gray-500);
900 + margin-top: 2px;
901 +}
902 +
903 +.sa-expense-amount {
904 + font-weight: 700;
905 + font-size: 1rem;
906 + color: var(--sa-gray-900);
907 + text-align: right;
908 + flex-shrink: 0;
909 +}
910 +
911 +.sa-expense-actions {
912 + display: flex;
913 + gap: 4px;
914 + opacity: 0;
915 + transition: opacity var(--sa-transition-fast);
916 +}
917 +
918 +.sa-expense-item:hover .sa-expense-actions {
919 + opacity: 1;
920 +}
921 +
922 +/* ---------- Split Method Cards ---------- */
923 +.sa-split-methods {
924 + display: grid;
925 + grid-template-columns: repeat(2, 1fr);
926 + gap: 8px;
927 +}
928 +
929 +@media (min-width: 576px) {
930 + .sa-split-methods {
931 + grid-template-columns: repeat(4, 1fr);
932 + }
933 +}
934 +
935 +.sa-split-option {
936 + position: relative;
937 + cursor: pointer;
938 +}
939 +
940 +.sa-split-option input {
941 + position: absolute;
942 + opacity: 0;
943 + width: 0;
944 + height: 0;
945 +}
946 +
947 +.sa-split-option-label {
948 + display: flex;
949 + flex-direction: column;
950 + align-items: center;
951 + gap: 6px;
952 + padding: var(--sa-space-4) var(--sa-space-3);
953 + border: 2px solid var(--sa-gray-200);
954 + border-radius: var(--sa-radius-md);
955 + text-align: center;
956 + transition: all var(--sa-transition-fast);
957 + background: #fff;
958 +}
959 +
960 +.sa-split-option input:checked + .sa-split-option-label {
961 + border-color: var(--sa-secondary);
962 + background: rgba(26, 158, 143, 0.06);
963 + box-shadow: 0 0 0 1px var(--sa-secondary);
964 +}
965 +
966 +.sa-split-option-icon {
967 + font-size: 1.4rem;
968 + color: var(--sa-gray-500);
969 +}
970 +
971 +.sa-split-option input:checked + .sa-split-option-label .sa-split-option-icon {
972 + color: var(--sa-secondary);
973 +}
974 +
975 +.sa-split-option-text {
976 + font-size: 0.75rem;
977 + font-weight: 600;
978 + color: var(--sa-gray-600);
979 +}
980 +
981 +/* ---------- Poll Option Card ---------- */
982 +.sa-poll-option {
983 + border: 2px solid var(--sa-gray-200);
984 + border-radius: var(--sa-radius-md);
985 + padding: var(--sa-space-4) var(--sa-space-5);
986 + margin-bottom: var(--sa-space-3);
987 + transition: all var(--sa-transition-fast);
988 + background: #fff;
989 +}
990 +
991 +.sa-poll-option-voted {
992 + border-color: var(--sa-secondary);
993 + background: rgba(26, 158, 143, 0.03);
994 +}
995 +
996 +.sa-poll-option-winner {
997 + border-color: var(--sa-accent);
998 + background: rgba(244, 166, 35, 0.05);
999 +}
1000 +
1001 +/* ---------- Wishlist Vote Button ---------- */
1002 +.sa-vote-btn {
1003 + display: inline-flex;
1004 + align-items: center;
1005 + gap: 6px;
1006 + padding: 6px 14px;
1007 + border: 1.5px solid var(--sa-gray-300);
1008 + border-radius: var(--sa-radius-full);
1009 + background: #fff;
1010 + color: var(--sa-gray-600);
1011 + font-size: 0.85rem;
1012 + font-weight: 600;
1013 + cursor: pointer;
1014 + transition: all var(--sa-transition-fast);
1015 +}
1016 +
1017 +.sa-vote-btn:hover {
1018 + border-color: var(--sa-primary);
1019 + color: var(--sa-primary);
1020 +}
1021 +
1022 +.sa-vote-btn-active {
1023 + border-color: var(--sa-primary);
1024 + background: rgba(232, 96, 76, 0.08);
1025 + color: var(--sa-primary);
1026 +}
1027 +
1028 +/* ---------- Navbar ---------- */
1029 +.sa-navbar {
1030 + position: sticky;
1031 + top: 0;
1032 + z-index: 1050;
1033 + background: rgba(255, 255, 255, 0.85);
1034 + backdrop-filter: blur(12px);
1035 + -webkit-backdrop-filter: blur(12px);
1036 + border-bottom: 1px solid rgba(0, 0, 0, 0.06);
1037 + padding: 0 var(--sa-space-4);
1038 +}
1039 +
1040 +.sa-navbar-brand {
1041 + font-weight: 800;
1042 + font-size: 1.25rem;
1043 + color: var(--sa-primary) !important;
1044 + display: flex;
1045 + align-items: center;
1046 + gap: 8px;
1047 + text-decoration: none;
1048 +}
1049 +
1050 +.sa-navbar-brand:hover {
1051 + color: var(--sa-primary-dark) !important;
1052 +}
1053 +
1054 +.sa-navbar .nav-link {
1055 + font-weight: 500;
1056 + color: var(--sa-gray-600) !important;
1057 + padding: 8px 16px !important;
1058 + border-radius: var(--sa-radius-sm);
1059 + transition: all var(--sa-transition-fast);
1060 +}
1061 +
1062 +.sa-navbar .nav-link:hover,
1063 +.sa-navbar .nav-link.active {
1064 + color: var(--sa-gray-900) !important;
1065 + background: var(--sa-gray-100);
1066 +}
1067 +
1068 +.sa-nav-avatar-btn {
1069 + display: flex;
1070 + align-items: center;
1071 + gap: 8px;
1072 + padding: 4px 12px 4px 4px;
1073 + background: var(--sa-gray-100);
1074 + border-radius: var(--sa-radius-full);
1075 + border: none;
1076 + cursor: pointer;
1077 + font-size: 0.875rem;
1078 + font-weight: 500;
1079 + color: var(--sa-gray-700);
1080 + transition: background var(--sa-transition-fast);
1081 +}
1082 +
1083 +.sa-nav-avatar-btn:hover {
1084 + background: var(--sa-gray-200);
1085 +}
1086 +
1087 +.sa-navbar-auth-btns {
1088 + display: flex;
1089 + gap: 8px;
1090 + align-items: center;
1091 +}
1092 +
1093 +/* ---------- Footer ---------- */
1094 +.sa-footer {
1095 + background: var(--sa-gray-800);
1096 + color: var(--sa-gray-400);
1097 + padding: var(--sa-space-6) 0;
1098 + font-size: 0.85rem;
1099 + margin-top: var(--sa-space-16);
1100 +}
1101 +
1102 +.sa-footer a {
1103 + color: var(--sa-gray-300);
1104 +}
1105 +
1106 +.sa-footer a:hover {
1107 + color: #fff;
1108 +}
1109 +
1110 +/* ---------- Hero Section ---------- */
1111 +.sa-hero {
1112 + text-align: center;
1113 + padding: var(--sa-space-16) var(--sa-space-4);
1114 + background: var(--sa-primary-gradient);
1115 + color: #fff;
1116 + margin: -1rem calc(-1 * (50vw - 50%)) var(--sa-space-12);
1117 + padding-left: calc(50vw - 50%);
1118 + padding-right: calc(50vw - 50%);
1119 +}
1120 +
1121 +.sa-hero h1 {
1122 + color: #fff;
1123 + font-size: 2.75rem;
1124 + font-weight: 800;
1125 + letter-spacing: -0.03em;
1126 + margin-bottom: var(--sa-space-4);
1127 +}
1128 +
1129 +.sa-hero p {
1130 + font-size: 1.2rem;
1131 + opacity: 0.9;
1132 + max-width: 540px;
1133 + margin: 0 auto var(--sa-space-8);
1134 +}
1135 +
1136 +.sa-hero-btns {
1137 + display: flex;
1138 + gap: 12px;
1139 + justify-content: center;
1140 + flex-wrap: wrap;
1141 +}
1142 +
1143 +.sa-hero-btn-white {
1144 + background: #fff;
1145 + color: var(--sa-primary);
1146 + font-weight: 700;
1147 + padding: 12px 28px;
1148 + border-radius: var(--sa-radius-full);
1149 + border: none;
1150 + font-size: 1rem;
1151 + transition: all var(--sa-transition-fast);
1152 + text-decoration: none;
1153 + display: inline-flex;
1154 + align-items: center;
1155 + gap: 8px;
1156 +}
1157 +
1158 +.sa-hero-btn-white:hover {
1159 + transform: translateY(-2px);
1160 + box-shadow: 0 4px 16px rgba(0,0,0,0.15);
1161 + color: var(--sa-primary);
1162 +}
1163 +
1164 +.sa-hero-btn-outline {
1165 + background: transparent;
1166 + color: #fff;
1167 + font-weight: 600;
1168 + padding: 12px 28px;
1169 + border-radius: var(--sa-radius-full);
1170 + border: 2px solid rgba(255,255,255,0.4);
1171 + font-size: 1rem;
1172 + transition: all var(--sa-transition-fast);
1173 + text-decoration: none;
1174 + display: inline-flex;
1175 + align-items: center;
1176 + gap: 8px;
1177 +}
1178 +
1179 +.sa-hero-btn-outline:hover {
1180 + background: rgba(255,255,255,0.15);
1181 + border-color: rgba(255,255,255,0.7);
1182 + color: #fff;
1183 +}
1184 +
1185 +@media (max-width: 767px) {
1186 + .sa-hero h1 { font-size: 2rem; }
1187 + .sa-hero { padding-top: var(--sa-space-10); padding-bottom: var(--sa-space-10); }
1188 +}
1189 +
1190 +/* ---------- Feature Cards ---------- */
1191 +.sa-feature-card {
1192 + text-align: center;
1193 + padding: var(--sa-space-8) var(--sa-space-6);
1194 +}
1195 +
1196 +.sa-feature-icon {
1197 + width: 64px;
1198 + height: 64px;
1199 + border-radius: var(--sa-radius-lg);
1200 + display: flex;
1201 + align-items: center;
1202 + justify-content: center;
1203 + font-size: 1.75rem;
1204 + margin: 0 auto var(--sa-space-4);
1205 +}
1206 +
1207 +.sa-feature-title {
1208 + font-size: 1.1rem;
1209 + font-weight: 700;
1210 + margin-bottom: var(--sa-space-2);
1211 +}
1212 +
1213 +.sa-feature-text {
1214 + font-size: 0.938rem;
1215 + color: var(--sa-gray-500);
1216 + line-height: 1.6;
1217 +}
1218 +
1219 +/* ---------- Step Flow ---------- */
1220 +.sa-steps {
1221 + display: flex;
1222 + gap: var(--sa-space-6);
1223 + align-items: flex-start;
1224 + justify-content: center;
1225 + flex-wrap: wrap;
1226 +}
1227 +
1228 +.sa-step {
1229 + flex: 1;
1230 + min-width: 200px;
1231 + max-width: 280px;
1232 + text-align: center;
1233 +}
1234 +
1235 +.sa-step-number {
1236 + width: 48px;
1237 + height: 48px;
1238 + border-radius: 50%;
1239 + background: var(--sa-secondary-gradient);
1240 + color: #fff;
1241 + font-weight: 800;
1242 + font-size: 1.25rem;
1243 + display: flex;
1244 + align-items: center;
1245 + justify-content: center;
1246 + margin: 0 auto var(--sa-space-4);
1247 +}
1248 +
1249 +.sa-step-title {
1250 + font-weight: 700;
1251 + margin-bottom: var(--sa-space-2);
1252 +}
1253 +
1254 +.sa-step-text {
1255 + font-size: 0.875rem;
1256 + color: var(--sa-gray-500);
1257 +}
1258 +
1259 +/* ---------- Confirmation Card ---------- */
1260 +.sa-confirm-card {
1261 + max-width: 480px;
1262 + margin: var(--sa-space-8) auto;
1263 +}
1264 +
1265 +.sa-confirm-icon {
1266 + width: 64px;
1267 + height: 64px;
1268 + border-radius: 50%;
1269 + display: flex;
1270 + align-items: center;
1271 + justify-content: center;
1272 + font-size: 1.75rem;
1273 + margin: 0 auto var(--sa-space-4);
1274 +}
1275 +
1276 +.sa-confirm-icon-danger {
1277 + background: var(--sa-danger-light);
1278 + color: var(--sa-danger);
1279 +}
1280 +
1281 +.sa-confirm-icon-success {
1282 + background: var(--sa-success-light);
1283 + color: var(--sa-success);
1284 +}
1285 +
1286 +.sa-confirm-icon-warning {
1287 + background: var(--sa-warning-light);
1288 + color: var(--sa-warning);
1289 +}
1290 +
1291 +/* ---------- Big Amount Input ---------- */
1292 +.sa-amount-input {
1293 + font-size: 2.5rem;
1294 + font-weight: 800;
1295 + text-align: center;
1296 + border: none;
1297 + border-bottom: 3px solid var(--sa-gray-200);
1298 + border-radius: 0;
1299 + padding: var(--sa-space-4);
1300 + background: transparent;
1301 + letter-spacing: -0.02em;
1302 +}
1303 +
1304 +.sa-amount-input:focus {
1305 + border-color: var(--sa-secondary);
1306 + box-shadow: none;
1307 + outline: none;
1308 +}
1309 +
1310 +.sa-amount-input::placeholder {
1311 + color: var(--sa-gray-300);
1312 +}
1313 +
1314 +/* ---------- Language Pills ---------- */
1315 +.sa-lang-pills {
1316 + display: flex;
1317 + gap: 4px;
1318 +}
1319 +
1320 +.sa-lang-pill {
1321 + padding: 4px 10px;
1322 + font-size: 0.75rem;
1323 + font-weight: 600;
1324 + border-radius: var(--sa-radius-full);
1325 + border: 1.5px solid var(--sa-gray-200);
1326 + background: transparent;
1327 + color: var(--sa-gray-500);
1328 + cursor: pointer;
1329 + transition: all var(--sa-transition-fast);
1330 + text-decoration: none;
1331 + text-transform: uppercase;
1332 +}
1333 +
1334 +.sa-lang-pill:hover,
1335 +.sa-lang-pill.active {
1336 + border-color: var(--sa-secondary);
1337 + color: var(--sa-secondary);
1338 + background: rgba(26, 158, 143, 0.06);
1339 +}
1340 +
1341 +/* ---------- Animations ---------- */
1342 +@keyframes sa-fade-in {
1343 + from { opacity: 0; }
1344 + to { opacity: 1; }
1345 +}
1346 +
1347 +@keyframes sa-slide-up {
1348 + from { opacity: 0; transform: translateY(20px); }
1349 + to { opacity: 1; transform: translateY(0); }
1350 +}
1351 +
1352 +@keyframes sa-slide-in-right {
1353 + from { opacity: 0; transform: translateX(100px); }
1354 + to { opacity: 1; transform: translateX(0); }
1355 +}
1356 +
1357 +@keyframes sa-fade-out {
1358 + from { opacity: 1; transform: translateX(0); }
1359 + to { opacity: 0; transform: translateX(100px); }
1360 +}
1361 +
1362 +@keyframes sa-scale-in {
1363 + from { opacity: 0; transform: scale(0.9); }
1364 + to { opacity: 1; transform: scale(1); }
1365 +}
1366 +
1367 +@keyframes sa-spin {
1368 + to { transform: rotate(360deg); }
1369 +}
1370 +
1371 +@keyframes sa-pulse {
1372 + 0%, 100% { opacity: 1; }
1373 + 50% { opacity: 0.5; }
1374 +}
1375 +
1376 +@keyframes sa-bounce-in {
1377 + 0% { transform: scale(0); }
1378 + 50% { transform: scale(1.15); }
1379 + 100% { transform: scale(1); }
1380 +}
1381 +
1382 +@keyframes sa-progress-fill {
1383 + from { width: 0; }
1384 +}
1385 +
1386 +.sa-animate-fade-in { animation: sa-fade-in 0.5s ease-out; }
1387 +.sa-animate-slide-up { animation: sa-slide-up 0.5s ease-out both; }
1388 +.sa-animate-scale-in { animation: sa-scale-in 0.4s ease-out both; }
1389 +.sa-animate-bounce-in { animation: sa-bounce-in 0.5s ease-out both; }
1390 +
1391 +/* Stagger delays */
1392 +.sa-stagger-1 { animation-delay: 0.05s; }
1393 +.sa-stagger-2 { animation-delay: 0.1s; }
1394 +.sa-stagger-3 { animation-delay: 0.15s; }
1395 +.sa-stagger-4 { animation-delay: 0.2s; }
1396 +.sa-stagger-5 { animation-delay: 0.25s; }
1397 +.sa-stagger-6 { animation-delay: 0.3s; }
1398 +
1399 +/* Scroll-triggered */
1400 +.sa-animate-on-scroll {
1401 + opacity: 0;
1402 + transform: translateY(20px);
1403 + transition: opacity 0.6s ease-out, transform 0.6s ease-out;
1404 +}
1405 +
1406 +.sa-animate-on-scroll.sa-visible {
1407 + opacity: 1;
1408 + transform: translateY(0);
1409 +}
1410 +
1411 +/* ---------- Utility Classes ---------- */
1412 +.sa-text-gradient {
1413 + background: var(--sa-primary-gradient);
1414 + -webkit-background-clip: text;
1415 + -webkit-text-fill-color: transparent;
1416 + background-clip: text;
1417 +}
1418 +
1419 +.sa-text-primary { color: var(--sa-primary); }
1420 +.sa-text-secondary { color: var(--sa-secondary); }
1421 +.sa-text-accent { color: var(--sa-accent); }
1422 +.sa-text-muted { color: var(--sa-gray-500); }
1423 +
1424 +.sa-bg-soft-primary { background: rgba(232, 96, 76, 0.08); }
1425 +.sa-bg-soft-secondary { background: rgba(26, 158, 143, 0.08); }
1426 +.sa-bg-soft-success { background: var(--sa-success-light); }
1427 +.sa-bg-soft-danger { background: var(--sa-danger-light); }
1428 +.sa-bg-soft-warning { background: var(--sa-warning-light); }
1429 +
1430 +.sa-rounded { border-radius: var(--sa-radius-md); }
1431 +.sa-rounded-lg { border-radius: var(--sa-radius-lg); }
1432 +
1433 +.sa-shadow { box-shadow: var(--sa-shadow-md); }
1434 +.sa-shadow-lg { box-shadow: var(--sa-shadow-lg); }
1435 +
1436 +.sa-divider {
1437 + height: 1px;
1438 + background: var(--sa-gray-100);
1439 + margin: var(--sa-space-4) 0;
1440 +}
1441 +
1442 +.sa-truncate {
1443 + white-space: nowrap;
1444 + overflow: hidden;
1445 + text-overflow: ellipsis;
1446 +}
1447 +
1448 +/* ---------- Category Colors ---------- */
1449 +.sa-category-food { background: #fee2e2; color: #dc2626; }
1450 +.sa-category-accommodation { background: #dbeafe; color: #2563eb; }
1451 +.sa-category-transport { background: #fef3c7; color: #d97706; }
1452 +.sa-category-activities { background: #dcfce7; color: #16a34a; }
1453 +.sa-category-shopping { background: #f3e8ff; color: #7c3aed; }
1454 +.sa-category-default { background: var(--sa-gray-100); color: var(--sa-gray-600); }
1455 +
1456 +/* ---------- Responsive Helpers ---------- */
1457 +@media (max-width: 575px) {
1458 + .sa-hide-mobile { display: none !important; }
1459 + .sa-gradient-header {
1460 + padding-top: var(--sa-space-6);
1461 + padding-bottom: var(--sa-space-6);
1462 + }
1463 + h1 { font-size: 1.5rem; }
1464 + .sa-stat-value { font-size: 1.4rem; }
1465 +}
1466 +
1467 +@media (min-width: 576px) {
1468 + .sa-hide-desktop { display: none !important; }
1469 +}
1470 +
1471 +/* ---------- Dropdown override ---------- */
1472 +.dropdown-menu {
1473 + border: 1px solid var(--sa-gray-100);
1474 + border-radius: var(--sa-radius-md);
1475 + box-shadow: var(--sa-shadow-lg);
1476 + padding: 6px;
1477 +}
1478 +
1479 +.dropdown-item {
1480 + border-radius: var(--sa-radius-sm);
1481 + padding: 8px 14px;
1482 + font-size: 0.875rem;
1483 + font-weight: 500;
1484 + transition: background var(--sa-transition-fast);
1485 +}
1486 +
1487 +.dropdown-item:hover {
1488 + background: var(--sa-gray-100);
1489 +}
1490 +
1491 +/* ---------- Table Override ---------- */
1492 +.sa-table {
1493 + width: 100%;
1494 + border-collapse: separate;
1495 + border-spacing: 0;
1496 +}
1497 +
1498 +.sa-table thead th {
1499 + font-size: 0.75rem;
1500 + font-weight: 600;
1501 + text-transform: uppercase;
1502 + letter-spacing: 0.06em;
1503 + color: var(--sa-gray-500);
1504 + border-bottom: 2px solid var(--sa-gray-100);
1505 + padding: var(--sa-space-3) var(--sa-space-4);
1506 +}
1507 +
1508 +.sa-table tbody td {
1509 + padding: var(--sa-space-3) var(--sa-space-4);
1510 + border-bottom: 1px solid var(--sa-gray-100);
1511 + font-size: 0.938rem;
1512 +}
1513 +
1514 +.sa-table tbody tr:hover {
1515 + background: var(--sa-gray-50);
1516 +}
1517 +
1518 +.sa-table tbody tr:last-child td {
1519 + border-bottom: none;
1520 +}
1521 +
1522 +/* ---------- Trip Card ---------- */
1523 +.sa-trip-card {
1524 + overflow: hidden;
1525 +}
1526 +
1527 +.sa-trip-card-gradient {
1528 + height: 6px;
1529 +}
1530 +
1531 +/* Generate variety with nth-child */
1532 +.sa-trip-card:nth-child(6n+1) .sa-trip-card-gradient { background: var(--sa-primary-gradient); }
1533 +.sa-trip-card:nth-child(6n+2) .sa-trip-card-gradient { background: var(--sa-secondary-gradient); }
1534 +.sa-trip-card:nth-child(6n+3) .sa-trip-card-gradient { background: linear-gradient(135deg, #6366f1, #8b5cf6); }
1535 +.sa-trip-card:nth-child(6n+4) .sa-trip-card-gradient { background: linear-gradient(135deg, var(--sa-accent), #f97316); }
1536 +.sa-trip-card:nth-child(6n+5) .sa-trip-card-gradient { background: linear-gradient(135deg, #ec4899, #f43f5e); }
1537 +.sa-trip-card:nth-child(6n+6) .sa-trip-card-gradient { background: linear-gradient(135deg, #14b8a6, #06b6d4); }
1538 +
1539 +/* ---------- Invitation / Accept page ---------- */
1540 +.sa-invite-page {
1541 + min-height: 80vh;
1542 + display: flex;
1543 + align-items: center;
1544 + justify-content: center;
1545 +}
1546 +
1547 +.sa-invite-card {
1548 + max-width: 440px;
1549 + width: 100%;
1550 + text-align: center;
1551 +}
1552 +
1553 +.sa-invite-icon {
1554 + font-size: 3rem;
1555 + margin-bottom: var(--sa-space-4);
1556 +}
1557 +
1558 +/* Copy link input */
1559 +.sa-copy-group {
1560 + display: flex;
1561 + gap: 0;
1562 +}
1563 +
1564 +.sa-copy-input {
1565 + flex: 1;
1566 + border-radius: var(--sa-radius-sm) 0 0 var(--sa-radius-sm);
1567 + font-family: var(--sa-font-mono);
1568 + font-size: 0.85rem;
1569 +}
1570 +
1571 +.sa-copy-btn {
1572 + border-radius: 0 var(--sa-radius-sm) var(--sa-radius-sm) 0;
1573 + white-space: nowrap;
1574 +}
1575 +
1576 +/* ---------- Participant Chip ---------- */
1577 +.sa-participant-chip {
1578 + display: inline-flex;
1579 + align-items: center;
1580 + gap: 8px;
1581 + padding: 4px 12px 4px 4px;
1582 + background: var(--sa-gray-100);
1583 + border-radius: var(--sa-radius-full);
1584 + font-size: 0.85rem;
1585 + font-weight: 500;
1586 + cursor: pointer;
1587 + transition: all var(--sa-transition-fast);
1588 + border: 2px solid transparent;
1589 +}
1590 +
1591 +.sa-participant-chip.active {
1592 + border-color: var(--sa-secondary);
1593 + background: rgba(26, 158, 143, 0.08);
1594 +}
1595 +
1596 +.sa-participant-chip:hover {
1597 + background: var(--sa-gray-200);
1598 +}
1599 +
1600 +/* ---------- Print ---------- */
1601 +@media print {
1602 + .sa-navbar, .sa-footer, .sa-fab, .sa-toast-container { display: none !important; }
1603 + .sa-card, .sa-card-static { box-shadow: none; border: 1px solid #ddd; }
1604 + body { background: #fff; }
1605 +}
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/favicon.ico +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/site.js +4 −0
@@ -0,0 +1,4 @@
1 +// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
2 +// for details on configuring this project to bundle and minify static web assets.
3 +
4 +// Write your JavaScript code.
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/splitapp.js +323 −0
@@ -0,0 +1,323 @@
1 +/* ============================================================
2 + SplitApp Interactive Utilities
3 + Vanilla JS enhancements for premium UX
4 + ============================================================ */
5 +
6 +const SplitApp = {
7 + /* ---------- Toast Notifications ---------- */
8 + toast(message, type = 'success', duration = 4000) {
9 + const container = document.getElementById('sa-toast-container');
10 + if (!container) return;
11 +
12 + const toast = document.createElement('div');
13 + toast.className = `sa-toast sa-toast-${type}`;
14 +
15 + const icons = {
16 + success: 'bi-check-circle-fill',
17 + error: 'bi-exclamation-circle-fill',
18 + warning: 'bi-exclamation-triangle-fill',
19 + info: 'bi-info-circle-fill'
20 + };
21 +
22 + toast.innerHTML = `
23 + <i class="bi ${icons[type] || icons.success}" style="font-size:1.1rem"></i>
24 + <span>${message}</span>
25 + `;
26 +
27 + container.appendChild(toast);
28 +
29 + setTimeout(() => {
30 + toast.classList.add('sa-toast-dismiss');
31 + setTimeout(() => toast.remove(), 300);
32 + }, duration);
33 + },
34 +
35 + /* ---------- Copy to Clipboard ---------- */
36 + async copyToClipboard(text, button) {
37 + try {
38 + await navigator.clipboard.writeText(text);
39 + if (button) {
40 + const original = button.innerHTML;
41 + button.innerHTML = '<i class="bi bi-check2"></i> Copied!';
42 + button.classList.add('sa-btn-success');
43 + setTimeout(() => {
44 + button.innerHTML = original;
45 + button.classList.remove('sa-btn-success');
46 + }, 2000);
47 + }
48 + SplitApp.toast('Copied to clipboard!', 'success', 2000);
49 + } catch {
50 + // Fallback
51 + const ta = document.createElement('textarea');
52 + ta.value = text;
53 + ta.style.position = 'fixed';
54 + ta.style.opacity = '0';
55 + document.body.appendChild(ta);
56 + ta.select();
57 + document.execCommand('copy');
58 + document.body.removeChild(ta);
59 + if (button) {
60 + const original = button.innerHTML;
61 + button.innerHTML = '<i class="bi bi-check2"></i> Copied!';
62 + setTimeout(() => { button.innerHTML = original; }, 2000);
63 + }
64 + }
65 + },
66 +
67 + /* ---------- Form Loading States ---------- */
68 + initFormLoading() {
69 + document.querySelectorAll('form[data-sa-loading]').forEach(form => {
70 + form.addEventListener('submit', (e) => {
71 + // Check if jQuery validation exists and if form is invalid
72 + if (typeof $ !== 'undefined' && $(form).valid && !$(form).valid()) {
73 + return;
74 + }
75 + const btn = form.querySelector('button[type="submit"], input[type="submit"]');
76 + if (btn && !btn.disabled) {
77 + btn.disabled = true;
78 + btn.classList.add('sa-btn-loading');
79 + const text = btn.textContent;
80 + btn.dataset.originalText = text;
81 + }
82 + });
83 + });
84 + },
85 +
86 + /* ---------- Scroll Animations ---------- */
87 + initScrollAnimations() {
88 + const observer = new IntersectionObserver((entries) => {
89 + entries.forEach(entry => {
90 + if (entry.isIntersecting) {
91 + entry.target.classList.add('sa-visible');
92 + observer.unobserve(entry.target);
93 + }
94 + });
95 + }, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' });
96 +
97 + document.querySelectorAll('.sa-animate-on-scroll').forEach(el => {
98 + observer.observe(el);
99 + });
100 + },
101 +
102 + /* ---------- Animated Progress Bars ---------- */
103 + initProgressBars() {
104 + const observer = new IntersectionObserver((entries) => {
105 + entries.forEach(entry => {
106 + if (entry.isIntersecting) {
107 + const bar = entry.target;
108 + const target = bar.dataset.width;
109 + if (target) {
110 + requestAnimationFrame(() => {
111 + bar.style.width = target;
112 + });
113 + }
114 + observer.unobserve(bar);
115 + }
116 + });
117 + }, { threshold: 0.2 });
118 +
119 + document.querySelectorAll('.sa-progress-bar[data-width]').forEach(bar => {
120 + bar.style.width = '0%';
121 + observer.observe(bar);
122 + });
123 + },
124 +
125 + /* ---------- Expense Split Calculator ---------- */
126 + initSplitCalculator() {
127 + const methodInputs = document.querySelectorAll('input[name="SplitMethod"]');
128 + if (!methodInputs.length) return;
129 +
130 + const amountInput = document.getElementById('expenseAmount');
131 + const section = document.getElementById('participantSection');
132 + const amountCols = document.querySelectorAll('.split-amount-col');
133 + const pctCols = document.querySelectorAll('.split-pct-col');
134 + const checks = document.querySelectorAll('.participant-check');
135 + const previews = document.querySelectorAll('.split-preview');
136 +
137 + function getMethod() {
138 + const checked = document.querySelector('input[name="SplitMethod"]:checked');
139 + return checked ? checked.value : '0';
140 + }
141 +
142 + function updateUI() {
143 + const method = getMethod();
144 +
145 + // Animate section visibility
146 + if (method === '0') {
147 + if (section) section.style.display = 'none';
148 + } else {
149 + if (section) {
150 + section.style.display = 'block';
151 + section.classList.add('sa-animate-fade-in');
152 + }
153 + }
154 +
155 + amountCols.forEach(c => c.style.display = method === '2' ? 'block' : 'none');
156 + pctCols.forEach(c => c.style.display = method === '3' ? 'block' : 'none');
157 +
158 + if (method === '2' || method === '3') {
159 + checks.forEach(c => { c.checked = true; });
160 + }
161 +
162 + updatePreview();
163 + }
164 +
165 + function updatePreview() {
166 + const method = getMethod();
167 + const amount = parseFloat(amountInput?.value) || 0;
168 + const checkedBoxes = document.querySelectorAll('.participant-check:checked');
169 +
170 + previews.forEach(p => { p.textContent = ''; p.className = 'sa-badge sa-badge-neutral split-preview'; });
171 +
172 + if (method === '0' || method === '1') {
173 + const count = method === '0' ? checks.length : checkedBoxes.length;
174 + if (count > 0 && amount > 0) {
175 + const each = (amount / count).toFixed(2);
176 + const targets = method === '0' ? checks : checkedBoxes;
177 + targets.forEach(c => {
178 + const idx = c.dataset.index;
179 + if (previews[idx]) {
180 + previews[idx].textContent = `€${each}`;
181 + previews[idx].className = 'sa-badge sa-badge-secondary split-preview';
182 + }
183 + });
184 + }
185 + }
186 +
187 + if (method === '2') {
188 + const amountInputs = document.querySelectorAll('.split-amount');
189 + let total = 0;
190 + amountInputs.forEach(input => { total += parseFloat(input.value) || 0; });
191 + const remaining = amount - total;
192 +
193 + // Update remaining indicator
194 + const indicator = document.getElementById('splitRemaining');
195 + if (indicator) {
196 + indicator.textContent = remaining.toFixed(2);
197 + indicator.className = Math.abs(remaining) < 0.01 ? 'sa-amount sa-amount-positive' : 'sa-amount sa-amount-negative';
198 + }
199 + }
200 +
201 + if (method === '3') {
202 + const pctInputs = document.querySelectorAll('.split-pct');
203 + let totalPct = 0;
204 + pctInputs.forEach(input => { totalPct += parseFloat(input.value) || 0; });
205 +
206 + const indicator = document.getElementById('splitPctTotal');
207 + if (indicator) {
208 + indicator.textContent = totalPct.toFixed(1) + '%';
209 + indicator.className = Math.abs(totalPct - 100) < 0.1 ? 'sa-amount sa-amount-positive' : 'sa-amount sa-amount-negative';
210 + }
211 + }
212 + }
213 +
214 + methodInputs.forEach(input => input.addEventListener('change', updateUI));
215 + if (amountInput) amountInput.addEventListener('input', updatePreview);
216 + checks.forEach(c => c.addEventListener('change', updatePreview));
217 + document.querySelectorAll('.split-amount, .split-pct').forEach(input => {
218 + input.addEventListener('input', updatePreview);
219 + });
220 + },
221 +
222 + /* ---------- Dynamic Poll Options ---------- */
223 + initDynamicPollOptions() {
224 + const container = document.getElementById('pollOptionsContainer');
225 + const addBtn = document.getElementById('addPollOption');
226 + if (!container || !addBtn) return;
227 +
228 + let count = container.querySelectorAll('.poll-option-row').length;
229 +
230 + addBtn.addEventListener('click', () => {
231 + if (count >= 10) {
232 + SplitApp.toast('Maximum 10 options allowed', 'warning');
233 + return;
234 + }
235 + count++;
236 + const row = document.createElement('div');
237 + row.className = 'poll-option-row d-flex gap-2 mb-2 sa-animate-slide-up';
238 + row.innerHTML = `
239 + <input type="text" name="Options" class="form-control" placeholder="Option ${count}" required />
240 + <button type="button" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm remove-option" title="Remove">
241 + <i class="bi bi-x-lg"></i>
242 + </button>
243 + `;
244 + container.appendChild(row);
245 +
246 + row.querySelector('.remove-option').addEventListener('click', () => {
247 + if (container.querySelectorAll('.poll-option-row').length > 2) {
248 + row.style.opacity = '0';
249 + row.style.transform = 'translateX(-20px)';
250 + row.style.transition = 'all 0.2s ease';
251 + setTimeout(() => row.remove(), 200);
252 + count--;
253 + }
254 + });
255 + });
256 +
257 + // Wire up existing remove buttons
258 + container.querySelectorAll('.remove-option').forEach(btn => {
259 + btn.addEventListener('click', () => {
260 + const row = btn.closest('.poll-option-row');
261 + if (container.querySelectorAll('.poll-option-row').length > 2) {
262 + row.remove();
263 + count--;
264 + }
265 + });
266 + });
267 + },
268 +
269 + /* ---------- Icon Picker ---------- */
270 + initIconPicker() {
271 + const picker = document.querySelector('.sa-icon-picker');
272 + const hiddenInput = document.getElementById('iconNameInput');
273 + if (!picker || !hiddenInput) return;
274 +
275 + picker.querySelectorAll('.sa-icon-option').forEach(opt => {
276 + opt.addEventListener('click', () => {
277 + picker.querySelectorAll('.sa-icon-option').forEach(o => o.classList.remove('active'));
278 + opt.classList.add('active');
279 + hiddenInput.value = opt.dataset.icon;
280 + });
281 + });
282 + },
283 +
284 + /* ---------- Confirm Delete ---------- */
285 + initDeleteConfirm() {
286 + document.querySelectorAll('[data-sa-confirm]').forEach(btn => {
287 + btn.addEventListener('click', (e) => {
288 + if (!confirm(btn.dataset.saConfirm || 'Are you sure?')) {
289 + e.preventDefault();
290 + }
291 + });
292 + });
293 + },
294 +
295 + /* ---------- TempData Toast Reader ---------- */
296 + readTempDataToasts() {
297 + const el = document.getElementById('sa-tempdata-messages');
298 + if (!el) return;
299 +
300 + const success = el.dataset.success;
301 + const error = el.dataset.error;
302 + const warning = el.dataset.warning;
303 +
304 + if (success) SplitApp.toast(success, 'success');
305 + if (error) SplitApp.toast(error, 'error');
306 + if (warning) SplitApp.toast(warning, 'warning');
307 + },
308 +
309 + /* ---------- Initialize Everything ---------- */
310 + init() {
311 + this.initScrollAnimations();
312 + this.initProgressBars();
313 + this.initFormLoading();
314 + this.initSplitCalculator();
315 + this.initDynamicPollOptions();
316 + this.initIconPicker();
317 + this.initDeleteConfirm();
318 + this.readTempDataToasts();
319 + }
320 +};
321 +
322 +// Auto-init on DOM ready
323 +document.addEventListener('DOMContentLoaded', () => SplitApp.init());
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/LICENSE +22 −0
@@ -0,0 +1,22 @@
1 +The MIT License (MIT)
2 +
3 +Copyright (c) 2011-2021 Twitter, Inc.
4 +Copyright (c) 2011-2021 The Bootstrap Authors
5 +
6 +Permission is hereby granted, free of charge, to any person obtaining a copy
7 +of this software and associated documentation files (the "Software"), to deal
8 +in the Software without restriction, including without limitation the rights
9 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 +copies of the Software, and to permit persons to whom the Software is
11 +furnished to do so, subject to the following conditions:
12 +
13 +The above copyright notice and this permission notice shall be included in
14 +all copies or substantial portions of the Software.
15 +
16 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 +THE SOFTWARE.
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css +4085 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css +6 −0
@@ -0,0 +1,6 @@
1 +/*!
2 + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{box-sizing:border-box;flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}@media (min-width:576px){.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}}@media (min-width:768px){.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}}@media (min-width:992px){.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}}@media (min-width:1200px){.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}}@media (min-width:1400px){.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}
6 +/*# sourceMappingURL=bootstrap-grid.min.css.map */
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css +4084 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css +6 −0
@@ -0,0 +1,6 @@
1 +/*!
2 + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-left:calc(var(--bs-gutter-x) * .5);padding-right:calc(var(--bs-gutter-x) * .5);margin-left:auto;margin-right:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-left:calc(-.5 * var(--bs-gutter-x));margin-right:calc(-.5 * var(--bs-gutter-x))}.row>*{box-sizing:border-box;flex-shrink:0;width:100%;max-width:100%;padding-left:calc(var(--bs-gutter-x) * .5);padding-right:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-right:8.33333333%}.offset-2{margin-right:16.66666667%}.offset-3{margin-right:25%}.offset-4{margin-right:33.33333333%}.offset-5{margin-right:41.66666667%}.offset-6{margin-right:50%}.offset-7{margin-right:58.33333333%}.offset-8{margin-right:66.66666667%}.offset-9{margin-right:75%}.offset-10{margin-right:83.33333333%}.offset-11{margin-right:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-right:0}.offset-sm-1{margin-right:8.33333333%}.offset-sm-2{margin-right:16.66666667%}.offset-sm-3{margin-right:25%}.offset-sm-4{margin-right:33.33333333%}.offset-sm-5{margin-right:41.66666667%}.offset-sm-6{margin-right:50%}.offset-sm-7{margin-right:58.33333333%}.offset-sm-8{margin-right:66.66666667%}.offset-sm-9{margin-right:75%}.offset-sm-10{margin-right:83.33333333%}.offset-sm-11{margin-right:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-right:0}.offset-md-1{margin-right:8.33333333%}.offset-md-2{margin-right:16.66666667%}.offset-md-3{margin-right:25%}.offset-md-4{margin-right:33.33333333%}.offset-md-5{margin-right:41.66666667%}.offset-md-6{margin-right:50%}.offset-md-7{margin-right:58.33333333%}.offset-md-8{margin-right:66.66666667%}.offset-md-9{margin-right:75%}.offset-md-10{margin-right:83.33333333%}.offset-md-11{margin-right:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-right:0}.offset-lg-1{margin-right:8.33333333%}.offset-lg-2{margin-right:16.66666667%}.offset-lg-3{margin-right:25%}.offset-lg-4{margin-right:33.33333333%}.offset-lg-5{margin-right:41.66666667%}.offset-lg-6{margin-right:50%}.offset-lg-7{margin-right:58.33333333%}.offset-lg-8{margin-right:66.66666667%}.offset-lg-9{margin-right:75%}.offset-lg-10{margin-right:83.33333333%}.offset-lg-11{margin-right:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-right:0}.offset-xl-1{margin-right:8.33333333%}.offset-xl-2{margin-right:16.66666667%}.offset-xl-3{margin-right:25%}.offset-xl-4{margin-right:33.33333333%}.offset-xl-5{margin-right:41.66666667%}.offset-xl-6{margin-right:50%}.offset-xl-7{margin-right:58.33333333%}.offset-xl-8{margin-right:66.66666667%}.offset-xl-9{margin-right:75%}.offset-xl-10{margin-right:83.33333333%}.offset-xl-11{margin-right:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-right:0}.offset-xxl-1{margin-right:8.33333333%}.offset-xxl-2{margin-right:16.66666667%}.offset-xxl-3{margin-right:25%}.offset-xxl-4{margin-right:33.33333333%}.offset-xxl-5{margin-right:41.66666667%}.offset-xxl-6{margin-right:50%}.offset-xxl-7{margin-right:58.33333333%}.offset-xxl-8{margin-right:66.66666667%}.offset-xxl-9{margin-right:75%}.offset-xxl-10{margin-right:83.33333333%}.offset-xxl-11{margin-right:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important}.mx-auto{margin-left:auto!important;margin-right:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-left:0!important}.me-1{margin-left:.25rem!important}.me-2{margin-left:.5rem!important}.me-3{margin-left:1rem!important}.me-4{margin-left:1.5rem!important}.me-5{margin-left:3rem!important}.me-auto{margin-left:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-right:0!important}.ms-1{margin-right:.25rem!important}.ms-2{margin-right:.5rem!important}.ms-3{margin-right:1rem!important}.ms-4{margin-right:1.5rem!important}.ms-5{margin-right:3rem!important}.ms-auto{margin-right:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-left:0!important;padding-right:0!important}.px-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-3{padding-left:1rem!important;padding-right:1rem!important}.px-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-5{padding-left:3rem!important;padding-right:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-left:0!important}.pe-1{padding-left:.25rem!important}.pe-2{padding-left:.5rem!important}.pe-3{padding-left:1rem!important}.pe-4{padding-left:1.5rem!important}.pe-5{padding-left:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-right:0!important}.ps-1{padding-right:.25rem!important}.ps-2{padding-right:.5rem!important}.ps-3{padding-right:1rem!important}.ps-4{padding-right:1.5rem!important}.ps-5{padding-right:3rem!important}@media (min-width:576px){.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-left:0!important;margin-right:0!important}.mx-sm-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-sm-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-sm-3{margin-left:1rem!important;margin-right:1rem!important}.mx-sm-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-sm-5{margin-left:3rem!important;margin-right:3rem!important}.mx-sm-auto{margin-left:auto!important;margin-right:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-left:0!important}.me-sm-1{margin-left:.25rem!important}.me-sm-2{margin-left:.5rem!important}.me-sm-3{margin-left:1rem!important}.me-sm-4{margin-left:1.5rem!important}.me-sm-5{margin-left:3rem!important}.me-sm-auto{margin-left:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-right:0!important}.ms-sm-1{margin-right:.25rem!important}.ms-sm-2{margin-right:.5rem!important}.ms-sm-3{margin-right:1rem!important}.ms-sm-4{margin-right:1.5rem!important}.ms-sm-5{margin-right:3rem!important}.ms-sm-auto{margin-right:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-left:0!important;padding-right:0!important}.px-sm-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-sm-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-sm-3{padding-left:1rem!important;padding-right:1rem!important}.px-sm-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-sm-5{padding-left:3rem!important;padding-right:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-left:0!important}.pe-sm-1{padding-left:.25rem!important}.pe-sm-2{padding-left:.5rem!important}.pe-sm-3{padding-left:1rem!important}.pe-sm-4{padding-left:1.5rem!important}.pe-sm-5{padding-left:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-right:0!important}.ps-sm-1{padding-right:.25rem!important}.ps-sm-2{padding-right:.5rem!important}.ps-sm-3{padding-right:1rem!important}.ps-sm-4{padding-right:1.5rem!important}.ps-sm-5{padding-right:3rem!important}}@media (min-width:768px){.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-left:0!important;margin-right:0!important}.mx-md-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-md-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-md-3{margin-left:1rem!important;margin-right:1rem!important}.mx-md-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-md-5{margin-left:3rem!important;margin-right:3rem!important}.mx-md-auto{margin-left:auto!important;margin-right:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-left:0!important}.me-md-1{margin-left:.25rem!important}.me-md-2{margin-left:.5rem!important}.me-md-3{margin-left:1rem!important}.me-md-4{margin-left:1.5rem!important}.me-md-5{margin-left:3rem!important}.me-md-auto{margin-left:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-right:0!important}.ms-md-1{margin-right:.25rem!important}.ms-md-2{margin-right:.5rem!important}.ms-md-3{margin-right:1rem!important}.ms-md-4{margin-right:1.5rem!important}.ms-md-5{margin-right:3rem!important}.ms-md-auto{margin-right:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-left:0!important;padding-right:0!important}.px-md-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-md-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-md-3{padding-left:1rem!important;padding-right:1rem!important}.px-md-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-md-5{padding-left:3rem!important;padding-right:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-left:0!important}.pe-md-1{padding-left:.25rem!important}.pe-md-2{padding-left:.5rem!important}.pe-md-3{padding-left:1rem!important}.pe-md-4{padding-left:1.5rem!important}.pe-md-5{padding-left:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-right:0!important}.ps-md-1{padding-right:.25rem!important}.ps-md-2{padding-right:.5rem!important}.ps-md-3{padding-right:1rem!important}.ps-md-4{padding-right:1.5rem!important}.ps-md-5{padding-right:3rem!important}}@media (min-width:992px){.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-left:0!important;margin-right:0!important}.mx-lg-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-lg-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-lg-3{margin-left:1rem!important;margin-right:1rem!important}.mx-lg-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-lg-5{margin-left:3rem!important;margin-right:3rem!important}.mx-lg-auto{margin-left:auto!important;margin-right:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-left:0!important}.me-lg-1{margin-left:.25rem!important}.me-lg-2{margin-left:.5rem!important}.me-lg-3{margin-left:1rem!important}.me-lg-4{margin-left:1.5rem!important}.me-lg-5{margin-left:3rem!important}.me-lg-auto{margin-left:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-right:0!important}.ms-lg-1{margin-right:.25rem!important}.ms-lg-2{margin-right:.5rem!important}.ms-lg-3{margin-right:1rem!important}.ms-lg-4{margin-right:1.5rem!important}.ms-lg-5{margin-right:3rem!important}.ms-lg-auto{margin-right:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-left:0!important;padding-right:0!important}.px-lg-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-lg-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-lg-3{padding-left:1rem!important;padding-right:1rem!important}.px-lg-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-lg-5{padding-left:3rem!important;padding-right:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-left:0!important}.pe-lg-1{padding-left:.25rem!important}.pe-lg-2{padding-left:.5rem!important}.pe-lg-3{padding-left:1rem!important}.pe-lg-4{padding-left:1.5rem!important}.pe-lg-5{padding-left:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-right:0!important}.ps-lg-1{padding-right:.25rem!important}.ps-lg-2{padding-right:.5rem!important}.ps-lg-3{padding-right:1rem!important}.ps-lg-4{padding-right:1.5rem!important}.ps-lg-5{padding-right:3rem!important}}@media (min-width:1200px){.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-left:0!important;margin-right:0!important}.mx-xl-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-xl-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-xl-3{margin-left:1rem!important;margin-right:1rem!important}.mx-xl-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-xl-5{margin-left:3rem!important;margin-right:3rem!important}.mx-xl-auto{margin-left:auto!important;margin-right:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-left:0!important}.me-xl-1{margin-left:.25rem!important}.me-xl-2{margin-left:.5rem!important}.me-xl-3{margin-left:1rem!important}.me-xl-4{margin-left:1.5rem!important}.me-xl-5{margin-left:3rem!important}.me-xl-auto{margin-left:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-right:0!important}.ms-xl-1{margin-right:.25rem!important}.ms-xl-2{margin-right:.5rem!important}.ms-xl-3{margin-right:1rem!important}.ms-xl-4{margin-right:1.5rem!important}.ms-xl-5{margin-right:3rem!important}.ms-xl-auto{margin-right:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-left:0!important;padding-right:0!important}.px-xl-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-xl-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-xl-3{padding-left:1rem!important;padding-right:1rem!important}.px-xl-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-xl-5{padding-left:3rem!important;padding-right:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-left:0!important}.pe-xl-1{padding-left:.25rem!important}.pe-xl-2{padding-left:.5rem!important}.pe-xl-3{padding-left:1rem!important}.pe-xl-4{padding-left:1.5rem!important}.pe-xl-5{padding-left:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-right:0!important}.ps-xl-1{padding-right:.25rem!important}.ps-xl-2{padding-right:.5rem!important}.ps-xl-3{padding-right:1rem!important}.ps-xl-4{padding-right:1.5rem!important}.ps-xl-5{padding-right:3rem!important}}@media (min-width:1400px){.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-left:0!important;margin-right:0!important}.mx-xxl-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-xxl-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-xxl-3{margin-left:1rem!important;margin-right:1rem!important}.mx-xxl-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-xxl-5{margin-left:3rem!important;margin-right:3rem!important}.mx-xxl-auto{margin-left:auto!important;margin-right:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-left:0!important}.me-xxl-1{margin-left:.25rem!important}.me-xxl-2{margin-left:.5rem!important}.me-xxl-3{margin-left:1rem!important}.me-xxl-4{margin-left:1.5rem!important}.me-xxl-5{margin-left:3rem!important}.me-xxl-auto{margin-left:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-right:0!important}.ms-xxl-1{margin-right:.25rem!important}.ms-xxl-2{margin-right:.5rem!important}.ms-xxl-3{margin-right:1rem!important}.ms-xxl-4{margin-right:1.5rem!important}.ms-xxl-5{margin-right:3rem!important}.ms-xxl-auto{margin-right:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-left:0!important;padding-right:0!important}.px-xxl-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-xxl-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-xxl-3{padding-left:1rem!important;padding-right:1rem!important}.px-xxl-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-xxl-5{padding-left:3rem!important;padding-right:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-left:0!important}.pe-xxl-1{padding-left:.25rem!important}.pe-xxl-2{padding-left:.5rem!important}.pe-xxl-3{padding-left:1rem!important}.pe-xxl-4{padding-left:1.5rem!important}.pe-xxl-5{padding-left:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-right:0!important}.ps-xxl-1{padding-right:.25rem!important}.ps-xxl-2{padding-right:.5rem!important}.ps-xxl-3{padding-right:1rem!important}.ps-xxl-4{padding-right:1.5rem!important}.ps-xxl-5{padding-right:3rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}
6 +/*# sourceMappingURL=bootstrap-grid.rtl.min.css.map */
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css +597 −0
@@ -0,0 +1,597 @@
1 +/*!
2 + * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */
6 +:root,
7 +[data-bs-theme=light] {
8 + --bs-blue: #0d6efd;
9 + --bs-indigo: #6610f2;
10 + --bs-purple: #6f42c1;
11 + --bs-pink: #d63384;
12 + --bs-red: #dc3545;
13 + --bs-orange: #fd7e14;
14 + --bs-yellow: #ffc107;
15 + --bs-green: #198754;
16 + --bs-teal: #20c997;
17 + --bs-cyan: #0dcaf0;
18 + --bs-black: #000;
19 + --bs-white: #fff;
20 + --bs-gray: #6c757d;
21 + --bs-gray-dark: #343a40;
22 + --bs-gray-100: #f8f9fa;
23 + --bs-gray-200: #e9ecef;
24 + --bs-gray-300: #dee2e6;
25 + --bs-gray-400: #ced4da;
26 + --bs-gray-500: #adb5bd;
27 + --bs-gray-600: #6c757d;
28 + --bs-gray-700: #495057;
29 + --bs-gray-800: #343a40;
30 + --bs-gray-900: #212529;
31 + --bs-primary: #0d6efd;
32 + --bs-secondary: #6c757d;
33 + --bs-success: #198754;
34 + --bs-info: #0dcaf0;
35 + --bs-warning: #ffc107;
36 + --bs-danger: #dc3545;
37 + --bs-light: #f8f9fa;
38 + --bs-dark: #212529;
39 + --bs-primary-rgb: 13, 110, 253;
40 + --bs-secondary-rgb: 108, 117, 125;
41 + --bs-success-rgb: 25, 135, 84;
42 + --bs-info-rgb: 13, 202, 240;
43 + --bs-warning-rgb: 255, 193, 7;
44 + --bs-danger-rgb: 220, 53, 69;
45 + --bs-light-rgb: 248, 249, 250;
46 + --bs-dark-rgb: 33, 37, 41;
47 + --bs-primary-text-emphasis: #052c65;
48 + --bs-secondary-text-emphasis: #2b2f32;
49 + --bs-success-text-emphasis: #0a3622;
50 + --bs-info-text-emphasis: #055160;
51 + --bs-warning-text-emphasis: #664d03;
52 + --bs-danger-text-emphasis: #58151c;
53 + --bs-light-text-emphasis: #495057;
54 + --bs-dark-text-emphasis: #495057;
55 + --bs-primary-bg-subtle: #cfe2ff;
56 + --bs-secondary-bg-subtle: #e2e3e5;
57 + --bs-success-bg-subtle: #d1e7dd;
58 + --bs-info-bg-subtle: #cff4fc;
59 + --bs-warning-bg-subtle: #fff3cd;
60 + --bs-danger-bg-subtle: #f8d7da;
61 + --bs-light-bg-subtle: #fcfcfd;
62 + --bs-dark-bg-subtle: #ced4da;
63 + --bs-primary-border-subtle: #9ec5fe;
64 + --bs-secondary-border-subtle: #c4c8cb;
65 + --bs-success-border-subtle: #a3cfbb;
66 + --bs-info-border-subtle: #9eeaf9;
67 + --bs-warning-border-subtle: #ffe69c;
68 + --bs-danger-border-subtle: #f1aeb5;
69 + --bs-light-border-subtle: #e9ecef;
70 + --bs-dark-border-subtle: #adb5bd;
71 + --bs-white-rgb: 255, 255, 255;
72 + --bs-black-rgb: 0, 0, 0;
73 + --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
74 + --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
75 + --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
76 + --bs-body-font-family: var(--bs-font-sans-serif);
77 + --bs-body-font-size: 1rem;
78 + --bs-body-font-weight: 400;
79 + --bs-body-line-height: 1.5;
80 + --bs-body-color: #212529;
81 + --bs-body-color-rgb: 33, 37, 41;
82 + --bs-body-bg: #fff;
83 + --bs-body-bg-rgb: 255, 255, 255;
84 + --bs-emphasis-color: #000;
85 + --bs-emphasis-color-rgb: 0, 0, 0;
86 + --bs-secondary-color: rgba(33, 37, 41, 0.75);
87 + --bs-secondary-color-rgb: 33, 37, 41;
88 + --bs-secondary-bg: #e9ecef;
89 + --bs-secondary-bg-rgb: 233, 236, 239;
90 + --bs-tertiary-color: rgba(33, 37, 41, 0.5);
91 + --bs-tertiary-color-rgb: 33, 37, 41;
92 + --bs-tertiary-bg: #f8f9fa;
93 + --bs-tertiary-bg-rgb: 248, 249, 250;
94 + --bs-heading-color: inherit;
95 + --bs-link-color: #0d6efd;
96 + --bs-link-color-rgb: 13, 110, 253;
97 + --bs-link-decoration: underline;
98 + --bs-link-hover-color: #0a58ca;
99 + --bs-link-hover-color-rgb: 10, 88, 202;
100 + --bs-code-color: #d63384;
101 + --bs-highlight-color: #212529;
102 + --bs-highlight-bg: #fff3cd;
103 + --bs-border-width: 1px;
104 + --bs-border-style: solid;
105 + --bs-border-color: #dee2e6;
106 + --bs-border-color-translucent: rgba(0, 0, 0, 0.175);
107 + --bs-border-radius: 0.375rem;
108 + --bs-border-radius-sm: 0.25rem;
109 + --bs-border-radius-lg: 0.5rem;
110 + --bs-border-radius-xl: 1rem;
111 + --bs-border-radius-xxl: 2rem;
112 + --bs-border-radius-2xl: var(--bs-border-radius-xxl);
113 + --bs-border-radius-pill: 50rem;
114 + --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
115 + --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
116 + --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
117 + --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
118 + --bs-focus-ring-width: 0.25rem;
119 + --bs-focus-ring-opacity: 0.25;
120 + --bs-focus-ring-color: rgba(13, 110, 253, 0.25);
121 + --bs-form-valid-color: #198754;
122 + --bs-form-valid-border-color: #198754;
123 + --bs-form-invalid-color: #dc3545;
124 + --bs-form-invalid-border-color: #dc3545;
125 +}
126 +
127 +[data-bs-theme=dark] {
128 + color-scheme: dark;
129 + --bs-body-color: #dee2e6;
130 + --bs-body-color-rgb: 222, 226, 230;
131 + --bs-body-bg: #212529;
132 + --bs-body-bg-rgb: 33, 37, 41;
133 + --bs-emphasis-color: #fff;
134 + --bs-emphasis-color-rgb: 255, 255, 255;
135 + --bs-secondary-color: rgba(222, 226, 230, 0.75);
136 + --bs-secondary-color-rgb: 222, 226, 230;
137 + --bs-secondary-bg: #343a40;
138 + --bs-secondary-bg-rgb: 52, 58, 64;
139 + --bs-tertiary-color: rgba(222, 226, 230, 0.5);
140 + --bs-tertiary-color-rgb: 222, 226, 230;
141 + --bs-tertiary-bg: #2b3035;
142 + --bs-tertiary-bg-rgb: 43, 48, 53;
143 + --bs-primary-text-emphasis: #6ea8fe;
144 + --bs-secondary-text-emphasis: #a7acb1;
145 + --bs-success-text-emphasis: #75b798;
146 + --bs-info-text-emphasis: #6edff6;
147 + --bs-warning-text-emphasis: #ffda6a;
148 + --bs-danger-text-emphasis: #ea868f;
149 + --bs-light-text-emphasis: #f8f9fa;
150 + --bs-dark-text-emphasis: #dee2e6;
151 + --bs-primary-bg-subtle: #031633;
152 + --bs-secondary-bg-subtle: #161719;
153 + --bs-success-bg-subtle: #051b11;
154 + --bs-info-bg-subtle: #032830;
155 + --bs-warning-bg-subtle: #332701;
156 + --bs-danger-bg-subtle: #2c0b0e;
157 + --bs-light-bg-subtle: #343a40;
158 + --bs-dark-bg-subtle: #1a1d20;
159 + --bs-primary-border-subtle: #084298;
160 + --bs-secondary-border-subtle: #41464b;
161 + --bs-success-border-subtle: #0f5132;
162 + --bs-info-border-subtle: #087990;
163 + --bs-warning-border-subtle: #997404;
164 + --bs-danger-border-subtle: #842029;
165 + --bs-light-border-subtle: #495057;
166 + --bs-dark-border-subtle: #343a40;
167 + --bs-heading-color: inherit;
168 + --bs-link-color: #6ea8fe;
169 + --bs-link-hover-color: #8bb9fe;
170 + --bs-link-color-rgb: 110, 168, 254;
171 + --bs-link-hover-color-rgb: 139, 185, 254;
172 + --bs-code-color: #e685b5;
173 + --bs-highlight-color: #dee2e6;
174 + --bs-highlight-bg: #664d03;
175 + --bs-border-color: #495057;
176 + --bs-border-color-translucent: rgba(255, 255, 255, 0.15);
177 + --bs-form-valid-color: #75b798;
178 + --bs-form-valid-border-color: #75b798;
179 + --bs-form-invalid-color: #ea868f;
180 + --bs-form-invalid-border-color: #ea868f;
181 +}
182 +
183 +*,
184 +*::before,
185 +*::after {
186 + box-sizing: border-box;
187 +}
188 +
189 +@media (prefers-reduced-motion: no-preference) {
190 + :root {
191 + scroll-behavior: smooth;
192 + }
193 +}
194 +
195 +body {
196 + margin: 0;
197 + font-family: var(--bs-body-font-family);
198 + font-size: var(--bs-body-font-size);
199 + font-weight: var(--bs-body-font-weight);
200 + line-height: var(--bs-body-line-height);
201 + color: var(--bs-body-color);
202 + text-align: var(--bs-body-text-align);
203 + background-color: var(--bs-body-bg);
204 + -webkit-text-size-adjust: 100%;
205 + -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
206 +}
207 +
208 +hr {
209 + margin: 1rem 0;
210 + color: inherit;
211 + border: 0;
212 + border-top: var(--bs-border-width) solid;
213 + opacity: 0.25;
214 +}
215 +
216 +h6, h5, h4, h3, h2, h1 {
217 + margin-top: 0;
218 + margin-bottom: 0.5rem;
219 + font-weight: 500;
220 + line-height: 1.2;
221 + color: var(--bs-heading-color);
222 +}
223 +
224 +h1 {
225 + font-size: calc(1.375rem + 1.5vw);
226 +}
227 +@media (min-width: 1200px) {
228 + h1 {
229 + font-size: 2.5rem;
230 + }
231 +}
232 +
233 +h2 {
234 + font-size: calc(1.325rem + 0.9vw);
235 +}
236 +@media (min-width: 1200px) {
237 + h2 {
238 + font-size: 2rem;
239 + }
240 +}
241 +
242 +h3 {
243 + font-size: calc(1.3rem + 0.6vw);
244 +}
245 +@media (min-width: 1200px) {
246 + h3 {
247 + font-size: 1.75rem;
248 + }
249 +}
250 +
251 +h4 {
252 + font-size: calc(1.275rem + 0.3vw);
253 +}
254 +@media (min-width: 1200px) {
255 + h4 {
256 + font-size: 1.5rem;
257 + }
258 +}
259 +
260 +h5 {
261 + font-size: 1.25rem;
262 +}
263 +
264 +h6 {
265 + font-size: 1rem;
266 +}
267 +
268 +p {
269 + margin-top: 0;
270 + margin-bottom: 1rem;
271 +}
272 +
273 +abbr[title] {
274 + -webkit-text-decoration: underline dotted;
275 + text-decoration: underline dotted;
276 + cursor: help;
277 + -webkit-text-decoration-skip-ink: none;
278 + text-decoration-skip-ink: none;
279 +}
280 +
281 +address {
282 + margin-bottom: 1rem;
283 + font-style: normal;
284 + line-height: inherit;
285 +}
286 +
287 +ol,
288 +ul {
289 + padding-left: 2rem;
290 +}
291 +
292 +ol,
293 +ul,
294 +dl {
295 + margin-top: 0;
296 + margin-bottom: 1rem;
297 +}
298 +
299 +ol ol,
300 +ul ul,
301 +ol ul,
302 +ul ol {
303 + margin-bottom: 0;
304 +}
305 +
306 +dt {
307 + font-weight: 700;
308 +}
309 +
310 +dd {
311 + margin-bottom: 0.5rem;
312 + margin-left: 0;
313 +}
314 +
315 +blockquote {
316 + margin: 0 0 1rem;
317 +}
318 +
319 +b,
320 +strong {
321 + font-weight: bolder;
322 +}
323 +
324 +small {
325 + font-size: 0.875em;
326 +}
327 +
328 +mark {
329 + padding: 0.1875em;
330 + color: var(--bs-highlight-color);
331 + background-color: var(--bs-highlight-bg);
332 +}
333 +
334 +sub,
335 +sup {
336 + position: relative;
337 + font-size: 0.75em;
338 + line-height: 0;
339 + vertical-align: baseline;
340 +}
341 +
342 +sub {
343 + bottom: -0.25em;
344 +}
345 +
346 +sup {
347 + top: -0.5em;
348 +}
349 +
350 +a {
351 + color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
352 + text-decoration: underline;
353 +}
354 +a:hover {
355 + --bs-link-color-rgb: var(--bs-link-hover-color-rgb);
356 +}
357 +
358 +a:not([href]):not([class]), a:not([href]):not([class]):hover {
359 + color: inherit;
360 + text-decoration: none;
361 +}
362 +
363 +pre,
364 +code,
365 +kbd,
366 +samp {
367 + font-family: var(--bs-font-monospace);
368 + font-size: 1em;
369 +}
370 +
371 +pre {
372 + display: block;
373 + margin-top: 0;
374 + margin-bottom: 1rem;
375 + overflow: auto;
376 + font-size: 0.875em;
377 +}
378 +pre code {
379 + font-size: inherit;
380 + color: inherit;
381 + word-break: normal;
382 +}
383 +
384 +code {
385 + font-size: 0.875em;
386 + color: var(--bs-code-color);
387 + word-wrap: break-word;
388 +}
389 +a > code {
390 + color: inherit;
391 +}
392 +
393 +kbd {
394 + padding: 0.1875rem 0.375rem;
395 + font-size: 0.875em;
396 + color: var(--bs-body-bg);
397 + background-color: var(--bs-body-color);
398 + border-radius: 0.25rem;
399 +}
400 +kbd kbd {
401 + padding: 0;
402 + font-size: 1em;
403 +}
404 +
405 +figure {
406 + margin: 0 0 1rem;
407 +}
408 +
409 +img,
410 +svg {
411 + vertical-align: middle;
412 +}
413 +
414 +table {
415 + caption-side: bottom;
416 + border-collapse: collapse;
417 +}
418 +
419 +caption {
420 + padding-top: 0.5rem;
421 + padding-bottom: 0.5rem;
422 + color: var(--bs-secondary-color);
423 + text-align: left;
424 +}
425 +
426 +th {
427 + text-align: inherit;
428 + text-align: -webkit-match-parent;
429 +}
430 +
431 +thead,
432 +tbody,
433 +tfoot,
434 +tr,
435 +td,
436 +th {
437 + border-color: inherit;
438 + border-style: solid;
439 + border-width: 0;
440 +}
441 +
442 +label {
443 + display: inline-block;
444 +}
445 +
446 +button {
447 + border-radius: 0;
448 +}
449 +
450 +button:focus:not(:focus-visible) {
451 + outline: 0;
452 +}
453 +
454 +input,
455 +button,
456 +select,
457 +optgroup,
458 +textarea {
459 + margin: 0;
460 + font-family: inherit;
461 + font-size: inherit;
462 + line-height: inherit;
463 +}
464 +
465 +button,
466 +select {
467 + text-transform: none;
468 +}
469 +
470 +[role=button] {
471 + cursor: pointer;
472 +}
473 +
474 +select {
475 + word-wrap: normal;
476 +}
477 +select:disabled {
478 + opacity: 1;
479 +}
480 +
481 +[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
482 + display: none !important;
483 +}
484 +
485 +button,
486 +[type=button],
487 +[type=reset],
488 +[type=submit] {
489 + -webkit-appearance: button;
490 +}
491 +button:not(:disabled),
492 +[type=button]:not(:disabled),
493 +[type=reset]:not(:disabled),
494 +[type=submit]:not(:disabled) {
495 + cursor: pointer;
496 +}
497 +
498 +::-moz-focus-inner {
499 + padding: 0;
500 + border-style: none;
501 +}
502 +
503 +textarea {
504 + resize: vertical;
505 +}
506 +
507 +fieldset {
508 + min-width: 0;
509 + padding: 0;
510 + margin: 0;
511 + border: 0;
512 +}
513 +
514 +legend {
515 + float: left;
516 + width: 100%;
517 + padding: 0;
518 + margin-bottom: 0.5rem;
519 + font-size: calc(1.275rem + 0.3vw);
520 + line-height: inherit;
521 +}
522 +@media (min-width: 1200px) {
523 + legend {
524 + font-size: 1.5rem;
525 + }
526 +}
527 +legend + * {
528 + clear: left;
529 +}
530 +
531 +::-webkit-datetime-edit-fields-wrapper,
532 +::-webkit-datetime-edit-text,
533 +::-webkit-datetime-edit-minute,
534 +::-webkit-datetime-edit-hour-field,
535 +::-webkit-datetime-edit-day-field,
536 +::-webkit-datetime-edit-month-field,
537 +::-webkit-datetime-edit-year-field {
538 + padding: 0;
539 +}
540 +
541 +::-webkit-inner-spin-button {
542 + height: auto;
543 +}
544 +
545 +[type=search] {
546 + -webkit-appearance: textfield;
547 + outline-offset: -2px;
548 +}
549 +
550 +/* rtl:raw:
551 +[type="tel"],
552 +[type="url"],
553 +[type="email"],
554 +[type="number"] {
555 + direction: ltr;
556 +}
557 +*/
558 +::-webkit-search-decoration {
559 + -webkit-appearance: none;
560 +}
561 +
562 +::-webkit-color-swatch-wrapper {
563 + padding: 0;
564 +}
565 +
566 +::-webkit-file-upload-button {
567 + font: inherit;
568 + -webkit-appearance: button;
569 +}
570 +
571 +::file-selector-button {
572 + font: inherit;
573 + -webkit-appearance: button;
574 +}
575 +
576 +output {
577 + display: inline-block;
578 +}
579 +
580 +iframe {
581 + border: 0;
582 +}
583 +
584 +summary {
585 + display: list-item;
586 + cursor: pointer;
587 +}
588 +
589 +progress {
590 + vertical-align: baseline;
591 +}
592 +
593 +[hidden] {
594 + display: none !important;
595 +}
596 +
597 +/*# sourceMappingURL=bootstrap-reboot.css.map */
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css +6 −0
@@ -0,0 +1,6 @@
1 +/*!
2 + * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
6 +/*# sourceMappingURL=bootstrap-reboot.min.css.map */
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map +1 −0
@@ -0,0 +1 @@
1 +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_root.scss","dist/css/bootstrap-reboot.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_color-mode.scss","../../scss/_reboot.scss","../../scss/mixins/_border-radius.scss"],"names":[],"mappings":"AACE;;;;ACDF,MCMA,sBDGI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAIA,uBAAA,QAAA,yBAAA,QAAA,uBAAA,QAAA,oBAAA,QAAA,uBAAA,QAAA,sBAAA,QAAA,qBAAA,QAAA,oBAAA,QAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,KAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAOA,sBAAA,0BE2OI,oBAAA,KFzOJ,sBAAA,IACA,sBAAA,IAKA,gBAAA,QACA,oBAAA,EAAA,CAAA,EAAA,CAAA,GACA,aAAA,KACA,iBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,oBAAA,KACA,wBAAA,CAAA,CAAA,CAAA,CAAA,EAEA,qBAAA,uBACA,yBAAA,EAAA,CAAA,EAAA,CAAA,GACA,kBAAA,QACA,sBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,oBAAA,sBACA,wBAAA,EAAA,CAAA,EAAA,CAAA,GACA,iBAAA,QACA,qBAAA,GAAA,CAAA,GAAA,CAAA,IAGA,mBAAA,QAEA,gBAAA,QACA,oBAAA,EAAA,CAAA,GAAA,CAAA,IACA,qBAAA,UAEA,sBAAA,QACA,0BAAA,EAAA,CAAA,EAAA,CAAA,IAMA,gBAAA,QACA,qBAAA,QACA,kBAAA,QAGA,kBAAA,IACA,kBAAA,MACA,kBAAA,QACA,8BAAA,qBAEA,mBAAA,SACA,sBAAA,QACA,sBAAA,OACA,sBAAA,KACA,uBAAA,KACA,uBAAA,4BACA,wBAAA,MAGA,gBAAA,EAAA,OAAA,KAAA,oBACA,mBAAA,EAAA,SAAA,QAAA,qBACA,mBAAA,EAAA,KAAA,KAAA,qBACA,sBAAA,MAAA,EAAA,IAAA,IAAA,qBAIA,sBAAA,QACA,wBAAA,KACA,sBAAA,yBAIA,sBAAA,QACA,6BAAA,QACA,wBAAA,QACA,+BAAA,QGhHE,qBHsHA,aAAA,KAGA,gBAAA,QACA,oBAAA,GAAA,CAAA,GAAA,CAAA,IACA,aAAA,QACA,iBAAA,EAAA,CAAA,EAAA,CAAA,GAEA,oBAAA,KACA,wBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,qBAAA,0BACA,yBAAA,GAAA,CAAA,GAAA,CAAA,IACA,kBAAA,QACA,sBAAA,EAAA,CAAA,EAAA,CAAA,GAEA,oBAAA,yBACA,wBAAA,GAAA,CAAA,GAAA,CAAA,IACA,iBAAA,QACA,qBAAA,EAAA,CAAA,EAAA,CAAA,GAGE,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAIA,uBAAA,QAAA,yBAAA,QAAA,uBAAA,QAAA,oBAAA,QAAA,uBAAA,QAAA,sBAAA,QAAA,qBAAA,QAAA,oBAAA,QAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAGF,mBAAA,QAEA,gBAAA,QACA,sBAAA,QACA,oBAAA,GAAA,CAAA,GAAA,CAAA,IACA,0BAAA,GAAA,CAAA,GAAA,CAAA,IAEA,gBAAA,QACA,qBAAA,QACA,kBAAA,QAEA,kBAAA,QACA,8BAAA,0BAEA,sBAAA,QACA,6BAAA,QACA,wBAAA,QACA,+BAAA,QIxKJ,EHyKA,QADA,SGrKE,WAAA,WAeE,8CANJ,MAOM,gBAAA,QAcN,KACE,OAAA,EACA,YAAA,2BF6OI,UAAA,yBE3OJ,YAAA,2BACA,YAAA,2BACA,MAAA,qBACA,WAAA,0BACA,iBAAA,kBACA,yBAAA,KACA,4BAAA,YASF,GACE,OAAA,KAAA,EACA,MAAA,QACA,OAAA,EACA,WAAA,uBAAA,MACA,QAAA,IAUF,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAGA,YAAA,IACA,YAAA,IACA,MAAA,wBAGF,GFuMQ,UAAA,uBA5JJ,0BE3CJ,GF8MQ,UAAA,QEzMR,GFkMQ,UAAA,sBA5JJ,0BEtCJ,GFyMQ,UAAA,MEpMR,GF6LQ,UAAA,oBA5JJ,0BEjCJ,GFoMQ,UAAA,SE/LR,GFwLQ,UAAA,sBA5JJ,0BE5BJ,GF+LQ,UAAA,QE1LR,GF+KM,UAAA,QE1KN,GF0KM,UAAA,KE/JN,EACE,WAAA,EACA,cAAA,KAUF,YACE,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,iCAAA,KAAA,yBAAA,KAMF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QAMF,GHiIA,GG/HE,aAAA,KHqIF,GGlIA,GHiIA,GG9HE,WAAA,EACA,cAAA,KAGF,MHkIA,MACA,MAFA,MG7HE,cAAA,EAGF,GACE,YAAA,IAKF,GACE,cAAA,MACA,YAAA,EAMF,WACE,OAAA,EAAA,EAAA,KAQF,EHuHA,OGrHE,YAAA,OAQF,MF6EM,UAAA,OEtEN,KACE,QAAA,QACA,MAAA,0BACA,iBAAA,uBASF,IHyGA,IGvGE,SAAA,SFwDI,UAAA,MEtDJ,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAKN,EACE,MAAA,wDACA,gBAAA,UAEA,QACE,oBAAA,+BAWF,2BAAA,iCAEE,MAAA,QACA,gBAAA,KHqGJ,KACA,IG/FA,IHgGA,KG5FE,YAAA,yBFcI,UAAA,IENN,IACE,QAAA,MACA,WAAA,EACA,cAAA,KACA,SAAA,KFEI,UAAA,OEGJ,SFHI,UAAA,QEKF,MAAA,QACA,WAAA,OAIJ,KFVM,UAAA,OEYJ,MAAA,qBACA,UAAA,WAGA,OACE,MAAA,QAIJ,IACE,QAAA,SAAA,QFtBI,UAAA,OEwBJ,MAAA,kBACA,iBAAA,qBCrSE,cAAA,ODwSF,QACE,QAAA,EF7BE,UAAA,IEwCN,OACE,OAAA,EAAA,EAAA,KAMF,IH2EA,IGzEE,eAAA,OAQF,MACE,aAAA,OACA,gBAAA,SAGF,QACE,YAAA,MACA,eAAA,MACA,MAAA,0BACA,WAAA,KAOF,GAEE,WAAA,QACA,WAAA,qBHoEF,MAGA,GAFA,MAGA,GGrEA,MHmEA,GG7DE,aAAA,QACA,aAAA,MACA,aAAA,EAQF,MACE,QAAA,aAMF,OAEE,cAAA,EAQF,iCACE,QAAA,EHsDF,OGjDA,MHmDA,SADA,OAEA,SG/CE,OAAA,EACA,YAAA,QF5HI,UAAA,QE8HJ,YAAA,QAIF,OHgDA,OG9CE,eAAA,KAKF,cACE,OAAA,QAGF,OAGE,UAAA,OAGA,gBACE,QAAA,EAOJ,0IACE,QAAA,eH0CF,cACA,aACA,cGpCA,OAIE,mBAAA,OHoCF,6BACA,4BACA,6BGnCI,sBACE,OAAA,QAON,mBACE,QAAA,EACA,aAAA,KAKF,SACE,OAAA,SAUF,SACE,UAAA,EACA,QAAA,EACA,OAAA,EACA,OAAA,EAQF,OACE,MAAA,KACA,MAAA,KACA,QAAA,EACA,cAAA,MFjNM,UAAA,sBEoNN,YAAA,QFhXE,0BEyWJ,OFtMQ,UAAA,QE+MN,SACE,MAAA,KH4BJ,kCGrBA,uCHoBA,mCADA,+BAGA,oCAJA,6BAKA,mCGhBE,QAAA,EAGF,4BACE,OAAA,KASF,cACE,mBAAA,UACA,eAAA,KAmBF,4BACE,mBAAA,KAKF,+BACE,QAAA,EAOF,6BACE,KAAA,QACA,mBAAA,OAFF,uBACE,KAAA,QACA,mBAAA,OAKF,OACE,QAAA,aAKF,OACE,OAAA,EAOF,QACE,QAAA,UACA,OAAA,QAQF,SACE,eAAA,SAQF,SACE,QAAA","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n",":root,\n[data-bs-theme=\"light\"] {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$prefix}#{$color}-rgb: #{$value};\n }\n\n @each $color, $value in $theme-colors-text {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}white-rgb: #{to-rgb($white)};\n --#{$prefix}black-rgb: #{to-rgb($black)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$prefix}gradient: #{$gradient};\n\n // Root and body\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$prefix}root-font-size: #{$font-size-root};\n }\n --#{$prefix}body-font-family: #{inspect($font-family-base)};\n @include rfs($font-size-base, --#{$prefix}body-font-size);\n --#{$prefix}body-font-weight: #{$font-weight-base};\n --#{$prefix}body-line-height: #{$line-height-base};\n @if $body-text-align != null {\n --#{$prefix}body-text-align: #{$body-text-align};\n }\n\n --#{$prefix}body-color: #{$body-color};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color)};\n --#{$prefix}body-bg: #{$body-bg};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg)};\n // scss-docs-end root-body-variables\n\n --#{$prefix}heading-color: #{$headings-color};\n\n --#{$prefix}link-color: #{$link-color};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color)};\n --#{$prefix}link-decoration: #{$link-decoration};\n\n --#{$prefix}link-hover-color: #{$link-hover-color};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color)};\n\n @if $link-hover-decoration != null {\n --#{$prefix}link-hover-decoration: #{$link-hover-decoration};\n }\n\n --#{$prefix}code-color: #{$code-color};\n --#{$prefix}highlight-color: #{$mark-color};\n --#{$prefix}highlight-bg: #{$mark-bg};\n\n // scss-docs-start root-border-var\n --#{$prefix}border-width: #{$border-width};\n --#{$prefix}border-style: #{$border-style};\n --#{$prefix}border-color: #{$border-color};\n --#{$prefix}border-color-translucent: #{$border-color-translucent};\n\n --#{$prefix}border-radius: #{$border-radius};\n --#{$prefix}border-radius-sm: #{$border-radius-sm};\n --#{$prefix}border-radius-lg: #{$border-radius-lg};\n --#{$prefix}border-radius-xl: #{$border-radius-xl};\n --#{$prefix}border-radius-xxl: #{$border-radius-xxl};\n --#{$prefix}border-radius-2xl: var(--#{$prefix}border-radius-xxl); // Deprecated in v5.3.0 for consistency\n --#{$prefix}border-radius-pill: #{$border-radius-pill};\n // scss-docs-end root-border-var\n\n --#{$prefix}box-shadow: #{$box-shadow};\n --#{$prefix}box-shadow-sm: #{$box-shadow-sm};\n --#{$prefix}box-shadow-lg: #{$box-shadow-lg};\n --#{$prefix}box-shadow-inset: #{$box-shadow-inset};\n\n // Focus styles\n // scss-docs-start root-focus-variables\n --#{$prefix}focus-ring-width: #{$focus-ring-width};\n --#{$prefix}focus-ring-opacity: #{$focus-ring-opacity};\n --#{$prefix}focus-ring-color: #{$focus-ring-color};\n // scss-docs-end root-focus-variables\n\n // scss-docs-start root-form-validation-variables\n --#{$prefix}form-valid-color: #{$form-valid-color};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color};\n --#{$prefix}form-invalid-color: #{$form-invalid-color};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color};\n // scss-docs-end root-form-validation-variables\n}\n\n@if $enable-dark-mode {\n @include color-mode(dark, true) {\n color-scheme: dark;\n\n // scss-docs-start root-dark-mode-vars\n --#{$prefix}body-color: #{$body-color-dark};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color-dark)};\n --#{$prefix}body-bg: #{$body-bg-dark};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg-dark)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color-dark};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color-dark)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color-dark};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color-dark)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg-dark};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg-dark)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color-dark};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color-dark)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg-dark};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg-dark)};\n\n @each $color, $value in $theme-colors-text-dark {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle-dark {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle-dark {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}heading-color: #{$headings-color-dark};\n\n --#{$prefix}link-color: #{$link-color-dark};\n --#{$prefix}link-hover-color: #{$link-hover-color-dark};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color-dark)};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color-dark)};\n\n --#{$prefix}code-color: #{$code-color-dark};\n --#{$prefix}highlight-color: #{$mark-color-dark};\n --#{$prefix}highlight-bg: #{$mark-bg-dark};\n\n --#{$prefix}border-color: #{$border-color-dark};\n --#{$prefix}border-color-translucent: #{$border-color-translucent-dark};\n\n --#{$prefix}form-valid-color: #{$form-valid-color-dark};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color-dark};\n --#{$prefix}form-invalid-color: #{$form-invalid-color-dark};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color-dark};\n // scss-docs-end root-dark-mode-vars\n }\n}\n","/*!\n * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n:root,\n[data-bs-theme=light] {\n --bs-blue: #0d6efd;\n --bs-indigo: #6610f2;\n --bs-purple: #6f42c1;\n --bs-pink: #d63384;\n --bs-red: #dc3545;\n --bs-orange: #fd7e14;\n --bs-yellow: #ffc107;\n --bs-green: #198754;\n --bs-teal: #20c997;\n --bs-cyan: #0dcaf0;\n --bs-black: #000;\n --bs-white: #fff;\n --bs-gray: #6c757d;\n --bs-gray-dark: #343a40;\n --bs-gray-100: #f8f9fa;\n --bs-gray-200: #e9ecef;\n --bs-gray-300: #dee2e6;\n --bs-gray-400: #ced4da;\n --bs-gray-500: #adb5bd;\n --bs-gray-600: #6c757d;\n --bs-gray-700: #495057;\n --bs-gray-800: #343a40;\n --bs-gray-900: #212529;\n --bs-primary: #0d6efd;\n --bs-secondary: #6c757d;\n --bs-success: #198754;\n --bs-info: #0dcaf0;\n --bs-warning: #ffc107;\n --bs-danger: #dc3545;\n --bs-light: #f8f9fa;\n --bs-dark: #212529;\n --bs-primary-rgb: 13, 110, 253;\n --bs-secondary-rgb: 108, 117, 125;\n --bs-success-rgb: 25, 135, 84;\n --bs-info-rgb: 13, 202, 240;\n --bs-warning-rgb: 255, 193, 7;\n --bs-danger-rgb: 220, 53, 69;\n --bs-light-rgb: 248, 249, 250;\n --bs-dark-rgb: 33, 37, 41;\n --bs-primary-text-emphasis: #052c65;\n --bs-secondary-text-emphasis: #2b2f32;\n --bs-success-text-emphasis: #0a3622;\n --bs-info-text-emphasis: #055160;\n --bs-warning-text-emphasis: #664d03;\n --bs-danger-text-emphasis: #58151c;\n --bs-light-text-emphasis: #495057;\n --bs-dark-text-emphasis: #495057;\n --bs-primary-bg-subtle: #cfe2ff;\n --bs-secondary-bg-subtle: #e2e3e5;\n --bs-success-bg-subtle: #d1e7dd;\n --bs-info-bg-subtle: #cff4fc;\n --bs-warning-bg-subtle: #fff3cd;\n --bs-danger-bg-subtle: #f8d7da;\n --bs-light-bg-subtle: #fcfcfd;\n --bs-dark-bg-subtle: #ced4da;\n --bs-primary-border-subtle: #9ec5fe;\n --bs-secondary-border-subtle: #c4c8cb;\n --bs-success-border-subtle: #a3cfbb;\n --bs-info-border-subtle: #9eeaf9;\n --bs-warning-border-subtle: #ffe69c;\n --bs-danger-border-subtle: #f1aeb5;\n --bs-light-border-subtle: #e9ecef;\n --bs-dark-border-subtle: #adb5bd;\n --bs-white-rgb: 255, 255, 255;\n --bs-black-rgb: 0, 0, 0;\n --bs-font-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));\n --bs-body-font-family: var(--bs-font-sans-serif);\n --bs-body-font-size: 1rem;\n --bs-body-font-weight: 400;\n --bs-body-line-height: 1.5;\n --bs-body-color: #212529;\n --bs-body-color-rgb: 33, 37, 41;\n --bs-body-bg: #fff;\n --bs-body-bg-rgb: 255, 255, 255;\n --bs-emphasis-color: #000;\n --bs-emphasis-color-rgb: 0, 0, 0;\n --bs-secondary-color: rgba(33, 37, 41, 0.75);\n --bs-secondary-color-rgb: 33, 37, 41;\n --bs-secondary-bg: #e9ecef;\n --bs-secondary-bg-rgb: 233, 236, 239;\n --bs-tertiary-color: rgba(33, 37, 41, 0.5);\n --bs-tertiary-color-rgb: 33, 37, 41;\n --bs-tertiary-bg: #f8f9fa;\n --bs-tertiary-bg-rgb: 248, 249, 250;\n --bs-heading-color: inherit;\n --bs-link-color: #0d6efd;\n --bs-link-color-rgb: 13, 110, 253;\n --bs-link-decoration: underline;\n --bs-link-hover-color: #0a58ca;\n --bs-link-hover-color-rgb: 10, 88, 202;\n --bs-code-color: #d63384;\n --bs-highlight-color: #212529;\n --bs-highlight-bg: #fff3cd;\n --bs-border-width: 1px;\n --bs-border-style: solid;\n --bs-border-color: #dee2e6;\n --bs-border-color-translucent: rgba(0, 0, 0, 0.175);\n --bs-border-radius: 0.375rem;\n --bs-border-radius-sm: 0.25rem;\n --bs-border-radius-lg: 0.5rem;\n --bs-border-radius-xl: 1rem;\n --bs-border-radius-xxl: 2rem;\n --bs-border-radius-2xl: var(--bs-border-radius-xxl);\n --bs-border-radius-pill: 50rem;\n --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);\n --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);\n --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);\n --bs-focus-ring-width: 0.25rem;\n --bs-focus-ring-opacity: 0.25;\n --bs-focus-ring-color: rgba(13, 110, 253, 0.25);\n --bs-form-valid-color: #198754;\n --bs-form-valid-border-color: #198754;\n --bs-form-invalid-color: #dc3545;\n --bs-form-invalid-border-color: #dc3545;\n}\n\n[data-bs-theme=dark] {\n color-scheme: dark;\n --bs-body-color: #dee2e6;\n --bs-body-color-rgb: 222, 226, 230;\n --bs-body-bg: #212529;\n --bs-body-bg-rgb: 33, 37, 41;\n --bs-emphasis-color: #fff;\n --bs-emphasis-color-rgb: 255, 255, 255;\n --bs-secondary-color: rgba(222, 226, 230, 0.75);\n --bs-secondary-color-rgb: 222, 226, 230;\n --bs-secondary-bg: #343a40;\n --bs-secondary-bg-rgb: 52, 58, 64;\n --bs-tertiary-color: rgba(222, 226, 230, 0.5);\n --bs-tertiary-color-rgb: 222, 226, 230;\n --bs-tertiary-bg: #2b3035;\n --bs-tertiary-bg-rgb: 43, 48, 53;\n --bs-primary-text-emphasis: #6ea8fe;\n --bs-secondary-text-emphasis: #a7acb1;\n --bs-success-text-emphasis: #75b798;\n --bs-info-text-emphasis: #6edff6;\n --bs-warning-text-emphasis: #ffda6a;\n --bs-danger-text-emphasis: #ea868f;\n --bs-light-text-emphasis: #f8f9fa;\n --bs-dark-text-emphasis: #dee2e6;\n --bs-primary-bg-subtle: #031633;\n --bs-secondary-bg-subtle: #161719;\n --bs-success-bg-subtle: #051b11;\n --bs-info-bg-subtle: #032830;\n --bs-warning-bg-subtle: #332701;\n --bs-danger-bg-subtle: #2c0b0e;\n --bs-light-bg-subtle: #343a40;\n --bs-dark-bg-subtle: #1a1d20;\n --bs-primary-border-subtle: #084298;\n --bs-secondary-border-subtle: #41464b;\n --bs-success-border-subtle: #0f5132;\n --bs-info-border-subtle: #087990;\n --bs-warning-border-subtle: #997404;\n --bs-danger-border-subtle: #842029;\n --bs-light-border-subtle: #495057;\n --bs-dark-border-subtle: #343a40;\n --bs-heading-color: inherit;\n --bs-link-color: #6ea8fe;\n --bs-link-hover-color: #8bb9fe;\n --bs-link-color-rgb: 110, 168, 254;\n --bs-link-hover-color-rgb: 139, 185, 254;\n --bs-code-color: #e685b5;\n --bs-highlight-color: #dee2e6;\n --bs-highlight-bg: #664d03;\n --bs-border-color: #495057;\n --bs-border-color-translucent: rgba(255, 255, 255, 0.15);\n --bs-form-valid-color: #75b798;\n --bs-form-valid-border-color: #75b798;\n --bs-form-invalid-color: #ea868f;\n --bs-form-invalid-border-color: #ea868f;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n :root {\n scroll-behavior: smooth;\n }\n}\n\nbody {\n margin: 0;\n font-family: var(--bs-body-font-family);\n font-size: var(--bs-body-font-size);\n font-weight: var(--bs-body-font-weight);\n line-height: var(--bs-body-line-height);\n color: var(--bs-body-color);\n text-align: var(--bs-body-text-align);\n background-color: var(--bs-body-bg);\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\nhr {\n margin: 1rem 0;\n color: inherit;\n border: 0;\n border-top: var(--bs-border-width) solid;\n opacity: 0.25;\n}\n\nh6, h5, h4, h3, h2, h1 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n color: var(--bs-heading-color);\n}\n\nh1 {\n font-size: calc(1.375rem + 1.5vw);\n}\n@media (min-width: 1200px) {\n h1 {\n font-size: 2.5rem;\n }\n}\n\nh2 {\n font-size: calc(1.325rem + 0.9vw);\n}\n@media (min-width: 1200px) {\n h2 {\n font-size: 2rem;\n }\n}\n\nh3 {\n font-size: calc(1.3rem + 0.6vw);\n}\n@media (min-width: 1200px) {\n h3 {\n font-size: 1.75rem;\n }\n}\n\nh4 {\n font-size: calc(1.275rem + 0.3vw);\n}\n@media (min-width: 1200px) {\n h4 {\n font-size: 1.5rem;\n }\n}\n\nh5 {\n font-size: 1.25rem;\n}\n\nh6 {\n font-size: 1rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title] {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n cursor: help;\n -webkit-text-decoration-skip-ink: none;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: 0.5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 0.875em;\n}\n\nmark {\n padding: 0.1875em;\n color: var(--bs-highlight-color);\n background-color: var(--bs-highlight-bg);\n}\n\nsub,\nsup {\n position: relative;\n font-size: 0.75em;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\na {\n color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));\n text-decoration: underline;\n}\na:hover {\n --bs-link-color-rgb: var(--bs-link-hover-color-rgb);\n}\n\na:not([href]):not([class]), a:not([href]):not([class]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: var(--bs-font-monospace);\n font-size: 1em;\n}\n\npre {\n display: block;\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n font-size: 0.875em;\n}\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\ncode {\n font-size: 0.875em;\n color: var(--bs-code-color);\n word-wrap: break-word;\n}\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.1875rem 0.375rem;\n font-size: 0.875em;\n color: var(--bs-body-bg);\n background-color: var(--bs-body-color);\n border-radius: 0.25rem;\n}\nkbd kbd {\n padding: 0;\n font-size: 1em;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n color: var(--bs-secondary-color);\n text-align: left;\n}\n\nth {\n text-align: inherit;\n text-align: -webkit-match-parent;\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\nlabel {\n display: inline-block;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\n[role=button] {\n cursor: pointer;\n}\n\nselect {\n word-wrap: normal;\n}\nselect:disabled {\n opacity: 1;\n}\n\n[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\nbutton,\n[type=button],\n[type=reset],\n[type=submit] {\n -webkit-appearance: button;\n}\nbutton:not(:disabled),\n[type=button]:not(:disabled),\n[type=reset]:not(:disabled),\n[type=submit]:not(:disabled) {\n cursor: pointer;\n}\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ntextarea {\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n float: left;\n width: 100%;\n padding: 0;\n margin-bottom: 0.5rem;\n font-size: calc(1.275rem + 0.3vw);\n line-height: inherit;\n}\n@media (min-width: 1200px) {\n legend {\n font-size: 1.5rem;\n }\n}\nlegend + * {\n clear: left;\n}\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n[type=search] {\n -webkit-appearance: textfield;\n outline-offset: -2px;\n}\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\n::file-selector-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\niframe {\n border: 0;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[hidden] {\n display: none !important;\n}\n\n/*# sourceMappingURL=bootstrap-reboot.css.map */","// stylelint-disable scss/dimension-no-non-numeric-values\n\n// SCSS RFS mixin\n//\n// Automated responsive values for font sizes, paddings, margins and much more\n//\n// Licensed under MIT (https://github.com/twbs/rfs/blob/main/LICENSE)\n\n// Configuration\n\n// Base value\n$rfs-base-value: 1.25rem !default;\n$rfs-unit: rem !default;\n\n@if $rfs-unit != rem and $rfs-unit != px {\n @error \"`#{$rfs-unit}` is not a valid unit for $rfs-unit. Use `px` or `rem`.\";\n}\n\n// Breakpoint at where values start decreasing if screen width is smaller\n$rfs-breakpoint: 1200px !default;\n$rfs-breakpoint-unit: px !default;\n\n@if $rfs-breakpoint-unit != px and $rfs-breakpoint-unit != em and $rfs-breakpoint-unit != rem {\n @error \"`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.\";\n}\n\n// Resize values based on screen height and width\n$rfs-two-dimensional: false !default;\n\n// Factor of decrease\n$rfs-factor: 10 !default;\n\n@if type-of($rfs-factor) != number or $rfs-factor <= 1 {\n @error \"`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.\";\n}\n\n// Mode. Possibilities: \"min-media-query\", \"max-media-query\"\n$rfs-mode: min-media-query !default;\n\n// Generate enable or disable classes. Possibilities: false, \"enable\" or \"disable\"\n$rfs-class: false !default;\n\n// 1 rem = $rfs-rem-value px\n$rfs-rem-value: 16 !default;\n\n// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14\n$rfs-safari-iframe-resize-bug-fix: false !default;\n\n// Disable RFS by setting $enable-rfs to false\n$enable-rfs: true !default;\n\n// Cache $rfs-base-value unit\n$rfs-base-value-unit: unit($rfs-base-value);\n\n@function divide($dividend, $divisor, $precision: 10) {\n $sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);\n $dividend: abs($dividend);\n $divisor: abs($divisor);\n @if $dividend == 0 {\n @return 0;\n }\n @if $divisor == 0 {\n @error \"Cannot divide by 0\";\n }\n $remainder: $dividend;\n $result: 0;\n $factor: 10;\n @while ($remainder > 0 and $precision >= 0) {\n $quotient: 0;\n @while ($remainder >= $divisor) {\n $remainder: $remainder - $divisor;\n $quotient: $quotient + 1;\n }\n $result: $result * 10 + $quotient;\n $factor: $factor * .1;\n $remainder: $remainder * 10;\n $precision: $precision - 1;\n @if ($precision < 0 and $remainder >= $divisor * 5) {\n $result: $result + 1;\n }\n }\n $result: $result * $factor * $sign;\n $dividend-unit: unit($dividend);\n $divisor-unit: unit($divisor);\n $unit-map: (\n \"px\": 1px,\n \"rem\": 1rem,\n \"em\": 1em,\n \"%\": 1%\n );\n @if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {\n $result: $result * map-get($unit-map, $dividend-unit);\n }\n @return $result;\n}\n\n// Remove px-unit from $rfs-base-value for calculations\n@if $rfs-base-value-unit == px {\n $rfs-base-value: divide($rfs-base-value, $rfs-base-value * 0 + 1);\n}\n@else if $rfs-base-value-unit == rem {\n $rfs-base-value: divide($rfs-base-value, divide($rfs-base-value * 0 + 1, $rfs-rem-value));\n}\n\n// Cache $rfs-breakpoint unit to prevent multiple calls\n$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);\n\n// Remove unit from $rfs-breakpoint for calculations\n@if $rfs-breakpoint-unit-cache == px {\n $rfs-breakpoint: divide($rfs-breakpoint, $rfs-breakpoint * 0 + 1);\n}\n@else if $rfs-breakpoint-unit-cache == rem or $rfs-breakpoint-unit-cache == \"em\" {\n $rfs-breakpoint: divide($rfs-breakpoint, divide($rfs-breakpoint * 0 + 1, $rfs-rem-value));\n}\n\n// Calculate the media query value\n$rfs-mq-value: if($rfs-breakpoint-unit == px, #{$rfs-breakpoint}px, #{divide($rfs-breakpoint, $rfs-rem-value)}#{$rfs-breakpoint-unit});\n$rfs-mq-property-width: if($rfs-mode == max-media-query, max-width, min-width);\n$rfs-mq-property-height: if($rfs-mode == max-media-query, max-height, min-height);\n\n// Internal mixin used to determine which media query needs to be used\n@mixin _rfs-media-query {\n @if $rfs-two-dimensional {\n @if $rfs-mode == max-media-query {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}), (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) and (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) {\n @content;\n }\n }\n}\n\n// Internal mixin that adds disable classes to the selector if needed.\n@mixin _rfs-rule {\n @if $rfs-class == disable and $rfs-mode == max-media-query {\n // Adding an extra class increases specificity, which prevents the media query to override the property\n &,\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @else if $rfs-class == enable and $rfs-mode == min-media-query {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Internal mixin that adds enable classes to the selector if needed.\n@mixin _rfs-media-query-rule {\n\n @if $rfs-class == enable {\n @if $rfs-mode == min-media-query {\n @content;\n }\n\n @include _rfs-media-query () {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n }\n }\n @else {\n @if $rfs-class == disable and $rfs-mode == min-media-query {\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @include _rfs-media-query () {\n @content;\n }\n }\n}\n\n// Helper function to get the formatted non-responsive value\n@function rfs-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n }\n @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n @if $unit == px {\n // Convert to rem if needed\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $value * 0 + $rfs-rem-value)}rem, $value);\n }\n @else if $unit == rem {\n // Convert to px if needed\n $val: $val + \" \" + if($rfs-unit == px, #{divide($value, $value * 0 + 1) * $rfs-rem-value}px, $value);\n } @else {\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n $val: $val + \" \" + $value;\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// Helper function to get the responsive value calculated by RFS\n@function rfs-fluid-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n } @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n @if not $unit or $unit != px and $unit != rem {\n $val: $val + \" \" + $value;\n } @else {\n // Remove unit from $value for calculations\n $value: divide($value, $value * 0 + if($unit == px, 1, divide(1, $rfs-rem-value)));\n\n // Only add the media query if the value is greater than the minimum value\n @if abs($value) <= $rfs-base-value or not $enable-rfs {\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $rfs-rem-value)}rem, #{$value}px);\n }\n @else {\n // Calculate the minimum value\n $value-min: $rfs-base-value + divide(abs($value) - $rfs-base-value, $rfs-factor);\n\n // Calculate difference between $value and the minimum value\n $value-diff: abs($value) - $value-min;\n\n // Base value formatting\n $min-width: if($rfs-unit == rem, #{divide($value-min, $rfs-rem-value)}rem, #{$value-min}px);\n\n // Use negative value if needed\n $min-width: if($value < 0, -$min-width, $min-width);\n\n // Use `vmin` if two-dimensional is enabled\n $variable-unit: if($rfs-two-dimensional, vmin, vw);\n\n // Calculate the variable width between 0 and $rfs-breakpoint\n $variable-width: #{divide($value-diff * 100, $rfs-breakpoint)}#{$variable-unit};\n\n // Return the calculated value\n $val: $val + \" calc(\" + $min-width + if($value < 0, \" - \", \" + \") + $variable-width + \")\";\n }\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// RFS mixin\n@mixin rfs($values, $property: font-size) {\n @if $values != null {\n $val: rfs-value($values);\n $fluid-val: rfs-fluid-value($values);\n\n // Do not print the media query if responsive & non-responsive values are the same\n @if $val == $fluid-val {\n #{$property}: $val;\n }\n @else {\n @include _rfs-rule () {\n #{$property}: if($rfs-mode == max-media-query, $val, $fluid-val);\n\n // Include safari iframe resize fix if needed\n min-width: if($rfs-safari-iframe-resize-bug-fix, (0 * 1vw), null);\n }\n\n @include _rfs-media-query-rule () {\n #{$property}: if($rfs-mode == max-media-query, $fluid-val, $val);\n }\n }\n }\n}\n\n// Shorthand helper mixins\n@mixin font-size($value) {\n @include rfs($value);\n}\n\n@mixin padding($value) {\n @include rfs($value, padding);\n}\n\n@mixin padding-top($value) {\n @include rfs($value, padding-top);\n}\n\n@mixin padding-right($value) {\n @include rfs($value, padding-right);\n}\n\n@mixin padding-bottom($value) {\n @include rfs($value, padding-bottom);\n}\n\n@mixin padding-left($value) {\n @include rfs($value, padding-left);\n}\n\n@mixin margin($value) {\n @include rfs($value, margin);\n}\n\n@mixin margin-top($value) {\n @include rfs($value, margin-top);\n}\n\n@mixin margin-right($value) {\n @include rfs($value, margin-right);\n}\n\n@mixin margin-bottom($value) {\n @include rfs($value, margin-bottom);\n}\n\n@mixin margin-left($value) {\n @include rfs($value, margin-left);\n}\n","// scss-docs-start color-mode-mixin\n@mixin color-mode($mode: light, $root: false) {\n @if $color-mode-type == \"media-query\" {\n @if $root == true {\n @media (prefers-color-scheme: $mode) {\n :root {\n @content;\n }\n }\n } @else {\n @media (prefers-color-scheme: $mode) {\n @content;\n }\n }\n } @else {\n [data-bs-theme=\"#{$mode}\"] {\n @content;\n }\n }\n}\n// scss-docs-end color-mode-mixin\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n @include font-size(var(--#{$prefix}root-font-size));\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$prefix}body-font-family);\n @include font-size(var(--#{$prefix}body-font-size));\n font-weight: var(--#{$prefix}body-font-weight);\n line-height: var(--#{$prefix}body-line-height);\n color: var(--#{$prefix}body-color);\n text-align: var(--#{$prefix}body-text-align);\n background-color: var(--#{$prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n border: 0;\n border-top: $hr-border-width solid $hr-border-color;\n opacity: $hr-opacity;\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `<h1>`-`<h6>` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: var(--#{$prefix}heading-color);\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `<p>`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 2. Add explicit cursor to indicate changed behavior.\n// 3. Prevent the text-decoration to be skipped.\n\nabbr[title] {\n text-decoration: underline dotted; // 1\n cursor: help; // 2\n text-decoration-skip-ink: none; // 3\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n color: var(--#{$prefix}highlight-color);\n background-color: var(--#{$prefix}highlight-bg);\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: rgba(var(--#{$prefix}link-color-rgb), var(--#{$prefix}link-opacity, 1));\n text-decoration: $link-decoration;\n\n &:hover {\n --#{$prefix}link-color-rgb: var(--#{$prefix}link-hover-color-rgb);\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: var(--#{$prefix}code-color);\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `<td>` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`<button>` buttons\n//\n// Details at https://github.com/twbs/bootstrap/pull/30562\n[role=\"button\"] {\n cursor: pointer;\n}\n\nselect {\n // Remove the inheritance of word-wrap in Safari.\n // See https://github.com/twbs/bootstrap/issues/24990\n word-wrap: normal;\n\n // Undo the opacity change from Chrome\n &:disabled {\n opacity: 1;\n }\n}\n\n// Remove the dropdown arrow only from text type inputs built with datalists in Chrome.\n// See https://stackoverflow.com/a/54997118\n\n[list]:not([type=\"date\"]):not([type=\"datetime-local\"]):not([type=\"month\"]):not([type=\"week\"]):not([type=\"time\"])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\n// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n// controls in Android 4.\n// 2. Correct the inability to style clickable types in iOS and Safari.\n// 3. Opinionated: add \"hand\" cursor to non-disabled button elements.\n\nbutton,\n[type=\"button\"], // 1\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button; // 2\n\n @if $enable-button-pointers {\n &:not(:disabled) {\n cursor: pointer; // 3\n }\n }\n}\n\n// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\n// 1. Textareas should really only resize vertically so they don't break their (horizontal) containers.\n\ntextarea {\n resize: vertical; // 1\n}\n\n// 1. Browsers set a default `min-width: min-content;` on fieldsets,\n// unlike e.g. `<div>`s, which have `min-width: 0;` by default.\n// So we reset that to ensure fieldsets behave more like a standard block element.\n// See https://github.com/twbs/bootstrap/issues/12359\n// and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements\n// 2. Reset the default outline behavior of fieldsets so they don't affect page layout.\n\nfieldset {\n min-width: 0; // 1\n padding: 0; // 2\n margin: 0; // 2\n border: 0; // 2\n}\n\n// 1. By using `float: left`, the legend will behave like a block element.\n// This way the border of a fieldset wraps around the legend if present.\n// 2. Fix wrapping bug.\n// See https://github.com/twbs/bootstrap/issues/29712\n\nlegend {\n float: left; // 1\n width: 100%;\n padding: 0;\n margin-bottom: $legend-margin-bottom;\n @include font-size($legend-font-size);\n font-weight: $legend-font-weight;\n line-height: inherit;\n\n + * {\n clear: left; // 2\n }\n}\n\n// Fix height of inputs with a type of datetime-local, date, month, week, or time\n// See https://github.com/twbs/bootstrap/issues/18842\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n// 1. This overrides the extra rounded corners on search inputs in iOS so that our\n// `.form-control` class can properly style them. Note that this cannot simply\n// be added to `.form-control` as it's not specific enough. For details, see\n// https://github.com/twbs/bootstrap/issues/11586.\n// 2. Correct the outline style in Safari.\n\n[type=\"search\"] {\n -webkit-appearance: textfield; // 1\n outline-offset: -2px; // 2\n}\n\n// 1. A few input types should stay LTR\n// See https://rtlstyling.com/posts/rtl-styling#form-inputs\n// 2. RTL only output\n// See https://rtlcss.com/learn/usage-guide/control-directives/#raw\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n\n// Remove the inner padding in Chrome and Safari on macOS.\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n// Remove padding around color pickers in webkit browsers\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n\n// 1. Inherit font family and line height for file input buttons\n// 2. Correct the inability to style clickable types in iOS and Safari.\n\n::file-selector-button {\n font: inherit; // 1\n -webkit-appearance: button; // 2\n}\n\n// Correct element displays\n\noutput {\n display: inline-block;\n}\n\n// Remove border from iframe\n\niframe {\n border: 0;\n}\n\n// Summary\n//\n// 1. Add the correct display in all browsers\n\nsummary {\n display: list-item; // 1\n cursor: pointer;\n}\n\n\n// Progress\n//\n// Add the correct vertical alignment in Chrome, Firefox, and Opera.\n\nprogress {\n vertical-align: baseline;\n}\n\n\n// Hidden attribute\n//\n// Always hide an element with the `hidden` HTML attribute.\n\n[hidden] {\n display: none !important;\n}\n","// stylelint-disable property-disallowed-list\n// Single side border-radius\n\n// Helper function to replace negative values with 0\n@function valid-radius($radius) {\n $return: ();\n @each $value in $radius {\n @if type-of($value) == number {\n $return: append($return, max($value, 0));\n } @else {\n $return: append($return, $value);\n }\n }\n @return $return;\n}\n\n// scss-docs-start border-radius-mixins\n@mixin border-radius($radius: $border-radius, $fallback-border-radius: false) {\n @if $enable-rounded {\n border-radius: valid-radius($radius);\n }\n @else if $fallback-border-radius != false {\n border-radius: $fallback-border-radius;\n }\n}\n\n@mixin border-top-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n border-top-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-right-radius: valid-radius($radius);\n border-bottom-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-right-radius: valid-radius($radius);\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-top-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-top-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n// scss-docs-end border-radius-mixins\n"]}
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css +594 −0
@@ -0,0 +1,594 @@
1 +/*!
2 + * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */
6 +:root,
7 +[data-bs-theme=light] {
8 + --bs-blue: #0d6efd;
9 + --bs-indigo: #6610f2;
10 + --bs-purple: #6f42c1;
11 + --bs-pink: #d63384;
12 + --bs-red: #dc3545;
13 + --bs-orange: #fd7e14;
14 + --bs-yellow: #ffc107;
15 + --bs-green: #198754;
16 + --bs-teal: #20c997;
17 + --bs-cyan: #0dcaf0;
18 + --bs-black: #000;
19 + --bs-white: #fff;
20 + --bs-gray: #6c757d;
21 + --bs-gray-dark: #343a40;
22 + --bs-gray-100: #f8f9fa;
23 + --bs-gray-200: #e9ecef;
24 + --bs-gray-300: #dee2e6;
25 + --bs-gray-400: #ced4da;
26 + --bs-gray-500: #adb5bd;
27 + --bs-gray-600: #6c757d;
28 + --bs-gray-700: #495057;
29 + --bs-gray-800: #343a40;
30 + --bs-gray-900: #212529;
31 + --bs-primary: #0d6efd;
32 + --bs-secondary: #6c757d;
33 + --bs-success: #198754;
34 + --bs-info: #0dcaf0;
35 + --bs-warning: #ffc107;
36 + --bs-danger: #dc3545;
37 + --bs-light: #f8f9fa;
38 + --bs-dark: #212529;
39 + --bs-primary-rgb: 13, 110, 253;
40 + --bs-secondary-rgb: 108, 117, 125;
41 + --bs-success-rgb: 25, 135, 84;
42 + --bs-info-rgb: 13, 202, 240;
43 + --bs-warning-rgb: 255, 193, 7;
44 + --bs-danger-rgb: 220, 53, 69;
45 + --bs-light-rgb: 248, 249, 250;
46 + --bs-dark-rgb: 33, 37, 41;
47 + --bs-primary-text-emphasis: #052c65;
48 + --bs-secondary-text-emphasis: #2b2f32;
49 + --bs-success-text-emphasis: #0a3622;
50 + --bs-info-text-emphasis: #055160;
51 + --bs-warning-text-emphasis: #664d03;
52 + --bs-danger-text-emphasis: #58151c;
53 + --bs-light-text-emphasis: #495057;
54 + --bs-dark-text-emphasis: #495057;
55 + --bs-primary-bg-subtle: #cfe2ff;
56 + --bs-secondary-bg-subtle: #e2e3e5;
57 + --bs-success-bg-subtle: #d1e7dd;
58 + --bs-info-bg-subtle: #cff4fc;
59 + --bs-warning-bg-subtle: #fff3cd;
60 + --bs-danger-bg-subtle: #f8d7da;
61 + --bs-light-bg-subtle: #fcfcfd;
62 + --bs-dark-bg-subtle: #ced4da;
63 + --bs-primary-border-subtle: #9ec5fe;
64 + --bs-secondary-border-subtle: #c4c8cb;
65 + --bs-success-border-subtle: #a3cfbb;
66 + --bs-info-border-subtle: #9eeaf9;
67 + --bs-warning-border-subtle: #ffe69c;
68 + --bs-danger-border-subtle: #f1aeb5;
69 + --bs-light-border-subtle: #e9ecef;
70 + --bs-dark-border-subtle: #adb5bd;
71 + --bs-white-rgb: 255, 255, 255;
72 + --bs-black-rgb: 0, 0, 0;
73 + --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
74 + --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
75 + --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
76 + --bs-body-font-family: var(--bs-font-sans-serif);
77 + --bs-body-font-size: 1rem;
78 + --bs-body-font-weight: 400;
79 + --bs-body-line-height: 1.5;
80 + --bs-body-color: #212529;
81 + --bs-body-color-rgb: 33, 37, 41;
82 + --bs-body-bg: #fff;
83 + --bs-body-bg-rgb: 255, 255, 255;
84 + --bs-emphasis-color: #000;
85 + --bs-emphasis-color-rgb: 0, 0, 0;
86 + --bs-secondary-color: rgba(33, 37, 41, 0.75);
87 + --bs-secondary-color-rgb: 33, 37, 41;
88 + --bs-secondary-bg: #e9ecef;
89 + --bs-secondary-bg-rgb: 233, 236, 239;
90 + --bs-tertiary-color: rgba(33, 37, 41, 0.5);
91 + --bs-tertiary-color-rgb: 33, 37, 41;
92 + --bs-tertiary-bg: #f8f9fa;
93 + --bs-tertiary-bg-rgb: 248, 249, 250;
94 + --bs-heading-color: inherit;
95 + --bs-link-color: #0d6efd;
96 + --bs-link-color-rgb: 13, 110, 253;
97 + --bs-link-decoration: underline;
98 + --bs-link-hover-color: #0a58ca;
99 + --bs-link-hover-color-rgb: 10, 88, 202;
100 + --bs-code-color: #d63384;
101 + --bs-highlight-color: #212529;
102 + --bs-highlight-bg: #fff3cd;
103 + --bs-border-width: 1px;
104 + --bs-border-style: solid;
105 + --bs-border-color: #dee2e6;
106 + --bs-border-color-translucent: rgba(0, 0, 0, 0.175);
107 + --bs-border-radius: 0.375rem;
108 + --bs-border-radius-sm: 0.25rem;
109 + --bs-border-radius-lg: 0.5rem;
110 + --bs-border-radius-xl: 1rem;
111 + --bs-border-radius-xxl: 2rem;
112 + --bs-border-radius-2xl: var(--bs-border-radius-xxl);
113 + --bs-border-radius-pill: 50rem;
114 + --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
115 + --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
116 + --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
117 + --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
118 + --bs-focus-ring-width: 0.25rem;
119 + --bs-focus-ring-opacity: 0.25;
120 + --bs-focus-ring-color: rgba(13, 110, 253, 0.25);
121 + --bs-form-valid-color: #198754;
122 + --bs-form-valid-border-color: #198754;
123 + --bs-form-invalid-color: #dc3545;
124 + --bs-form-invalid-border-color: #dc3545;
125 +}
126 +
127 +[data-bs-theme=dark] {
128 + color-scheme: dark;
129 + --bs-body-color: #dee2e6;
130 + --bs-body-color-rgb: 222, 226, 230;
131 + --bs-body-bg: #212529;
132 + --bs-body-bg-rgb: 33, 37, 41;
133 + --bs-emphasis-color: #fff;
134 + --bs-emphasis-color-rgb: 255, 255, 255;
135 + --bs-secondary-color: rgba(222, 226, 230, 0.75);
136 + --bs-secondary-color-rgb: 222, 226, 230;
137 + --bs-secondary-bg: #343a40;
138 + --bs-secondary-bg-rgb: 52, 58, 64;
139 + --bs-tertiary-color: rgba(222, 226, 230, 0.5);
140 + --bs-tertiary-color-rgb: 222, 226, 230;
141 + --bs-tertiary-bg: #2b3035;
142 + --bs-tertiary-bg-rgb: 43, 48, 53;
143 + --bs-primary-text-emphasis: #6ea8fe;
144 + --bs-secondary-text-emphasis: #a7acb1;
145 + --bs-success-text-emphasis: #75b798;
146 + --bs-info-text-emphasis: #6edff6;
147 + --bs-warning-text-emphasis: #ffda6a;
148 + --bs-danger-text-emphasis: #ea868f;
149 + --bs-light-text-emphasis: #f8f9fa;
150 + --bs-dark-text-emphasis: #dee2e6;
151 + --bs-primary-bg-subtle: #031633;
152 + --bs-secondary-bg-subtle: #161719;
153 + --bs-success-bg-subtle: #051b11;
154 + --bs-info-bg-subtle: #032830;
155 + --bs-warning-bg-subtle: #332701;
156 + --bs-danger-bg-subtle: #2c0b0e;
157 + --bs-light-bg-subtle: #343a40;
158 + --bs-dark-bg-subtle: #1a1d20;
159 + --bs-primary-border-subtle: #084298;
160 + --bs-secondary-border-subtle: #41464b;
161 + --bs-success-border-subtle: #0f5132;
162 + --bs-info-border-subtle: #087990;
163 + --bs-warning-border-subtle: #997404;
164 + --bs-danger-border-subtle: #842029;
165 + --bs-light-border-subtle: #495057;
166 + --bs-dark-border-subtle: #343a40;
167 + --bs-heading-color: inherit;
168 + --bs-link-color: #6ea8fe;
169 + --bs-link-hover-color: #8bb9fe;
170 + --bs-link-color-rgb: 110, 168, 254;
171 + --bs-link-hover-color-rgb: 139, 185, 254;
172 + --bs-code-color: #e685b5;
173 + --bs-highlight-color: #dee2e6;
174 + --bs-highlight-bg: #664d03;
175 + --bs-border-color: #495057;
176 + --bs-border-color-translucent: rgba(255, 255, 255, 0.15);
177 + --bs-form-valid-color: #75b798;
178 + --bs-form-valid-border-color: #75b798;
179 + --bs-form-invalid-color: #ea868f;
180 + --bs-form-invalid-border-color: #ea868f;
181 +}
182 +
183 +*,
184 +*::before,
185 +*::after {
186 + box-sizing: border-box;
187 +}
188 +
189 +@media (prefers-reduced-motion: no-preference) {
190 + :root {
191 + scroll-behavior: smooth;
192 + }
193 +}
194 +
195 +body {
196 + margin: 0;
197 + font-family: var(--bs-body-font-family);
198 + font-size: var(--bs-body-font-size);
199 + font-weight: var(--bs-body-font-weight);
200 + line-height: var(--bs-body-line-height);
201 + color: var(--bs-body-color);
202 + text-align: var(--bs-body-text-align);
203 + background-color: var(--bs-body-bg);
204 + -webkit-text-size-adjust: 100%;
205 + -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
206 +}
207 +
208 +hr {
209 + margin: 1rem 0;
210 + color: inherit;
211 + border: 0;
212 + border-top: var(--bs-border-width) solid;
213 + opacity: 0.25;
214 +}
215 +
216 +h6, h5, h4, h3, h2, h1 {
217 + margin-top: 0;
218 + margin-bottom: 0.5rem;
219 + font-weight: 500;
220 + line-height: 1.2;
221 + color: var(--bs-heading-color);
222 +}
223 +
224 +h1 {
225 + font-size: calc(1.375rem + 1.5vw);
226 +}
227 +@media (min-width: 1200px) {
228 + h1 {
229 + font-size: 2.5rem;
230 + }
231 +}
232 +
233 +h2 {
234 + font-size: calc(1.325rem + 0.9vw);
235 +}
236 +@media (min-width: 1200px) {
237 + h2 {
238 + font-size: 2rem;
239 + }
240 +}
241 +
242 +h3 {
243 + font-size: calc(1.3rem + 0.6vw);
244 +}
245 +@media (min-width: 1200px) {
246 + h3 {
247 + font-size: 1.75rem;
248 + }
249 +}
250 +
251 +h4 {
252 + font-size: calc(1.275rem + 0.3vw);
253 +}
254 +@media (min-width: 1200px) {
255 + h4 {
256 + font-size: 1.5rem;
257 + }
258 +}
259 +
260 +h5 {
261 + font-size: 1.25rem;
262 +}
263 +
264 +h6 {
265 + font-size: 1rem;
266 +}
267 +
268 +p {
269 + margin-top: 0;
270 + margin-bottom: 1rem;
271 +}
272 +
273 +abbr[title] {
274 + -webkit-text-decoration: underline dotted;
275 + text-decoration: underline dotted;
276 + cursor: help;
277 + -webkit-text-decoration-skip-ink: none;
278 + text-decoration-skip-ink: none;
279 +}
280 +
281 +address {
282 + margin-bottom: 1rem;
283 + font-style: normal;
284 + line-height: inherit;
285 +}
286 +
287 +ol,
288 +ul {
289 + padding-right: 2rem;
290 +}
291 +
292 +ol,
293 +ul,
294 +dl {
295 + margin-top: 0;
296 + margin-bottom: 1rem;
297 +}
298 +
299 +ol ol,
300 +ul ul,
301 +ol ul,
302 +ul ol {
303 + margin-bottom: 0;
304 +}
305 +
306 +dt {
307 + font-weight: 700;
308 +}
309 +
310 +dd {
311 + margin-bottom: 0.5rem;
312 + margin-right: 0;
313 +}
314 +
315 +blockquote {
316 + margin: 0 0 1rem;
317 +}
318 +
319 +b,
320 +strong {
321 + font-weight: bolder;
322 +}
323 +
324 +small {
325 + font-size: 0.875em;
326 +}
327 +
328 +mark {
329 + padding: 0.1875em;
330 + color: var(--bs-highlight-color);
331 + background-color: var(--bs-highlight-bg);
332 +}
333 +
334 +sub,
335 +sup {
336 + position: relative;
337 + font-size: 0.75em;
338 + line-height: 0;
339 + vertical-align: baseline;
340 +}
341 +
342 +sub {
343 + bottom: -0.25em;
344 +}
345 +
346 +sup {
347 + top: -0.5em;
348 +}
349 +
350 +a {
351 + color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
352 + text-decoration: underline;
353 +}
354 +a:hover {
355 + --bs-link-color-rgb: var(--bs-link-hover-color-rgb);
356 +}
357 +
358 +a:not([href]):not([class]), a:not([href]):not([class]):hover {
359 + color: inherit;
360 + text-decoration: none;
361 +}
362 +
363 +pre,
364 +code,
365 +kbd,
366 +samp {
367 + font-family: var(--bs-font-monospace);
368 + font-size: 1em;
369 +}
370 +
371 +pre {
372 + display: block;
373 + margin-top: 0;
374 + margin-bottom: 1rem;
375 + overflow: auto;
376 + font-size: 0.875em;
377 +}
378 +pre code {
379 + font-size: inherit;
380 + color: inherit;
381 + word-break: normal;
382 +}
383 +
384 +code {
385 + font-size: 0.875em;
386 + color: var(--bs-code-color);
387 + word-wrap: break-word;
388 +}
389 +a > code {
390 + color: inherit;
391 +}
392 +
393 +kbd {
394 + padding: 0.1875rem 0.375rem;
395 + font-size: 0.875em;
396 + color: var(--bs-body-bg);
397 + background-color: var(--bs-body-color);
398 + border-radius: 0.25rem;
399 +}
400 +kbd kbd {
401 + padding: 0;
402 + font-size: 1em;
403 +}
404 +
405 +figure {
406 + margin: 0 0 1rem;
407 +}
408 +
409 +img,
410 +svg {
411 + vertical-align: middle;
412 +}
413 +
414 +table {
415 + caption-side: bottom;
416 + border-collapse: collapse;
417 +}
418 +
419 +caption {
420 + padding-top: 0.5rem;
421 + padding-bottom: 0.5rem;
422 + color: var(--bs-secondary-color);
423 + text-align: right;
424 +}
425 +
426 +th {
427 + text-align: inherit;
428 + text-align: -webkit-match-parent;
429 +}
430 +
431 +thead,
432 +tbody,
433 +tfoot,
434 +tr,
435 +td,
436 +th {
437 + border-color: inherit;
438 + border-style: solid;
439 + border-width: 0;
440 +}
441 +
442 +label {
443 + display: inline-block;
444 +}
445 +
446 +button {
447 + border-radius: 0;
448 +}
449 +
450 +button:focus:not(:focus-visible) {
451 + outline: 0;
452 +}
453 +
454 +input,
455 +button,
456 +select,
457 +optgroup,
458 +textarea {
459 + margin: 0;
460 + font-family: inherit;
461 + font-size: inherit;
462 + line-height: inherit;
463 +}
464 +
465 +button,
466 +select {
467 + text-transform: none;
468 +}
469 +
470 +[role=button] {
471 + cursor: pointer;
472 +}
473 +
474 +select {
475 + word-wrap: normal;
476 +}
477 +select:disabled {
478 + opacity: 1;
479 +}
480 +
481 +[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
482 + display: none !important;
483 +}
484 +
485 +button,
486 +[type=button],
487 +[type=reset],
488 +[type=submit] {
489 + -webkit-appearance: button;
490 +}
491 +button:not(:disabled),
492 +[type=button]:not(:disabled),
493 +[type=reset]:not(:disabled),
494 +[type=submit]:not(:disabled) {
495 + cursor: pointer;
496 +}
497 +
498 +::-moz-focus-inner {
499 + padding: 0;
500 + border-style: none;
501 +}
502 +
503 +textarea {
504 + resize: vertical;
505 +}
506 +
507 +fieldset {
508 + min-width: 0;
509 + padding: 0;
510 + margin: 0;
511 + border: 0;
512 +}
513 +
514 +legend {
515 + float: right;
516 + width: 100%;
517 + padding: 0;
518 + margin-bottom: 0.5rem;
519 + font-size: calc(1.275rem + 0.3vw);
520 + line-height: inherit;
521 +}
522 +@media (min-width: 1200px) {
523 + legend {
524 + font-size: 1.5rem;
525 + }
526 +}
527 +legend + * {
528 + clear: right;
529 +}
530 +
531 +::-webkit-datetime-edit-fields-wrapper,
532 +::-webkit-datetime-edit-text,
533 +::-webkit-datetime-edit-minute,
534 +::-webkit-datetime-edit-hour-field,
535 +::-webkit-datetime-edit-day-field,
536 +::-webkit-datetime-edit-month-field,
537 +::-webkit-datetime-edit-year-field {
538 + padding: 0;
539 +}
540 +
541 +::-webkit-inner-spin-button {
542 + height: auto;
543 +}
544 +
545 +[type=search] {
546 + -webkit-appearance: textfield;
547 + outline-offset: -2px;
548 +}
549 +
550 +[type="tel"],
551 +[type="url"],
552 +[type="email"],
553 +[type="number"] {
554 + direction: ltr;
555 +}
556 +::-webkit-search-decoration {
557 + -webkit-appearance: none;
558 +}
559 +
560 +::-webkit-color-swatch-wrapper {
561 + padding: 0;
562 +}
563 +
564 +::-webkit-file-upload-button {
565 + font: inherit;
566 + -webkit-appearance: button;
567 +}
568 +
569 +::file-selector-button {
570 + font: inherit;
571 + -webkit-appearance: button;
572 +}
573 +
574 +output {
575 + display: inline-block;
576 +}
577 +
578 +iframe {
579 + border: 0;
580 +}
581 +
582 +summary {
583 + display: list-item;
584 + cursor: pointer;
585 +}
586 +
587 +progress {
588 + vertical-align: baseline;
589 +}
590 +
591 +[hidden] {
592 + display: none !important;
593 +}
594 +/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css +6 −0
@@ -0,0 +1,6 @@
1 +/*!
2 + * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-right:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-right:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:right}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:right;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:right}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=email],[type=number],[type=tel],[type=url]{direction:ltr}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
6 +/*# sourceMappingURL=bootstrap-reboot.rtl.min.css.map */
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map +1 −0
@@ -0,0 +1 @@
1 +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_root.scss","dist/css/bootstrap-reboot.rtl.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_color-mode.scss","../../scss/_reboot.scss","../../scss/mixins/_border-radius.scss","bootstrap-reboot.css"],"names":[],"mappings":"AACE;;;;ACDF,MCMA,sBDGI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAIA,uBAAA,QAAA,yBAAA,QAAA,uBAAA,QAAA,oBAAA,QAAA,uBAAA,QAAA,sBAAA,QAAA,qBAAA,QAAA,oBAAA,QAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,KAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAOA,sBAAA,0BE2OI,oBAAA,KFzOJ,sBAAA,IACA,sBAAA,IAKA,gBAAA,QACA,oBAAA,EAAA,CAAA,EAAA,CAAA,GACA,aAAA,KACA,iBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,oBAAA,KACA,wBAAA,CAAA,CAAA,CAAA,CAAA,EAEA,qBAAA,uBACA,yBAAA,EAAA,CAAA,EAAA,CAAA,GACA,kBAAA,QACA,sBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,oBAAA,sBACA,wBAAA,EAAA,CAAA,EAAA,CAAA,GACA,iBAAA,QACA,qBAAA,GAAA,CAAA,GAAA,CAAA,IAGA,mBAAA,QAEA,gBAAA,QACA,oBAAA,EAAA,CAAA,GAAA,CAAA,IACA,qBAAA,UAEA,sBAAA,QACA,0BAAA,EAAA,CAAA,EAAA,CAAA,IAMA,gBAAA,QACA,qBAAA,QACA,kBAAA,QAGA,kBAAA,IACA,kBAAA,MACA,kBAAA,QACA,8BAAA,qBAEA,mBAAA,SACA,sBAAA,QACA,sBAAA,OACA,sBAAA,KACA,uBAAA,KACA,uBAAA,4BACA,wBAAA,MAGA,gBAAA,EAAA,OAAA,KAAA,oBACA,mBAAA,EAAA,SAAA,QAAA,qBACA,mBAAA,EAAA,KAAA,KAAA,qBACA,sBAAA,MAAA,EAAA,IAAA,IAAA,qBAIA,sBAAA,QACA,wBAAA,KACA,sBAAA,yBAIA,sBAAA,QACA,6BAAA,QACA,wBAAA,QACA,+BAAA,QGhHE,qBHsHA,aAAA,KAGA,gBAAA,QACA,oBAAA,GAAA,CAAA,GAAA,CAAA,IACA,aAAA,QACA,iBAAA,EAAA,CAAA,EAAA,CAAA,GAEA,oBAAA,KACA,wBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,qBAAA,0BACA,yBAAA,GAAA,CAAA,GAAA,CAAA,IACA,kBAAA,QACA,sBAAA,EAAA,CAAA,EAAA,CAAA,GAEA,oBAAA,yBACA,wBAAA,GAAA,CAAA,GAAA,CAAA,IACA,iBAAA,QACA,qBAAA,EAAA,CAAA,EAAA,CAAA,GAGE,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAIA,uBAAA,QAAA,yBAAA,QAAA,uBAAA,QAAA,oBAAA,QAAA,uBAAA,QAAA,sBAAA,QAAA,qBAAA,QAAA,oBAAA,QAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAGF,mBAAA,QAEA,gBAAA,QACA,sBAAA,QACA,oBAAA,GAAA,CAAA,GAAA,CAAA,IACA,0BAAA,GAAA,CAAA,GAAA,CAAA,IAEA,gBAAA,QACA,qBAAA,QACA,kBAAA,QAEA,kBAAA,QACA,8BAAA,0BAEA,sBAAA,QACA,6BAAA,QACA,wBAAA,QACA,+BAAA,QIxKJ,EHyKA,QADA,SGrKE,WAAA,WAeE,8CANJ,MAOM,gBAAA,QAcN,KACE,OAAA,EACA,YAAA,2BF6OI,UAAA,yBE3OJ,YAAA,2BACA,YAAA,2BACA,MAAA,qBACA,WAAA,0BACA,iBAAA,kBACA,yBAAA,KACA,4BAAA,YASF,GACE,OAAA,KAAA,EACA,MAAA,QACA,OAAA,EACA,WAAA,uBAAA,MACA,QAAA,IAUF,GAAA,GAAA,GAAA,GAAA,GAAA,GACE,WAAA,EACA,cAAA,MAGA,YAAA,IACA,YAAA,IACA,MAAA,wBAGF,GFuMQ,UAAA,uBA5JJ,0BE3CJ,GF8MQ,UAAA,QEzMR,GFkMQ,UAAA,sBA5JJ,0BEtCJ,GFyMQ,UAAA,MEpMR,GF6LQ,UAAA,oBA5JJ,0BEjCJ,GFoMQ,UAAA,SE/LR,GFwLQ,UAAA,sBA5JJ,0BE5BJ,GF+LQ,UAAA,QE1LR,GF+KM,UAAA,QE1KN,GF0KM,UAAA,KE/JN,EACE,WAAA,EACA,cAAA,KAUF,YACE,wBAAA,UAAA,OAAA,gBAAA,UAAA,OACA,OAAA,KACA,iCAAA,KAAA,yBAAA,KAMF,QACE,cAAA,KACA,WAAA,OACA,YAAA,QAMF,GHiIA,GG/HE,cAAA,KHqIF,GGlIA,GHiIA,GG9HE,WAAA,EACA,cAAA,KAGF,MHkIA,MACA,MAFA,MG7HE,cAAA,EAGF,GACE,YAAA,IAKF,GACE,cAAA,MACA,aAAA,EAMF,WACE,OAAA,EAAA,EAAA,KAQF,EHuHA,OGrHE,YAAA,OAQF,MF6EM,UAAA,OEtEN,KACE,QAAA,QACA,MAAA,0BACA,iBAAA,uBASF,IHyGA,IGvGE,SAAA,SFwDI,UAAA,MEtDJ,YAAA,EACA,eAAA,SAGF,IAAM,OAAA,OACN,IAAM,IAAA,MAKN,EACE,MAAA,wDACA,gBAAA,UAEA,QACE,oBAAA,+BAWF,2BAAA,iCAEE,MAAA,QACA,gBAAA,KHqGJ,KACA,IG/FA,IHgGA,KG5FE,YAAA,yBFcI,UAAA,IENN,IACE,QAAA,MACA,WAAA,EACA,cAAA,KACA,SAAA,KFEI,UAAA,OEGJ,SFHI,UAAA,QEKF,MAAA,QACA,WAAA,OAIJ,KFVM,UAAA,OEYJ,MAAA,qBACA,UAAA,WAGA,OACE,MAAA,QAIJ,IACE,QAAA,SAAA,QFtBI,UAAA,OEwBJ,MAAA,kBACA,iBAAA,qBCrSE,cAAA,ODwSF,QACE,QAAA,EF7BE,UAAA,IEwCN,OACE,OAAA,EAAA,EAAA,KAMF,IH2EA,IGzEE,eAAA,OAQF,MACE,aAAA,OACA,gBAAA,SAGF,QACE,YAAA,MACA,eAAA,MACA,MAAA,0BACA,WAAA,MAOF,GAEE,WAAA,QACA,WAAA,qBHoEF,MAGA,GAFA,MAGA,GGrEA,MHmEA,GG7DE,aAAA,QACA,aAAA,MACA,aAAA,EAQF,MACE,QAAA,aAMF,OAEE,cAAA,EAQF,iCACE,QAAA,EHsDF,OGjDA,MHmDA,SADA,OAEA,SG/CE,OAAA,EACA,YAAA,QF5HI,UAAA,QE8HJ,YAAA,QAIF,OHgDA,OG9CE,eAAA,KAKF,cACE,OAAA,QAGF,OAGE,UAAA,OAGA,gBACE,QAAA,EAOJ,0IACE,QAAA,eH0CF,cACA,aACA,cGpCA,OAIE,mBAAA,OHoCF,6BACA,4BACA,6BGnCI,sBACE,OAAA,QAON,mBACE,QAAA,EACA,aAAA,KAKF,SACE,OAAA,SAUF,SACE,UAAA,EACA,QAAA,EACA,OAAA,EACA,OAAA,EAQF,OACE,MAAA,MACA,MAAA,KACA,QAAA,EACA,cAAA,MFjNM,UAAA,sBEoNN,YAAA,QFhXE,0BEyWJ,OFtMQ,UAAA,QE+MN,SACE,MAAA,MH4BJ,kCGrBA,uCHoBA,mCADA,+BAGA,oCAJA,6BAKA,mCGhBE,QAAA,EAGF,4BACE,OAAA,KASF,cACE,mBAAA,UACA,eAAA,KHgBF,aACA,cKviBA,WLqiBA,WDtiBA,UAAA,II0iBA,4BACE,mBAAA,KAKF,+BACE,QAAA,EAOF,6BACE,KAAA,QACA,mBAAA,OAFF,uBACE,KAAA,QACA,mBAAA,OAKF,OACE,QAAA,aAKF,OACE,OAAA,EAOF,QACE,QAAA,UACA,OAAA,QAQF,SACE,eAAA,SAQF,SACE,QAAA","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n",":root,\n[data-bs-theme=\"light\"] {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$prefix}#{$color}-rgb: #{$value};\n }\n\n @each $color, $value in $theme-colors-text {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}white-rgb: #{to-rgb($white)};\n --#{$prefix}black-rgb: #{to-rgb($black)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$prefix}gradient: #{$gradient};\n\n // Root and body\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$prefix}root-font-size: #{$font-size-root};\n }\n --#{$prefix}body-font-family: #{inspect($font-family-base)};\n @include rfs($font-size-base, --#{$prefix}body-font-size);\n --#{$prefix}body-font-weight: #{$font-weight-base};\n --#{$prefix}body-line-height: #{$line-height-base};\n @if $body-text-align != null {\n --#{$prefix}body-text-align: #{$body-text-align};\n }\n\n --#{$prefix}body-color: #{$body-color};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color)};\n --#{$prefix}body-bg: #{$body-bg};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg)};\n // scss-docs-end root-body-variables\n\n --#{$prefix}heading-color: #{$headings-color};\n\n --#{$prefix}link-color: #{$link-color};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color)};\n --#{$prefix}link-decoration: #{$link-decoration};\n\n --#{$prefix}link-hover-color: #{$link-hover-color};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color)};\n\n @if $link-hover-decoration != null {\n --#{$prefix}link-hover-decoration: #{$link-hover-decoration};\n }\n\n --#{$prefix}code-color: #{$code-color};\n --#{$prefix}highlight-color: #{$mark-color};\n --#{$prefix}highlight-bg: #{$mark-bg};\n\n // scss-docs-start root-border-var\n --#{$prefix}border-width: #{$border-width};\n --#{$prefix}border-style: #{$border-style};\n --#{$prefix}border-color: #{$border-color};\n --#{$prefix}border-color-translucent: #{$border-color-translucent};\n\n --#{$prefix}border-radius: #{$border-radius};\n --#{$prefix}border-radius-sm: #{$border-radius-sm};\n --#{$prefix}border-radius-lg: #{$border-radius-lg};\n --#{$prefix}border-radius-xl: #{$border-radius-xl};\n --#{$prefix}border-radius-xxl: #{$border-radius-xxl};\n --#{$prefix}border-radius-2xl: var(--#{$prefix}border-radius-xxl); // Deprecated in v5.3.0 for consistency\n --#{$prefix}border-radius-pill: #{$border-radius-pill};\n // scss-docs-end root-border-var\n\n --#{$prefix}box-shadow: #{$box-shadow};\n --#{$prefix}box-shadow-sm: #{$box-shadow-sm};\n --#{$prefix}box-shadow-lg: #{$box-shadow-lg};\n --#{$prefix}box-shadow-inset: #{$box-shadow-inset};\n\n // Focus styles\n // scss-docs-start root-focus-variables\n --#{$prefix}focus-ring-width: #{$focus-ring-width};\n --#{$prefix}focus-ring-opacity: #{$focus-ring-opacity};\n --#{$prefix}focus-ring-color: #{$focus-ring-color};\n // scss-docs-end root-focus-variables\n\n // scss-docs-start root-form-validation-variables\n --#{$prefix}form-valid-color: #{$form-valid-color};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color};\n --#{$prefix}form-invalid-color: #{$form-invalid-color};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color};\n // scss-docs-end root-form-validation-variables\n}\n\n@if $enable-dark-mode {\n @include color-mode(dark, true) {\n color-scheme: dark;\n\n // scss-docs-start root-dark-mode-vars\n --#{$prefix}body-color: #{$body-color-dark};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color-dark)};\n --#{$prefix}body-bg: #{$body-bg-dark};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg-dark)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color-dark};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color-dark)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color-dark};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color-dark)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg-dark};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg-dark)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color-dark};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color-dark)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg-dark};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg-dark)};\n\n @each $color, $value in $theme-colors-text-dark {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle-dark {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle-dark {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}heading-color: #{$headings-color-dark};\n\n --#{$prefix}link-color: #{$link-color-dark};\n --#{$prefix}link-hover-color: #{$link-hover-color-dark};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color-dark)};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color-dark)};\n\n --#{$prefix}code-color: #{$code-color-dark};\n --#{$prefix}highlight-color: #{$mark-color-dark};\n --#{$prefix}highlight-bg: #{$mark-bg-dark};\n\n --#{$prefix}border-color: #{$border-color-dark};\n --#{$prefix}border-color-translucent: #{$border-color-translucent-dark};\n\n --#{$prefix}form-valid-color: #{$form-valid-color-dark};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color-dark};\n --#{$prefix}form-invalid-color: #{$form-invalid-color-dark};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color-dark};\n // scss-docs-end root-dark-mode-vars\n }\n}\n","/*!\n * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n:root,\n[data-bs-theme=light] {\n --bs-blue: #0d6efd;\n --bs-indigo: #6610f2;\n --bs-purple: #6f42c1;\n --bs-pink: #d63384;\n --bs-red: #dc3545;\n --bs-orange: #fd7e14;\n --bs-yellow: #ffc107;\n --bs-green: #198754;\n --bs-teal: #20c997;\n --bs-cyan: #0dcaf0;\n --bs-black: #000;\n --bs-white: #fff;\n --bs-gray: #6c757d;\n --bs-gray-dark: #343a40;\n --bs-gray-100: #f8f9fa;\n --bs-gray-200: #e9ecef;\n --bs-gray-300: #dee2e6;\n --bs-gray-400: #ced4da;\n --bs-gray-500: #adb5bd;\n --bs-gray-600: #6c757d;\n --bs-gray-700: #495057;\n --bs-gray-800: #343a40;\n --bs-gray-900: #212529;\n --bs-primary: #0d6efd;\n --bs-secondary: #6c757d;\n --bs-success: #198754;\n --bs-info: #0dcaf0;\n --bs-warning: #ffc107;\n --bs-danger: #dc3545;\n --bs-light: #f8f9fa;\n --bs-dark: #212529;\n --bs-primary-rgb: 13, 110, 253;\n --bs-secondary-rgb: 108, 117, 125;\n --bs-success-rgb: 25, 135, 84;\n --bs-info-rgb: 13, 202, 240;\n --bs-warning-rgb: 255, 193, 7;\n --bs-danger-rgb: 220, 53, 69;\n --bs-light-rgb: 248, 249, 250;\n --bs-dark-rgb: 33, 37, 41;\n --bs-primary-text-emphasis: #052c65;\n --bs-secondary-text-emphasis: #2b2f32;\n --bs-success-text-emphasis: #0a3622;\n --bs-info-text-emphasis: #055160;\n --bs-warning-text-emphasis: #664d03;\n --bs-danger-text-emphasis: #58151c;\n --bs-light-text-emphasis: #495057;\n --bs-dark-text-emphasis: #495057;\n --bs-primary-bg-subtle: #cfe2ff;\n --bs-secondary-bg-subtle: #e2e3e5;\n --bs-success-bg-subtle: #d1e7dd;\n --bs-info-bg-subtle: #cff4fc;\n --bs-warning-bg-subtle: #fff3cd;\n --bs-danger-bg-subtle: #f8d7da;\n --bs-light-bg-subtle: #fcfcfd;\n --bs-dark-bg-subtle: #ced4da;\n --bs-primary-border-subtle: #9ec5fe;\n --bs-secondary-border-subtle: #c4c8cb;\n --bs-success-border-subtle: #a3cfbb;\n --bs-info-border-subtle: #9eeaf9;\n --bs-warning-border-subtle: #ffe69c;\n --bs-danger-border-subtle: #f1aeb5;\n --bs-light-border-subtle: #e9ecef;\n --bs-dark-border-subtle: #adb5bd;\n --bs-white-rgb: 255, 255, 255;\n --bs-black-rgb: 0, 0, 0;\n --bs-font-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));\n --bs-body-font-family: var(--bs-font-sans-serif);\n --bs-body-font-size: 1rem;\n --bs-body-font-weight: 400;\n --bs-body-line-height: 1.5;\n --bs-body-color: #212529;\n --bs-body-color-rgb: 33, 37, 41;\n --bs-body-bg: #fff;\n --bs-body-bg-rgb: 255, 255, 255;\n --bs-emphasis-color: #000;\n --bs-emphasis-color-rgb: 0, 0, 0;\n --bs-secondary-color: rgba(33, 37, 41, 0.75);\n --bs-secondary-color-rgb: 33, 37, 41;\n --bs-secondary-bg: #e9ecef;\n --bs-secondary-bg-rgb: 233, 236, 239;\n --bs-tertiary-color: rgba(33, 37, 41, 0.5);\n --bs-tertiary-color-rgb: 33, 37, 41;\n --bs-tertiary-bg: #f8f9fa;\n --bs-tertiary-bg-rgb: 248, 249, 250;\n --bs-heading-color: inherit;\n --bs-link-color: #0d6efd;\n --bs-link-color-rgb: 13, 110, 253;\n --bs-link-decoration: underline;\n --bs-link-hover-color: #0a58ca;\n --bs-link-hover-color-rgb: 10, 88, 202;\n --bs-code-color: #d63384;\n --bs-highlight-color: #212529;\n --bs-highlight-bg: #fff3cd;\n --bs-border-width: 1px;\n --bs-border-style: solid;\n --bs-border-color: #dee2e6;\n --bs-border-color-translucent: rgba(0, 0, 0, 0.175);\n --bs-border-radius: 0.375rem;\n --bs-border-radius-sm: 0.25rem;\n --bs-border-radius-lg: 0.5rem;\n --bs-border-radius-xl: 1rem;\n --bs-border-radius-xxl: 2rem;\n --bs-border-radius-2xl: var(--bs-border-radius-xxl);\n --bs-border-radius-pill: 50rem;\n --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);\n --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);\n --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);\n --bs-focus-ring-width: 0.25rem;\n --bs-focus-ring-opacity: 0.25;\n --bs-focus-ring-color: rgba(13, 110, 253, 0.25);\n --bs-form-valid-color: #198754;\n --bs-form-valid-border-color: #198754;\n --bs-form-invalid-color: #dc3545;\n --bs-form-invalid-border-color: #dc3545;\n}\n\n[data-bs-theme=dark] {\n color-scheme: dark;\n --bs-body-color: #dee2e6;\n --bs-body-color-rgb: 222, 226, 230;\n --bs-body-bg: #212529;\n --bs-body-bg-rgb: 33, 37, 41;\n --bs-emphasis-color: #fff;\n --bs-emphasis-color-rgb: 255, 255, 255;\n --bs-secondary-color: rgba(222, 226, 230, 0.75);\n --bs-secondary-color-rgb: 222, 226, 230;\n --bs-secondary-bg: #343a40;\n --bs-secondary-bg-rgb: 52, 58, 64;\n --bs-tertiary-color: rgba(222, 226, 230, 0.5);\n --bs-tertiary-color-rgb: 222, 226, 230;\n --bs-tertiary-bg: #2b3035;\n --bs-tertiary-bg-rgb: 43, 48, 53;\n --bs-primary-text-emphasis: #6ea8fe;\n --bs-secondary-text-emphasis: #a7acb1;\n --bs-success-text-emphasis: #75b798;\n --bs-info-text-emphasis: #6edff6;\n --bs-warning-text-emphasis: #ffda6a;\n --bs-danger-text-emphasis: #ea868f;\n --bs-light-text-emphasis: #f8f9fa;\n --bs-dark-text-emphasis: #dee2e6;\n --bs-primary-bg-subtle: #031633;\n --bs-secondary-bg-subtle: #161719;\n --bs-success-bg-subtle: #051b11;\n --bs-info-bg-subtle: #032830;\n --bs-warning-bg-subtle: #332701;\n --bs-danger-bg-subtle: #2c0b0e;\n --bs-light-bg-subtle: #343a40;\n --bs-dark-bg-subtle: #1a1d20;\n --bs-primary-border-subtle: #084298;\n --bs-secondary-border-subtle: #41464b;\n --bs-success-border-subtle: #0f5132;\n --bs-info-border-subtle: #087990;\n --bs-warning-border-subtle: #997404;\n --bs-danger-border-subtle: #842029;\n --bs-light-border-subtle: #495057;\n --bs-dark-border-subtle: #343a40;\n --bs-heading-color: inherit;\n --bs-link-color: #6ea8fe;\n --bs-link-hover-color: #8bb9fe;\n --bs-link-color-rgb: 110, 168, 254;\n --bs-link-hover-color-rgb: 139, 185, 254;\n --bs-code-color: #e685b5;\n --bs-highlight-color: #dee2e6;\n --bs-highlight-bg: #664d03;\n --bs-border-color: #495057;\n --bs-border-color-translucent: rgba(255, 255, 255, 0.15);\n --bs-form-valid-color: #75b798;\n --bs-form-valid-border-color: #75b798;\n --bs-form-invalid-color: #ea868f;\n --bs-form-invalid-border-color: #ea868f;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n :root {\n scroll-behavior: smooth;\n }\n}\n\nbody {\n margin: 0;\n font-family: var(--bs-body-font-family);\n font-size: var(--bs-body-font-size);\n font-weight: var(--bs-body-font-weight);\n line-height: var(--bs-body-line-height);\n color: var(--bs-body-color);\n text-align: var(--bs-body-text-align);\n background-color: var(--bs-body-bg);\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\nhr {\n margin: 1rem 0;\n color: inherit;\n border: 0;\n border-top: var(--bs-border-width) solid;\n opacity: 0.25;\n}\n\nh6, h5, h4, h3, h2, h1 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n color: var(--bs-heading-color);\n}\n\nh1 {\n font-size: calc(1.375rem + 1.5vw);\n}\n@media (min-width: 1200px) {\n h1 {\n font-size: 2.5rem;\n }\n}\n\nh2 {\n font-size: calc(1.325rem + 0.9vw);\n}\n@media (min-width: 1200px) {\n h2 {\n font-size: 2rem;\n }\n}\n\nh3 {\n font-size: calc(1.3rem + 0.6vw);\n}\n@media (min-width: 1200px) {\n h3 {\n font-size: 1.75rem;\n }\n}\n\nh4 {\n font-size: calc(1.275rem + 0.3vw);\n}\n@media (min-width: 1200px) {\n h4 {\n font-size: 1.5rem;\n }\n}\n\nh5 {\n font-size: 1.25rem;\n}\n\nh6 {\n font-size: 1rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title] {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n cursor: help;\n -webkit-text-decoration-skip-ink: none;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul {\n padding-right: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: 0.5rem;\n margin-right: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 0.875em;\n}\n\nmark {\n padding: 0.1875em;\n color: var(--bs-highlight-color);\n background-color: var(--bs-highlight-bg);\n}\n\nsub,\nsup {\n position: relative;\n font-size: 0.75em;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\na {\n color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));\n text-decoration: underline;\n}\na:hover {\n --bs-link-color-rgb: var(--bs-link-hover-color-rgb);\n}\n\na:not([href]):not([class]), a:not([href]):not([class]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: var(--bs-font-monospace);\n font-size: 1em;\n}\n\npre {\n display: block;\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n font-size: 0.875em;\n}\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\ncode {\n font-size: 0.875em;\n color: var(--bs-code-color);\n word-wrap: break-word;\n}\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.1875rem 0.375rem;\n font-size: 0.875em;\n color: var(--bs-body-bg);\n background-color: var(--bs-body-color);\n border-radius: 0.25rem;\n}\nkbd kbd {\n padding: 0;\n font-size: 1em;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n color: var(--bs-secondary-color);\n text-align: right;\n}\n\nth {\n text-align: inherit;\n text-align: -webkit-match-parent;\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\nlabel {\n display: inline-block;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\n[role=button] {\n cursor: pointer;\n}\n\nselect {\n word-wrap: normal;\n}\nselect:disabled {\n opacity: 1;\n}\n\n[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\nbutton,\n[type=button],\n[type=reset],\n[type=submit] {\n -webkit-appearance: button;\n}\nbutton:not(:disabled),\n[type=button]:not(:disabled),\n[type=reset]:not(:disabled),\n[type=submit]:not(:disabled) {\n cursor: pointer;\n}\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ntextarea {\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n float: right;\n width: 100%;\n padding: 0;\n margin-bottom: 0.5rem;\n font-size: calc(1.275rem + 0.3vw);\n line-height: inherit;\n}\n@media (min-width: 1200px) {\n legend {\n font-size: 1.5rem;\n }\n}\nlegend + * {\n clear: right;\n}\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n[type=search] {\n -webkit-appearance: textfield;\n outline-offset: -2px;\n}\n\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n::-webkit-file-upload-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\n::file-selector-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\niframe {\n border: 0;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[hidden] {\n display: none !important;\n}\n/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */","// stylelint-disable scss/dimension-no-non-numeric-values\n\n// SCSS RFS mixin\n//\n// Automated responsive values for font sizes, paddings, margins and much more\n//\n// Licensed under MIT (https://github.com/twbs/rfs/blob/main/LICENSE)\n\n// Configuration\n\n// Base value\n$rfs-base-value: 1.25rem !default;\n$rfs-unit: rem !default;\n\n@if $rfs-unit != rem and $rfs-unit != px {\n @error \"`#{$rfs-unit}` is not a valid unit for $rfs-unit. Use `px` or `rem`.\";\n}\n\n// Breakpoint at where values start decreasing if screen width is smaller\n$rfs-breakpoint: 1200px !default;\n$rfs-breakpoint-unit: px !default;\n\n@if $rfs-breakpoint-unit != px and $rfs-breakpoint-unit != em and $rfs-breakpoint-unit != rem {\n @error \"`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.\";\n}\n\n// Resize values based on screen height and width\n$rfs-two-dimensional: false !default;\n\n// Factor of decrease\n$rfs-factor: 10 !default;\n\n@if type-of($rfs-factor) != number or $rfs-factor <= 1 {\n @error \"`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.\";\n}\n\n// Mode. Possibilities: \"min-media-query\", \"max-media-query\"\n$rfs-mode: min-media-query !default;\n\n// Generate enable or disable classes. Possibilities: false, \"enable\" or \"disable\"\n$rfs-class: false !default;\n\n// 1 rem = $rfs-rem-value px\n$rfs-rem-value: 16 !default;\n\n// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14\n$rfs-safari-iframe-resize-bug-fix: false !default;\n\n// Disable RFS by setting $enable-rfs to false\n$enable-rfs: true !default;\n\n// Cache $rfs-base-value unit\n$rfs-base-value-unit: unit($rfs-base-value);\n\n@function divide($dividend, $divisor, $precision: 10) {\n $sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);\n $dividend: abs($dividend);\n $divisor: abs($divisor);\n @if $dividend == 0 {\n @return 0;\n }\n @if $divisor == 0 {\n @error \"Cannot divide by 0\";\n }\n $remainder: $dividend;\n $result: 0;\n $factor: 10;\n @while ($remainder > 0 and $precision >= 0) {\n $quotient: 0;\n @while ($remainder >= $divisor) {\n $remainder: $remainder - $divisor;\n $quotient: $quotient + 1;\n }\n $result: $result * 10 + $quotient;\n $factor: $factor * .1;\n $remainder: $remainder * 10;\n $precision: $precision - 1;\n @if ($precision < 0 and $remainder >= $divisor * 5) {\n $result: $result + 1;\n }\n }\n $result: $result * $factor * $sign;\n $dividend-unit: unit($dividend);\n $divisor-unit: unit($divisor);\n $unit-map: (\n \"px\": 1px,\n \"rem\": 1rem,\n \"em\": 1em,\n \"%\": 1%\n );\n @if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {\n $result: $result * map-get($unit-map, $dividend-unit);\n }\n @return $result;\n}\n\n// Remove px-unit from $rfs-base-value for calculations\n@if $rfs-base-value-unit == px {\n $rfs-base-value: divide($rfs-base-value, $rfs-base-value * 0 + 1);\n}\n@else if $rfs-base-value-unit == rem {\n $rfs-base-value: divide($rfs-base-value, divide($rfs-base-value * 0 + 1, $rfs-rem-value));\n}\n\n// Cache $rfs-breakpoint unit to prevent multiple calls\n$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);\n\n// Remove unit from $rfs-breakpoint for calculations\n@if $rfs-breakpoint-unit-cache == px {\n $rfs-breakpoint: divide($rfs-breakpoint, $rfs-breakpoint * 0 + 1);\n}\n@else if $rfs-breakpoint-unit-cache == rem or $rfs-breakpoint-unit-cache == \"em\" {\n $rfs-breakpoint: divide($rfs-breakpoint, divide($rfs-breakpoint * 0 + 1, $rfs-rem-value));\n}\n\n// Calculate the media query value\n$rfs-mq-value: if($rfs-breakpoint-unit == px, #{$rfs-breakpoint}px, #{divide($rfs-breakpoint, $rfs-rem-value)}#{$rfs-breakpoint-unit});\n$rfs-mq-property-width: if($rfs-mode == max-media-query, max-width, min-width);\n$rfs-mq-property-height: if($rfs-mode == max-media-query, max-height, min-height);\n\n// Internal mixin used to determine which media query needs to be used\n@mixin _rfs-media-query {\n @if $rfs-two-dimensional {\n @if $rfs-mode == max-media-query {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}), (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) and (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) {\n @content;\n }\n }\n}\n\n// Internal mixin that adds disable classes to the selector if needed.\n@mixin _rfs-rule {\n @if $rfs-class == disable and $rfs-mode == max-media-query {\n // Adding an extra class increases specificity, which prevents the media query to override the property\n &,\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @else if $rfs-class == enable and $rfs-mode == min-media-query {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Internal mixin that adds enable classes to the selector if needed.\n@mixin _rfs-media-query-rule {\n\n @if $rfs-class == enable {\n @if $rfs-mode == min-media-query {\n @content;\n }\n\n @include _rfs-media-query () {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n }\n }\n @else {\n @if $rfs-class == disable and $rfs-mode == min-media-query {\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @include _rfs-media-query () {\n @content;\n }\n }\n}\n\n// Helper function to get the formatted non-responsive value\n@function rfs-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n }\n @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n @if $unit == px {\n // Convert to rem if needed\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $value * 0 + $rfs-rem-value)}rem, $value);\n }\n @else if $unit == rem {\n // Convert to px if needed\n $val: $val + \" \" + if($rfs-unit == px, #{divide($value, $value * 0 + 1) * $rfs-rem-value}px, $value);\n } @else {\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n $val: $val + \" \" + $value;\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// Helper function to get the responsive value calculated by RFS\n@function rfs-fluid-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n } @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n @if not $unit or $unit != px and $unit != rem {\n $val: $val + \" \" + $value;\n } @else {\n // Remove unit from $value for calculations\n $value: divide($value, $value * 0 + if($unit == px, 1, divide(1, $rfs-rem-value)));\n\n // Only add the media query if the value is greater than the minimum value\n @if abs($value) <= $rfs-base-value or not $enable-rfs {\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $rfs-rem-value)}rem, #{$value}px);\n }\n @else {\n // Calculate the minimum value\n $value-min: $rfs-base-value + divide(abs($value) - $rfs-base-value, $rfs-factor);\n\n // Calculate difference between $value and the minimum value\n $value-diff: abs($value) - $value-min;\n\n // Base value formatting\n $min-width: if($rfs-unit == rem, #{divide($value-min, $rfs-rem-value)}rem, #{$value-min}px);\n\n // Use negative value if needed\n $min-width: if($value < 0, -$min-width, $min-width);\n\n // Use `vmin` if two-dimensional is enabled\n $variable-unit: if($rfs-two-dimensional, vmin, vw);\n\n // Calculate the variable width between 0 and $rfs-breakpoint\n $variable-width: #{divide($value-diff * 100, $rfs-breakpoint)}#{$variable-unit};\n\n // Return the calculated value\n $val: $val + \" calc(\" + $min-width + if($value < 0, \" - \", \" + \") + $variable-width + \")\";\n }\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// RFS mixin\n@mixin rfs($values, $property: font-size) {\n @if $values != null {\n $val: rfs-value($values);\n $fluid-val: rfs-fluid-value($values);\n\n // Do not print the media query if responsive & non-responsive values are the same\n @if $val == $fluid-val {\n #{$property}: $val;\n }\n @else {\n @include _rfs-rule () {\n #{$property}: if($rfs-mode == max-media-query, $val, $fluid-val);\n\n // Include safari iframe resize fix if needed\n min-width: if($rfs-safari-iframe-resize-bug-fix, (0 * 1vw), null);\n }\n\n @include _rfs-media-query-rule () {\n #{$property}: if($rfs-mode == max-media-query, $fluid-val, $val);\n }\n }\n }\n}\n\n// Shorthand helper mixins\n@mixin font-size($value) {\n @include rfs($value);\n}\n\n@mixin padding($value) {\n @include rfs($value, padding);\n}\n\n@mixin padding-top($value) {\n @include rfs($value, padding-top);\n}\n\n@mixin padding-right($value) {\n @include rfs($value, padding-right);\n}\n\n@mixin padding-bottom($value) {\n @include rfs($value, padding-bottom);\n}\n\n@mixin padding-left($value) {\n @include rfs($value, padding-left);\n}\n\n@mixin margin($value) {\n @include rfs($value, margin);\n}\n\n@mixin margin-top($value) {\n @include rfs($value, margin-top);\n}\n\n@mixin margin-right($value) {\n @include rfs($value, margin-right);\n}\n\n@mixin margin-bottom($value) {\n @include rfs($value, margin-bottom);\n}\n\n@mixin margin-left($value) {\n @include rfs($value, margin-left);\n}\n","// scss-docs-start color-mode-mixin\n@mixin color-mode($mode: light, $root: false) {\n @if $color-mode-type == \"media-query\" {\n @if $root == true {\n @media (prefers-color-scheme: $mode) {\n :root {\n @content;\n }\n }\n } @else {\n @media (prefers-color-scheme: $mode) {\n @content;\n }\n }\n } @else {\n [data-bs-theme=\"#{$mode}\"] {\n @content;\n }\n }\n}\n// scss-docs-end color-mode-mixin\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n @include font-size(var(--#{$prefix}root-font-size));\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$prefix}body-font-family);\n @include font-size(var(--#{$prefix}body-font-size));\n font-weight: var(--#{$prefix}body-font-weight);\n line-height: var(--#{$prefix}body-line-height);\n color: var(--#{$prefix}body-color);\n text-align: var(--#{$prefix}body-text-align);\n background-color: var(--#{$prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n border: 0;\n border-top: $hr-border-width solid $hr-border-color;\n opacity: $hr-opacity;\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `<h1>`-`<h6>` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: var(--#{$prefix}heading-color);\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `<p>`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 2. Add explicit cursor to indicate changed behavior.\n// 3. Prevent the text-decoration to be skipped.\n\nabbr[title] {\n text-decoration: underline dotted; // 1\n cursor: help; // 2\n text-decoration-skip-ink: none; // 3\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n color: var(--#{$prefix}highlight-color);\n background-color: var(--#{$prefix}highlight-bg);\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: rgba(var(--#{$prefix}link-color-rgb), var(--#{$prefix}link-opacity, 1));\n text-decoration: $link-decoration;\n\n &:hover {\n --#{$prefix}link-color-rgb: var(--#{$prefix}link-hover-color-rgb);\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: var(--#{$prefix}code-color);\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `<td>` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-`<button>` buttons\n//\n// Details at https://github.com/twbs/bootstrap/pull/30562\n[role=\"button\"] {\n cursor: pointer;\n}\n\nselect {\n // Remove the inheritance of word-wrap in Safari.\n // See https://github.com/twbs/bootstrap/issues/24990\n word-wrap: normal;\n\n // Undo the opacity change from Chrome\n &:disabled {\n opacity: 1;\n }\n}\n\n// Remove the dropdown arrow only from text type inputs built with datalists in Chrome.\n// See https://stackoverflow.com/a/54997118\n\n[list]:not([type=\"date\"]):not([type=\"datetime-local\"]):not([type=\"month\"]):not([type=\"week\"]):not([type=\"time\"])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\n// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`\n// controls in Android 4.\n// 2. Correct the inability to style clickable types in iOS and Safari.\n// 3. Opinionated: add \"hand\" cursor to non-disabled button elements.\n\nbutton,\n[type=\"button\"], // 1\n[type=\"reset\"],\n[type=\"submit\"] {\n -webkit-appearance: button; // 2\n\n @if $enable-button-pointers {\n &:not(:disabled) {\n cursor: pointer; // 3\n }\n }\n}\n\n// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\n// 1. Textareas should really only resize vertically so they don't break their (horizontal) containers.\n\ntextarea {\n resize: vertical; // 1\n}\n\n// 1. Browsers set a default `min-width: min-content;` on fieldsets,\n// unlike e.g. `<div>`s, which have `min-width: 0;` by default.\n// So we reset that to ensure fieldsets behave more like a standard block element.\n// See https://github.com/twbs/bootstrap/issues/12359\n// and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements\n// 2. Reset the default outline behavior of fieldsets so they don't affect page layout.\n\nfieldset {\n min-width: 0; // 1\n padding: 0; // 2\n margin: 0; // 2\n border: 0; // 2\n}\n\n// 1. By using `float: left`, the legend will behave like a block element.\n// This way the border of a fieldset wraps around the legend if present.\n// 2. Fix wrapping bug.\n// See https://github.com/twbs/bootstrap/issues/29712\n\nlegend {\n float: left; // 1\n width: 100%;\n padding: 0;\n margin-bottom: $legend-margin-bottom;\n @include font-size($legend-font-size);\n font-weight: $legend-font-weight;\n line-height: inherit;\n\n + * {\n clear: left; // 2\n }\n}\n\n// Fix height of inputs with a type of datetime-local, date, month, week, or time\n// See https://github.com/twbs/bootstrap/issues/18842\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n// 1. This overrides the extra rounded corners on search inputs in iOS so that our\n// `.form-control` class can properly style them. Note that this cannot simply\n// be added to `.form-control` as it's not specific enough. For details, see\n// https://github.com/twbs/bootstrap/issues/11586.\n// 2. Correct the outline style in Safari.\n\n[type=\"search\"] {\n -webkit-appearance: textfield; // 1\n outline-offset: -2px; // 2\n}\n\n// 1. A few input types should stay LTR\n// See https://rtlstyling.com/posts/rtl-styling#form-inputs\n// 2. RTL only output\n// See https://rtlcss.com/learn/usage-guide/control-directives/#raw\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n\n// Remove the inner padding in Chrome and Safari on macOS.\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n// Remove padding around color pickers in webkit browsers\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n\n// 1. Inherit font family and line height for file input buttons\n// 2. Correct the inability to style clickable types in iOS and Safari.\n\n::file-selector-button {\n font: inherit; // 1\n -webkit-appearance: button; // 2\n}\n\n// Correct element displays\n\noutput {\n display: inline-block;\n}\n\n// Remove border from iframe\n\niframe {\n border: 0;\n}\n\n// Summary\n//\n// 1. Add the correct display in all browsers\n\nsummary {\n display: list-item; // 1\n cursor: pointer;\n}\n\n\n// Progress\n//\n// Add the correct vertical alignment in Chrome, Firefox, and Opera.\n\nprogress {\n vertical-align: baseline;\n}\n\n\n// Hidden attribute\n//\n// Always hide an element with the `hidden` HTML attribute.\n\n[hidden] {\n display: none !important;\n}\n","// stylelint-disable property-disallowed-list\n// Single side border-radius\n\n// Helper function to replace negative values with 0\n@function valid-radius($radius) {\n $return: ();\n @each $value in $radius {\n @if type-of($value) == number {\n $return: append($return, max($value, 0));\n } @else {\n $return: append($return, $value);\n }\n }\n @return $return;\n}\n\n// scss-docs-start border-radius-mixins\n@mixin border-radius($radius: $border-radius, $fallback-border-radius: false) {\n @if $enable-rounded {\n border-radius: valid-radius($radius);\n }\n @else if $fallback-border-radius != false {\n border-radius: $fallback-border-radius;\n }\n}\n\n@mixin border-top-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n border-top-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-right-radius: valid-radius($radius);\n border-bottom-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-right-radius: valid-radius($radius);\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-top-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-left-radius: valid-radius($radius);\n }\n}\n\n@mixin border-top-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-top-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-end-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-right-radius: valid-radius($radius);\n }\n}\n\n@mixin border-bottom-start-radius($radius: $border-radius) {\n @if $enable-rounded {\n border-bottom-left-radius: valid-radius($radius);\n }\n}\n// scss-docs-end border-radius-mixins\n","/*!\n * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n:root,\n[data-bs-theme=light] {\n --bs-blue: #0d6efd;\n --bs-indigo: #6610f2;\n --bs-purple: #6f42c1;\n --bs-pink: #d63384;\n --bs-red: #dc3545;\n --bs-orange: #fd7e14;\n --bs-yellow: #ffc107;\n --bs-green: #198754;\n --bs-teal: #20c997;\n --bs-cyan: #0dcaf0;\n --bs-black: #000;\n --bs-white: #fff;\n --bs-gray: #6c757d;\n --bs-gray-dark: #343a40;\n --bs-gray-100: #f8f9fa;\n --bs-gray-200: #e9ecef;\n --bs-gray-300: #dee2e6;\n --bs-gray-400: #ced4da;\n --bs-gray-500: #adb5bd;\n --bs-gray-600: #6c757d;\n --bs-gray-700: #495057;\n --bs-gray-800: #343a40;\n --bs-gray-900: #212529;\n --bs-primary: #0d6efd;\n --bs-secondary: #6c757d;\n --bs-success: #198754;\n --bs-info: #0dcaf0;\n --bs-warning: #ffc107;\n --bs-danger: #dc3545;\n --bs-light: #f8f9fa;\n --bs-dark: #212529;\n --bs-primary-rgb: 13, 110, 253;\n --bs-secondary-rgb: 108, 117, 125;\n --bs-success-rgb: 25, 135, 84;\n --bs-info-rgb: 13, 202, 240;\n --bs-warning-rgb: 255, 193, 7;\n --bs-danger-rgb: 220, 53, 69;\n --bs-light-rgb: 248, 249, 250;\n --bs-dark-rgb: 33, 37, 41;\n --bs-primary-text-emphasis: #052c65;\n --bs-secondary-text-emphasis: #2b2f32;\n --bs-success-text-emphasis: #0a3622;\n --bs-info-text-emphasis: #055160;\n --bs-warning-text-emphasis: #664d03;\n --bs-danger-text-emphasis: #58151c;\n --bs-light-text-emphasis: #495057;\n --bs-dark-text-emphasis: #495057;\n --bs-primary-bg-subtle: #cfe2ff;\n --bs-secondary-bg-subtle: #e2e3e5;\n --bs-success-bg-subtle: #d1e7dd;\n --bs-info-bg-subtle: #cff4fc;\n --bs-warning-bg-subtle: #fff3cd;\n --bs-danger-bg-subtle: #f8d7da;\n --bs-light-bg-subtle: #fcfcfd;\n --bs-dark-bg-subtle: #ced4da;\n --bs-primary-border-subtle: #9ec5fe;\n --bs-secondary-border-subtle: #c4c8cb;\n --bs-success-border-subtle: #a3cfbb;\n --bs-info-border-subtle: #9eeaf9;\n --bs-warning-border-subtle: #ffe69c;\n --bs-danger-border-subtle: #f1aeb5;\n --bs-light-border-subtle: #e9ecef;\n --bs-dark-border-subtle: #adb5bd;\n --bs-white-rgb: 255, 255, 255;\n --bs-black-rgb: 0, 0, 0;\n --bs-font-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));\n --bs-body-font-family: var(--bs-font-sans-serif);\n --bs-body-font-size: 1rem;\n --bs-body-font-weight: 400;\n --bs-body-line-height: 1.5;\n --bs-body-color: #212529;\n --bs-body-color-rgb: 33, 37, 41;\n --bs-body-bg: #fff;\n --bs-body-bg-rgb: 255, 255, 255;\n --bs-emphasis-color: #000;\n --bs-emphasis-color-rgb: 0, 0, 0;\n --bs-secondary-color: rgba(33, 37, 41, 0.75);\n --bs-secondary-color-rgb: 33, 37, 41;\n --bs-secondary-bg: #e9ecef;\n --bs-secondary-bg-rgb: 233, 236, 239;\n --bs-tertiary-color: rgba(33, 37, 41, 0.5);\n --bs-tertiary-color-rgb: 33, 37, 41;\n --bs-tertiary-bg: #f8f9fa;\n --bs-tertiary-bg-rgb: 248, 249, 250;\n --bs-heading-color: inherit;\n --bs-link-color: #0d6efd;\n --bs-link-color-rgb: 13, 110, 253;\n --bs-link-decoration: underline;\n --bs-link-hover-color: #0a58ca;\n --bs-link-hover-color-rgb: 10, 88, 202;\n --bs-code-color: #d63384;\n --bs-highlight-color: #212529;\n --bs-highlight-bg: #fff3cd;\n --bs-border-width: 1px;\n --bs-border-style: solid;\n --bs-border-color: #dee2e6;\n --bs-border-color-translucent: rgba(0, 0, 0, 0.175);\n --bs-border-radius: 0.375rem;\n --bs-border-radius-sm: 0.25rem;\n --bs-border-radius-lg: 0.5rem;\n --bs-border-radius-xl: 1rem;\n --bs-border-radius-xxl: 2rem;\n --bs-border-radius-2xl: var(--bs-border-radius-xxl);\n --bs-border-radius-pill: 50rem;\n --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);\n --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);\n --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);\n --bs-focus-ring-width: 0.25rem;\n --bs-focus-ring-opacity: 0.25;\n --bs-focus-ring-color: rgba(13, 110, 253, 0.25);\n --bs-form-valid-color: #198754;\n --bs-form-valid-border-color: #198754;\n --bs-form-invalid-color: #dc3545;\n --bs-form-invalid-border-color: #dc3545;\n}\n\n[data-bs-theme=dark] {\n color-scheme: dark;\n --bs-body-color: #dee2e6;\n --bs-body-color-rgb: 222, 226, 230;\n --bs-body-bg: #212529;\n --bs-body-bg-rgb: 33, 37, 41;\n --bs-emphasis-color: #fff;\n --bs-emphasis-color-rgb: 255, 255, 255;\n --bs-secondary-color: rgba(222, 226, 230, 0.75);\n --bs-secondary-color-rgb: 222, 226, 230;\n --bs-secondary-bg: #343a40;\n --bs-secondary-bg-rgb: 52, 58, 64;\n --bs-tertiary-color: rgba(222, 226, 230, 0.5);\n --bs-tertiary-color-rgb: 222, 226, 230;\n --bs-tertiary-bg: #2b3035;\n --bs-tertiary-bg-rgb: 43, 48, 53;\n --bs-primary-text-emphasis: #6ea8fe;\n --bs-secondary-text-emphasis: #a7acb1;\n --bs-success-text-emphasis: #75b798;\n --bs-info-text-emphasis: #6edff6;\n --bs-warning-text-emphasis: #ffda6a;\n --bs-danger-text-emphasis: #ea868f;\n --bs-light-text-emphasis: #f8f9fa;\n --bs-dark-text-emphasis: #dee2e6;\n --bs-primary-bg-subtle: #031633;\n --bs-secondary-bg-subtle: #161719;\n --bs-success-bg-subtle: #051b11;\n --bs-info-bg-subtle: #032830;\n --bs-warning-bg-subtle: #332701;\n --bs-danger-bg-subtle: #2c0b0e;\n --bs-light-bg-subtle: #343a40;\n --bs-dark-bg-subtle: #1a1d20;\n --bs-primary-border-subtle: #084298;\n --bs-secondary-border-subtle: #41464b;\n --bs-success-border-subtle: #0f5132;\n --bs-info-border-subtle: #087990;\n --bs-warning-border-subtle: #997404;\n --bs-danger-border-subtle: #842029;\n --bs-light-border-subtle: #495057;\n --bs-dark-border-subtle: #343a40;\n --bs-heading-color: inherit;\n --bs-link-color: #6ea8fe;\n --bs-link-hover-color: #8bb9fe;\n --bs-link-color-rgb: 110, 168, 254;\n --bs-link-hover-color-rgb: 139, 185, 254;\n --bs-code-color: #e685b5;\n --bs-highlight-color: #dee2e6;\n --bs-highlight-bg: #664d03;\n --bs-border-color: #495057;\n --bs-border-color-translucent: rgba(255, 255, 255, 0.15);\n --bs-form-valid-color: #75b798;\n --bs-form-valid-border-color: #75b798;\n --bs-form-invalid-color: #ea868f;\n --bs-form-invalid-border-color: #ea868f;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n :root {\n scroll-behavior: smooth;\n }\n}\n\nbody {\n margin: 0;\n font-family: var(--bs-body-font-family);\n font-size: var(--bs-body-font-size);\n font-weight: var(--bs-body-font-weight);\n line-height: var(--bs-body-line-height);\n color: var(--bs-body-color);\n text-align: var(--bs-body-text-align);\n background-color: var(--bs-body-bg);\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\nhr {\n margin: 1rem 0;\n color: inherit;\n border: 0;\n border-top: var(--bs-border-width) solid;\n opacity: 0.25;\n}\n\nh6, h5, h4, h3, h2, h1 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n color: var(--bs-heading-color);\n}\n\nh1 {\n font-size: calc(1.375rem + 1.5vw);\n}\n@media (min-width: 1200px) {\n h1 {\n font-size: 2.5rem;\n }\n}\n\nh2 {\n font-size: calc(1.325rem + 0.9vw);\n}\n@media (min-width: 1200px) {\n h2 {\n font-size: 2rem;\n }\n}\n\nh3 {\n font-size: calc(1.3rem + 0.6vw);\n}\n@media (min-width: 1200px) {\n h3 {\n font-size: 1.75rem;\n }\n}\n\nh4 {\n font-size: calc(1.275rem + 0.3vw);\n}\n@media (min-width: 1200px) {\n h4 {\n font-size: 1.5rem;\n }\n}\n\nh5 {\n font-size: 1.25rem;\n}\n\nh6 {\n font-size: 1rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title] {\n text-decoration: underline dotted;\n cursor: help;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: 0.5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 0.875em;\n}\n\nmark {\n padding: 0.1875em;\n color: var(--bs-highlight-color);\n background-color: var(--bs-highlight-bg);\n}\n\nsub,\nsup {\n position: relative;\n font-size: 0.75em;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\na {\n color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));\n text-decoration: underline;\n}\na:hover {\n --bs-link-color-rgb: var(--bs-link-hover-color-rgb);\n}\n\na:not([href]):not([class]), a:not([href]):not([class]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: var(--bs-font-monospace);\n font-size: 1em;\n}\n\npre {\n display: block;\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n font-size: 0.875em;\n}\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\ncode {\n font-size: 0.875em;\n color: var(--bs-code-color);\n word-wrap: break-word;\n}\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.1875rem 0.375rem;\n font-size: 0.875em;\n color: var(--bs-body-bg);\n background-color: var(--bs-body-color);\n border-radius: 0.25rem;\n}\nkbd kbd {\n padding: 0;\n font-size: 1em;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n color: var(--bs-secondary-color);\n text-align: left;\n}\n\nth {\n text-align: inherit;\n text-align: -webkit-match-parent;\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\nlabel {\n display: inline-block;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\n[role=button] {\n cursor: pointer;\n}\n\nselect {\n word-wrap: normal;\n}\nselect:disabled {\n opacity: 1;\n}\n\n[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\nbutton,\n[type=button],\n[type=reset],\n[type=submit] {\n -webkit-appearance: button;\n}\nbutton:not(:disabled),\n[type=button]:not(:disabled),\n[type=reset]:not(:disabled),\n[type=submit]:not(:disabled) {\n cursor: pointer;\n}\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ntextarea {\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n float: left;\n width: 100%;\n padding: 0;\n margin-bottom: 0.5rem;\n font-size: calc(1.275rem + 0.3vw);\n line-height: inherit;\n}\n@media (min-width: 1200px) {\n legend {\n font-size: 1.5rem;\n }\n}\nlegend + * {\n clear: left;\n}\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n[type=search] {\n -webkit-appearance: textfield;\n outline-offset: -2px;\n}\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n::file-selector-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\niframe {\n border: 0;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[hidden] {\n display: none !important;\n}\n\n/*# sourceMappingURL=bootstrap-reboot.css.map */\n"]}
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css +5402 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css +6 −0
@@ -0,0 +1,6 @@
1 +/*!
2 + * Bootstrap Utilities v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}
6 +/*# sourceMappingURL=bootstrap-utilities.min.css.map */
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css +5393 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css +6 −0
@@ -0,0 +1,6 @@
1 +/*!
2 + * Bootstrap Utilities v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(-.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;right:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;left:0;right:0;z-index:1030}.fixed-bottom{position:fixed;left:0;bottom:0;right:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;left:0;bottom:0;right:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:right!important}.float-end{float:left!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{right:0!important}.start-50{right:50%!important}.start-100{right:100%!important}.end-0{left:0!important}.end-50{left:50%!important}.end-100{left:100%!important}.translate-middle{transform:translate(50%,-50%)!important}.translate-middle-x{transform:translateX(50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-left:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-right:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important}.mx-auto{margin-left:auto!important;margin-right:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-left:0!important}.me-1{margin-left:.25rem!important}.me-2{margin-left:.5rem!important}.me-3{margin-left:1rem!important}.me-4{margin-left:1.5rem!important}.me-5{margin-left:3rem!important}.me-auto{margin-left:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-right:0!important}.ms-1{margin-right:.25rem!important}.ms-2{margin-right:.5rem!important}.ms-3{margin-right:1rem!important}.ms-4{margin-right:1.5rem!important}.ms-5{margin-right:3rem!important}.ms-auto{margin-right:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-left:0!important;padding-right:0!important}.px-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-3{padding-left:1rem!important;padding-right:1rem!important}.px-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-5{padding-left:3rem!important;padding-right:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-left:0!important}.pe-1{padding-left:.25rem!important}.pe-2{padding-left:.5rem!important}.pe-3{padding-left:1rem!important}.pe-4{padding-left:1.5rem!important}.pe-5{padding-left:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-right:0!important}.ps-1{padding-right:.25rem!important}.ps-2{padding-right:.5rem!important}.ps-3{padding-right:1rem!important}.ps-4{padding-right:1.5rem!important}.ps-5{padding-right:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:right!important}.text-end{text-align:left!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-right-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-right-radius:0!important;border-top-left-radius:0!important}.rounded-top-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-right-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-right-radius:50%!important;border-top-left-radius:50%!important}.rounded-top-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-left-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-left-radius:0!important;border-bottom-left-radius:0!important}.rounded-end-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-left-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-left-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-end-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-left-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-left-radius:0!important;border-bottom-right-radius:0!important}.rounded-bottom-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-left-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-bottom-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-right-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-right-radius:0!important;border-top-right-radius:0!important}.rounded-start-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-right-radius:50%!important;border-top-right-radius:50%!important}.rounded-start-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:right!important}.float-sm-end{float:left!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-left:0!important;margin-right:0!important}.mx-sm-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-sm-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-sm-3{margin-left:1rem!important;margin-right:1rem!important}.mx-sm-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-sm-5{margin-left:3rem!important;margin-right:3rem!important}.mx-sm-auto{margin-left:auto!important;margin-right:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-left:0!important}.me-sm-1{margin-left:.25rem!important}.me-sm-2{margin-left:.5rem!important}.me-sm-3{margin-left:1rem!important}.me-sm-4{margin-left:1.5rem!important}.me-sm-5{margin-left:3rem!important}.me-sm-auto{margin-left:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-right:0!important}.ms-sm-1{margin-right:.25rem!important}.ms-sm-2{margin-right:.5rem!important}.ms-sm-3{margin-right:1rem!important}.ms-sm-4{margin-right:1.5rem!important}.ms-sm-5{margin-right:3rem!important}.ms-sm-auto{margin-right:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-left:0!important;padding-right:0!important}.px-sm-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-sm-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-sm-3{padding-left:1rem!important;padding-right:1rem!important}.px-sm-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-sm-5{padding-left:3rem!important;padding-right:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-left:0!important}.pe-sm-1{padding-left:.25rem!important}.pe-sm-2{padding-left:.5rem!important}.pe-sm-3{padding-left:1rem!important}.pe-sm-4{padding-left:1.5rem!important}.pe-sm-5{padding-left:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-right:0!important}.ps-sm-1{padding-right:.25rem!important}.ps-sm-2{padding-right:.5rem!important}.ps-sm-3{padding-right:1rem!important}.ps-sm-4{padding-right:1.5rem!important}.ps-sm-5{padding-right:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:right!important}.text-sm-end{text-align:left!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:right!important}.float-md-end{float:left!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-left:0!important;margin-right:0!important}.mx-md-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-md-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-md-3{margin-left:1rem!important;margin-right:1rem!important}.mx-md-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-md-5{margin-left:3rem!important;margin-right:3rem!important}.mx-md-auto{margin-left:auto!important;margin-right:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-left:0!important}.me-md-1{margin-left:.25rem!important}.me-md-2{margin-left:.5rem!important}.me-md-3{margin-left:1rem!important}.me-md-4{margin-left:1.5rem!important}.me-md-5{margin-left:3rem!important}.me-md-auto{margin-left:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-right:0!important}.ms-md-1{margin-right:.25rem!important}.ms-md-2{margin-right:.5rem!important}.ms-md-3{margin-right:1rem!important}.ms-md-4{margin-right:1.5rem!important}.ms-md-5{margin-right:3rem!important}.ms-md-auto{margin-right:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-left:0!important;padding-right:0!important}.px-md-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-md-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-md-3{padding-left:1rem!important;padding-right:1rem!important}.px-md-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-md-5{padding-left:3rem!important;padding-right:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-left:0!important}.pe-md-1{padding-left:.25rem!important}.pe-md-2{padding-left:.5rem!important}.pe-md-3{padding-left:1rem!important}.pe-md-4{padding-left:1.5rem!important}.pe-md-5{padding-left:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-right:0!important}.ps-md-1{padding-right:.25rem!important}.ps-md-2{padding-right:.5rem!important}.ps-md-3{padding-right:1rem!important}.ps-md-4{padding-right:1.5rem!important}.ps-md-5{padding-right:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:right!important}.text-md-end{text-align:left!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:right!important}.float-lg-end{float:left!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-left:0!important;margin-right:0!important}.mx-lg-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-lg-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-lg-3{margin-left:1rem!important;margin-right:1rem!important}.mx-lg-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-lg-5{margin-left:3rem!important;margin-right:3rem!important}.mx-lg-auto{margin-left:auto!important;margin-right:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-left:0!important}.me-lg-1{margin-left:.25rem!important}.me-lg-2{margin-left:.5rem!important}.me-lg-3{margin-left:1rem!important}.me-lg-4{margin-left:1.5rem!important}.me-lg-5{margin-left:3rem!important}.me-lg-auto{margin-left:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-right:0!important}.ms-lg-1{margin-right:.25rem!important}.ms-lg-2{margin-right:.5rem!important}.ms-lg-3{margin-right:1rem!important}.ms-lg-4{margin-right:1.5rem!important}.ms-lg-5{margin-right:3rem!important}.ms-lg-auto{margin-right:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-left:0!important;padding-right:0!important}.px-lg-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-lg-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-lg-3{padding-left:1rem!important;padding-right:1rem!important}.px-lg-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-lg-5{padding-left:3rem!important;padding-right:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-left:0!important}.pe-lg-1{padding-left:.25rem!important}.pe-lg-2{padding-left:.5rem!important}.pe-lg-3{padding-left:1rem!important}.pe-lg-4{padding-left:1.5rem!important}.pe-lg-5{padding-left:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-right:0!important}.ps-lg-1{padding-right:.25rem!important}.ps-lg-2{padding-right:.5rem!important}.ps-lg-3{padding-right:1rem!important}.ps-lg-4{padding-right:1.5rem!important}.ps-lg-5{padding-right:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:right!important}.text-lg-end{text-align:left!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:right!important}.float-xl-end{float:left!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-left:0!important;margin-right:0!important}.mx-xl-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-xl-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-xl-3{margin-left:1rem!important;margin-right:1rem!important}.mx-xl-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-xl-5{margin-left:3rem!important;margin-right:3rem!important}.mx-xl-auto{margin-left:auto!important;margin-right:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-left:0!important}.me-xl-1{margin-left:.25rem!important}.me-xl-2{margin-left:.5rem!important}.me-xl-3{margin-left:1rem!important}.me-xl-4{margin-left:1.5rem!important}.me-xl-5{margin-left:3rem!important}.me-xl-auto{margin-left:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-right:0!important}.ms-xl-1{margin-right:.25rem!important}.ms-xl-2{margin-right:.5rem!important}.ms-xl-3{margin-right:1rem!important}.ms-xl-4{margin-right:1.5rem!important}.ms-xl-5{margin-right:3rem!important}.ms-xl-auto{margin-right:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-left:0!important;padding-right:0!important}.px-xl-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-xl-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-xl-3{padding-left:1rem!important;padding-right:1rem!important}.px-xl-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-xl-5{padding-left:3rem!important;padding-right:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-left:0!important}.pe-xl-1{padding-left:.25rem!important}.pe-xl-2{padding-left:.5rem!important}.pe-xl-3{padding-left:1rem!important}.pe-xl-4{padding-left:1.5rem!important}.pe-xl-5{padding-left:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-right:0!important}.ps-xl-1{padding-right:.25rem!important}.ps-xl-2{padding-right:.5rem!important}.ps-xl-3{padding-right:1rem!important}.ps-xl-4{padding-right:1.5rem!important}.ps-xl-5{padding-right:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:right!important}.text-xl-end{text-align:left!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:right!important}.float-xxl-end{float:left!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-left:0!important;margin-right:0!important}.mx-xxl-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-xxl-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-xxl-3{margin-left:1rem!important;margin-right:1rem!important}.mx-xxl-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-xxl-5{margin-left:3rem!important;margin-right:3rem!important}.mx-xxl-auto{margin-left:auto!important;margin-right:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-left:0!important}.me-xxl-1{margin-left:.25rem!important}.me-xxl-2{margin-left:.5rem!important}.me-xxl-3{margin-left:1rem!important}.me-xxl-4{margin-left:1.5rem!important}.me-xxl-5{margin-left:3rem!important}.me-xxl-auto{margin-left:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-right:0!important}.ms-xxl-1{margin-right:.25rem!important}.ms-xxl-2{margin-right:.5rem!important}.ms-xxl-3{margin-right:1rem!important}.ms-xxl-4{margin-right:1.5rem!important}.ms-xxl-5{margin-right:3rem!important}.ms-xxl-auto{margin-right:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-left:0!important;padding-right:0!important}.px-xxl-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-xxl-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-xxl-3{padding-left:1rem!important;padding-right:1rem!important}.px-xxl-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-xxl-5{padding-left:3rem!important;padding-right:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-left:0!important}.pe-xxl-1{padding-left:.25rem!important}.pe-xxl-2{padding-left:.5rem!important}.pe-xxl-3{padding-left:1rem!important}.pe-xxl-4{padding-left:1.5rem!important}.pe-xxl-5{padding-left:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-right:0!important}.ps-xxl-1{padding-right:.25rem!important}.ps-xxl-2{padding-right:.5rem!important}.ps-xxl-3{padding-right:1rem!important}.ps-xxl-4{padding-right:1.5rem!important}.ps-xxl-5{padding-right:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:right!important}.text-xxl-end{text-align:left!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}}
6 +/*# sourceMappingURL=bootstrap-utilities.rtl.min.css.map */
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css +12057 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css +6 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css +12030 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css +6 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js +6314 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js +7 −0
@@ -0,0 +1,7 @@
1 +/*!
2 + * Bootstrap v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */
6 +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function M(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${M(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${M(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${M(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>n(t))).join(","):null},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="next",lt="prev",ct="left",ht="right",dt=`slide${ot}`,ut=`slid${ot}`,ft=`keydown${ot}`,pt=`mouseenter${ot}`,mt=`mouseleave${ot}`,gt=`dragstart${ot}`,_t=`load${ot}${rt}`,bt=`click${ot}${rt}`,vt="carousel",yt="active",wt=".active",At=".carousel-item",Et=wt+At,Tt={ArrowLeft:ht,ArrowRight:ct},Ct={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class xt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===vt&&this.cycle()}static get Default(){return Ct}static get DefaultType(){return Ot}static get NAME(){return"carousel"}next(){this._slide(at)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(lt)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,ut,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,ut,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?at:lt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,ft,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,pt,(()=>this.pause())),N.on(this._element,mt,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,gt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ct)),rightCallback:()=>this._slide(this._directionToOrder(ht)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Tt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(wt,this._indicatorsElement);e.classList.remove(yt),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(yt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===at,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(dt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(yt),i.classList.remove(yt,c,l),this._isSliding=!1,r(ut)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Et,this._element)}_getItems(){return z.find(At,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ct?lt:at:t===ct?at:lt}_orderToDirection(t){return p()?t===lt?ct:ht:t===lt?ht:ct}static jQueryInterface(t){return this.each((function(){const e=xt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,bt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(vt))return;t.preventDefault();const i=xt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,_t,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)xt.getOrCreateInstance(e)})),m(xt);const kt=".bs.collapse",Lt=`show${kt}`,St=`shown${kt}`,Dt=`hide${kt}`,$t=`hidden${kt}`,It=`click${kt}.data-api`,Nt="show",Pt="collapse",jt="collapsing",Mt=`:scope .${Pt} .${Pt}`,Ft='[data-bs-toggle="collapse"]',Ht={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Bt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Ft);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ht}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Bt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Lt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Pt),this._element.classList.add(jt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt,Nt),this._element.style[e]="",N.trigger(this._element,St)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,Dt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(jt),this._element.classList.remove(Pt,Nt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt),N.trigger(this._element,$t)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(Nt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ft);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(Mt,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Bt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,It,Ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Bt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Bt);var zt="top",Rt="bottom",qt="right",Vt="left",Kt="auto",Qt=[zt,Rt,qt,Vt],Xt="start",Yt="end",Ut="clippingParents",Gt="viewport",Jt="popper",Zt="reference",te=Qt.reduce((function(t,e){return t.concat([e+"-"+Xt,e+"-"+Yt])}),[]),ee=[].concat(Qt,[Kt]).reduce((function(t,e){return t.concat([e,e+"-"+Xt,e+"-"+Yt])}),[]),ie="beforeRead",ne="read",se="afterRead",oe="beforeMain",re="main",ae="afterMain",le="beforeWrite",ce="write",he="afterWrite",de=[ie,ne,se,oe,re,ae,le,ce,he];function ue(t){return t?(t.nodeName||"").toLowerCase():null}function fe(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function pe(t){return t instanceof fe(t).Element||t instanceof Element}function me(t){return t instanceof fe(t).HTMLElement||t instanceof HTMLElement}function ge(t){return"undefined"!=typeof ShadowRoot&&(t instanceof fe(t).ShadowRoot||t instanceof ShadowRoot)}const _e={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];me(s)&&ue(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});me(n)&&ue(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function be(t){return t.split("-")[0]}var ve=Math.max,ye=Math.min,we=Math.round;function Ae(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ee(){return!/^((?!chrome|android).)*safari/i.test(Ae())}function Te(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&me(t)&&(s=t.offsetWidth>0&&we(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&we(n.height)/t.offsetHeight||1);var r=(pe(t)?fe(t):window).visualViewport,a=!Ee()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Ce(t){var e=Te(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Oe(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ge(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function xe(t){return fe(t).getComputedStyle(t)}function ke(t){return["table","td","th"].indexOf(ue(t))>=0}function Le(t){return((pe(t)?t.ownerDocument:t.document)||window.document).documentElement}function Se(t){return"html"===ue(t)?t:t.assignedSlot||t.parentNode||(ge(t)?t.host:null)||Le(t)}function De(t){return me(t)&&"fixed"!==xe(t).position?t.offsetParent:null}function $e(t){for(var e=fe(t),i=De(t);i&&ke(i)&&"static"===xe(i).position;)i=De(i);return i&&("html"===ue(i)||"body"===ue(i)&&"static"===xe(i).position)?e:i||function(t){var e=/firefox/i.test(Ae());if(/Trident/i.test(Ae())&&me(t)&&"fixed"===xe(t).position)return null;var i=Se(t);for(ge(i)&&(i=i.host);me(i)&&["html","body"].indexOf(ue(i))<0;){var n=xe(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ie(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Ne(t,e,i){return ve(t,ye(e,i))}function Pe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function je(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const Me={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=be(i.placement),l=Ie(a),c=[Vt,qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Pe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:je(t,Qt))}(s.padding,i),d=Ce(o),u="y"===l?zt:Vt,f="y"===l?Rt:qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=$e(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Ne(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Oe(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Fe(t){return t.split("-")[1]}var He={top:"auto",right:"auto",bottom:"auto",left:"auto"};function We(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Vt,y=zt,w=window;if(c){var A=$e(i),E="clientHeight",T="clientWidth";A===fe(i)&&"static"!==xe(A=Le(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===zt||(s===Vt||s===qt)&&o===Yt)&&(y=Rt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Vt&&(s!==zt&&s!==Rt||o!==Yt)||(v=qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&He),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:we(i*s)/s||0,y:we(n*s)/s||0}}({x:f,y:m},fe(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Be={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:be(e.placement),variation:Fe(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,We(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,We(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ze={passive:!0};const Re={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=fe(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ze)})),a&&l.addEventListener("resize",i.update,ze),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ze)})),a&&l.removeEventListener("resize",i.update,ze)}},data:{}};var qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Ve(t){return t.replace(/left|right|bottom|top/g,(function(t){return qe[t]}))}var Ke={start:"end",end:"start"};function Qe(t){return t.replace(/start|end/g,(function(t){return Ke[t]}))}function Xe(t){var e=fe(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ye(t){return Te(Le(t)).left+Xe(t).scrollLeft}function Ue(t){var e=xe(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ge(t){return["html","body","#document"].indexOf(ue(t))>=0?t.ownerDocument.body:me(t)&&Ue(t)?t:Ge(Se(t))}function Je(t,e){var i;void 0===e&&(e=[]);var n=Ge(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=fe(n),r=s?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Je(Se(r)))}function Ze(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ti(t,e,i){return e===Gt?Ze(function(t,e){var i=fe(t),n=Le(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ee();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ye(t),y:l}}(t,i)):pe(e)?function(t,e){var i=Te(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ze(function(t){var e,i=Le(t),n=Xe(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ve(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ve(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ye(t),l=-n.scrollTop;return"rtl"===xe(s||i).direction&&(a+=ve(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Le(t)))}function ei(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?be(s):null,r=s?Fe(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case zt:e={x:a,y:i.y-n.height};break;case Rt:e={x:a,y:i.y+i.height};break;case qt:e={x:i.x+i.width,y:l};break;case Vt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ie(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Xt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Yt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ii(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Ut:a,c=i.rootBoundary,h=void 0===c?Gt:c,d=i.elementContext,u=void 0===d?Jt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Pe("number"!=typeof g?g:je(g,Qt)),b=u===Jt?Zt:Jt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Je(Se(t)),i=["absolute","fixed"].indexOf(xe(t).position)>=0&&me(t)?$e(t):t;return pe(i)?e.filter((function(t){return pe(t)&&Oe(t,i)&&"body"!==ue(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ti(t,i,n);return e.top=ve(s.top,e.top),e.right=ye(s.right,e.right),e.bottom=ye(s.bottom,e.bottom),e.left=ve(s.left,e.left),e}),ti(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(pe(y)?y:y.contextElement||Le(t.elements.popper),l,h,r),A=Te(t.elements.reference),E=ei({reference:A,element:v,strategy:"absolute",placement:s}),T=Ze(Object.assign({},v,E)),C=u===Jt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Jt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[qt,Rt].indexOf(t)>=0?1:-1,i=[zt,Rt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function ni(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ee:l,h=Fe(n),d=h?a?te:te.filter((function(t){return Fe(t)===h})):Qt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ii(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[be(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const si={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=be(g),b=l||(_!==g&&p?function(t){if(be(t)===Kt)return[];var e=Ve(t);return[Qe(t),e,Qe(e)]}(g):[Ve(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(be(i)===Kt?ni(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C<v.length;C++){var O=v[C],x=be(O),k=Fe(O)===Xt,L=[zt,Rt].indexOf(x)>=0,S=L?"width":"height",D=ii(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?qt:Vt:k?Rt:zt;y[S]>w[S]&&($=Ve($));var I=Ve($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==P(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function oi(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ri(t){return[zt,qt,Rt,Vt].some((function(e){return t[e]>=0}))}const ai={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ii(e,{elementContext:"reference"}),a=ii(e,{altBoundary:!0}),l=oi(r,n),c=oi(a,s,o),h=ri(l),d=ri(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},li={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ee.reduce((function(t,i){return t[i]=function(t,e,i){var n=be(t),s=[Vt,zt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Vt,qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ci={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ei({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},hi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ii(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=be(e.placement),b=Fe(e.placement),v=!b,y=Ie(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?zt:Vt,D="y"===y?Rt:qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],j=f?-T[$]/2:0,M=b===Xt?E[$]:T[$],F=b===Xt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?Ce(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Ne(0,E[$],W[$]),V=v?E[$]/2-j-q-z-O.mainAxis:M-q-z-O.mainAxis,K=v?-E[$]/2+j+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&$e(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Ne(f?ye(N,I+V-Y-X):N,I,f?ve(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?zt:Vt,tt="x"===y?Rt:qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[zt,Vt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Ne(t,e,i);return n>i?i:n}(at,et,lt):Ne(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function di(t,e,i){void 0===i&&(i=!1);var n,s,o=me(e),r=me(e)&&function(t){var e=t.getBoundingClientRect(),i=we(e.width)/t.offsetWidth||1,n=we(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=Le(e),l=Te(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==ue(e)||Ue(a))&&(c=(n=e)!==fe(n)&&me(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Xe(n)),me(e)?((h=Te(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ye(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function ui(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var fi={placement:"bottom",modifiers:[],strategy:"absolute"};function pi(){for(var t=arguments.length,e=new Array(t),i=0;i<t;i++)e[i]=arguments[i];return!e.some((function(t){return!(t&&"function"==typeof t.getBoundingClientRect)}))}function mi(t){void 0===t&&(t={});var e=t,i=e.defaultModifiers,n=void 0===i?[]:i,s=e.defaultOptions,o=void 0===s?fi:s;return function(t,e,i){void 0===i&&(i=o);var s,r,a={placement:"bottom",orderedModifiers:[],options:Object.assign({},fi,o),modifiersData:{},elements:{reference:t,popper:e},attributes:{},styles:{}},l=[],c=!1,h={state:a,setOptions:function(i){var s="function"==typeof i?i(a.options):i;d(),a.options=Object.assign({},o,a.options,s),a.scrollParents={reference:pe(t)?Je(t):t.contextElement?Je(t.contextElement):[],popper:Je(e)};var r,c,u=function(t){var e=ui(t);return de.reduce((function(t,i){return t.concat(e.filter((function(t){return t.phase===i})))}),[])}((r=[].concat(n,a.options.modifiers),c=r.reduce((function(t,e){var i=t[e.name];return t[e.name]=i?Object.assign({},i,e,{options:Object.assign({},i.options,e.options),data:Object.assign({},i.data,e.data)}):e,t}),{}),Object.keys(c).map((function(t){return c[t]}))));return a.orderedModifiers=u.filter((function(t){return t.enabled})),a.orderedModifiers.forEach((function(t){var e=t.name,i=t.options,n=void 0===i?{}:i,s=t.effect;if("function"==typeof s){var o=s({state:a,name:e,instance:h,options:n});l.push(o||function(){})}})),h.update()},forceUpdate:function(){if(!c){var t=a.elements,e=t.reference,i=t.popper;if(pi(e,i)){a.rects={reference:di(e,$e(i),"fixed"===a.options.strategy),popper:Ce(i)},a.reset=!1,a.placement=a.options.placement,a.orderedModifiers.forEach((function(t){return a.modifiersData[t.name]=Object.assign({},t.data)}));for(var n=0;n<a.orderedModifiers.length;n++)if(!0!==a.reset){var s=a.orderedModifiers[n],o=s.fn,r=s.options,l=void 0===r?{}:r,d=s.name;"function"==typeof o&&(a=o({state:a,options:l,name:d,instance:h})||a)}else a.reset=!1,n=-1}}},update:(s=function(){return new Promise((function(t){h.forceUpdate(),t(a)}))},function(){return r||(r=new Promise((function(t){Promise.resolve().then((function(){r=void 0,t(s())}))}))),r}),destroy:function(){d(),c=!0}};if(!pi(t,e))return h;function d(){l.forEach((function(t){return t()})),l=[]}return h.setOptions(i).then((function(t){!c&&i.onFirstUpdate&&i.onFirstUpdate(t)})),h}}var gi=mi(),_i=mi({defaultModifiers:[Re,ci,Be,_e]}),bi=mi({defaultModifiers:[Re,ci,Be,_e,li,si,hi,Me,ai]});const vi=Object.freeze(Object.defineProperty({__proto__:null,afterMain:ae,afterRead:se,afterWrite:he,applyStyles:_e,arrow:Me,auto:Kt,basePlacements:Qt,beforeMain:oe,beforeRead:ie,beforeWrite:le,bottom:Rt,clippingParents:Ut,computeStyles:Be,createPopper:bi,createPopperBase:gi,createPopperLite:_i,detectOverflow:ii,end:Yt,eventListeners:Re,flip:si,hide:ai,left:Vt,main:re,modifierPhases:de,offset:li,placements:ee,popper:Jt,popperGenerator:mi,popperOffsets:ci,preventOverflow:hi,read:ne,reference:Zt,right:qt,start:Xt,top:zt,variationPlacements:te,viewport:Gt,write:ce},Symbol.toStringTag,{value:"Module"})),yi="dropdown",wi=".bs.dropdown",Ai=".data-api",Ei="ArrowUp",Ti="ArrowDown",Ci=`hide${wi}`,Oi=`hidden${wi}`,xi=`show${wi}`,ki=`shown${wi}`,Li=`click${wi}${Ai}`,Si=`keydown${wi}${Ai}`,Di=`keyup${wi}${Ai}`,$i="show",Ii='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',Ni=`${Ii}.${$i}`,Pi=".dropdown-menu",ji=p()?"top-end":"top-start",Mi=p()?"top-start":"top-end",Fi=p()?"bottom-end":"bottom-start",Hi=p()?"bottom-start":"bottom-end",Wi=p()?"left-start":"right-start",Bi=p()?"right-start":"left-start",zi={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},Ri={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"};class qi extends W{constructor(t,e){super(t,e),this._popper=null,this._parent=this._element.parentNode,this._menu=z.next(this._element,Pi)[0]||z.prev(this._element,Pi)[0]||z.findOne(Pi,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return zi}static get DefaultType(){return Ri}static get NAME(){return yi}toggle(){return this._isShown()?this.hide():this.show()}show(){if(l(this._element)||this._isShown())return;const t={relatedTarget:this._element};if(!N.trigger(this._element,xi,t).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(".navbar-nav"))for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add($i),this._element.classList.add($i),N.trigger(this._element,ki,t)}}hide(){if(l(this._element)||!this._isShown())return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){if(!N.trigger(this._element,Ci,t).defaultPrevented){if("ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._popper&&this._popper.destroy(),this._menu.classList.remove($i),this._element.classList.remove($i),this._element.setAttribute("aria-expanded","false"),F.removeDataAttribute(this._menu,"popper"),N.trigger(this._element,Oi,t)}}_getConfig(t){if("object"==typeof(t=super._getConfig(t)).reference&&!o(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${yi.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(){if(void 0===vi)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let t=this._element;"parent"===this._config.reference?t=this._parent:o(this._config.reference)?t=r(this._config.reference):"object"==typeof this._config.reference&&(t=this._config.reference);const e=this._getPopperConfig();this._popper=bi(t,this._menu,e)}_isShown(){return this._menu.classList.contains($i)}_getPlacement(){const t=this._parent;if(t.classList.contains("dropend"))return Wi;if(t.classList.contains("dropstart"))return Bi;if(t.classList.contains("dropup-center"))return"top";if(t.classList.contains("dropdown-center"))return"bottom";const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?Mi:ji:e?Hi:Fi}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ti,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ni);for(const i of e){const e=qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ei,Ti].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ii)?this:z.prev(this,Ii)[0]||z.next(this,Ii)[0]||z.findOne(Ii,t.delegateTarget.parentNode),o=qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,Si,Ii,qi.dataApiKeydownHandler),N.on(document,Si,Pi,qi.dataApiKeydownHandler),N.on(document,Li,qi.clearMenus),N.on(document,Di,qi.clearMenus),N.on(document,Li,Ii,(function(t){t.preventDefault(),qi.getOrCreateInstance(this).toggle()})),m(qi);const Vi="backdrop",Ki="show",Qi=`mousedown.bs.${Vi}`,Xi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Yi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ui extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return Vi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Ki),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ki),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Qi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Qi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Gi=".bs.focustrap",Ji=`focusin${Gi}`,Zi=`keydown.tab${Gi}`,tn="backward",en={autofocus:!0,trapElement:null},nn={autofocus:"boolean",trapElement:"element"};class sn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return en}static get DefaultType(){return nn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Gi),N.on(document,Ji,(t=>this._handleFocusin(t))),N.on(document,Zi,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Gi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===tn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?tn:"forward")}}const on=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",rn=".sticky-top",an="padding-right",ln="margin-right";class cn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,an,(e=>e+t)),this._setElementAttributes(on,an,(e=>e+t)),this._setElementAttributes(rn,ln,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,an),this._resetElementAttributes(on,an),this._resetElementAttributes(rn,ln)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const hn=".bs.modal",dn=`hide${hn}`,un=`hidePrevented${hn}`,fn=`hidden${hn}`,pn=`show${hn}`,mn=`shown${hn}`,gn=`resize${hn}`,_n=`click.dismiss${hn}`,bn=`mousedown.dismiss${hn}`,vn=`keydown.dismiss${hn}`,yn=`click${hn}.data-api`,wn="modal-open",An="show",En="modal-static",Tn={backdrop:!0,focus:!0,keyboard:!0},Cn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class On extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new cn,this._addEventListeners()}static get Default(){return Tn}static get DefaultType(){return Cn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,pn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(wn),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,dn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(An),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,hn),N.off(this._dialog,hn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ui({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(An),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,mn,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,vn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,gn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,bn,(t=>{N.one(this._element,_n,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(wn),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,fn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,un).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(En)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(En),this._queueCallback((()=>{this._element.classList.remove(En),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=On.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,yn,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,pn,(t=>{t.defaultPrevented||N.one(e,fn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&On.getInstance(i).hide(),On.getOrCreateInstance(e).toggle(this)})),R(On),m(On);const xn=".bs.offcanvas",kn=".data-api",Ln=`load${xn}${kn}`,Sn="show",Dn="showing",$n="hiding",In=".offcanvas.show",Nn=`show${xn}`,Pn=`shown${xn}`,jn=`hide${xn}`,Mn=`hidePrevented${xn}`,Fn=`hidden${xn}`,Hn=`resize${xn}`,Wn=`click${xn}${kn}`,Bn=`keydown.dismiss${xn}`,zn={backdrop:!0,keyboard:!0,scroll:!1},Rn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class qn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return zn}static get DefaultType(){return Rn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,Nn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new cn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Dn),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Sn),this._element.classList.remove(Dn),N.trigger(this._element,Pn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,jn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add($n),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Sn,$n),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new cn).reset(),N.trigger(this._element,Fn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ui({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,Mn)}:null})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Bn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,Mn))}))}static jQueryInterface(t){return this.each((function(){const e=qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,Wn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Fn,(()=>{a(this)&&this.focus()}));const i=z.findOne(In);i&&i!==e&&qn.getInstance(i).hide(),qn.getOrCreateInstance(e).toggle(this)})),N.on(window,Ln,(()=>{for(const t of z.find(In))qn.getOrCreateInstance(t).show()})),N.on(window,Hn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&qn.getOrCreateInstance(t).hide()})),R(qn),m(qn);const Vn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Kn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Qn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Xn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Kn.has(i)||Boolean(Qn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Yn={allowList:Vn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"<div></div>"},Un={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Gn={entry:"(string|element|function|null)",selector:"(string|element)"};class Jn extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Yn}static get DefaultType(){return Un}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Gn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Xn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Zn=new Set(["sanitize","allowList","sanitizeFn"]),ts="fade",es="show",is=".modal",ns="hide.bs.modal",ss="hover",os="focus",rs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},as={allowList:Vn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',title:"",trigger:"hover focus"},ls={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cs extends W{constructor(t,e){if(void 0===vi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return as}static get DefaultType(){return ls}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(is),ns,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[os]=!1,this._activeTrigger[ss]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ts,es),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ts),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Jn({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ts)}_isShown(){return this.tip&&this.tip.classList.contains(es)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=rs[e.toUpperCase()];return bi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===ss?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ss?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?os:ss]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?os:ss]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(is),ns,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))Zn.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=cs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(cs);const hs={...cs.Default,content:"",offset:[0,8],placement:"right",template:'<div class="popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>',trigger:"click"},ds={...cs.DefaultType,content:"(null|string|element|function)"};class us extends cs{static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".bs.scrollspy",ps=`activate${fs}`,ms=`click${fs}`,gs=`load${fs}.data-api`,_s="active",bs="[href]",vs=".nav-link",ys=`${vs}, .nav-item > ${vs}, .list-group-item`,ws={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},As={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Es extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ws}static get DefaultType(){return As}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ms),N.on(this._config.target,ms,bs,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(bs,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(_s),this._activateParents(t),N.trigger(this._element,ps,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(_s);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,ys))t.classList.add(_s)}_clearActiveClass(t){t.classList.remove(_s);const e=z.find(`${bs}.${_s}`,t);for(const t of e)t.classList.remove(_s)}static jQueryInterface(t){return this.each((function(){const e=Es.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,gs,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))Es.getOrCreateInstance(t)})),m(Es);const Ts=".bs.tab",Cs=`hide${Ts}`,Os=`hidden${Ts}`,xs=`show${Ts}`,ks=`shown${Ts}`,Ls=`click${Ts}`,Ss=`keydown${Ts}`,Ds=`load${Ts}`,$s="ArrowLeft",Is="ArrowRight",Ns="ArrowUp",Ps="ArrowDown",js="Home",Ms="End",Fs="active",Hs="fade",Ws="show",Bs=".dropdown-toggle",zs=`:not(${Bs})`,Rs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',qs=`.nav-link${zs}, .list-group-item${zs}, [role="tab"]${zs}, ${Rs}`,Vs=`.${Fs}[data-bs-toggle="tab"], .${Fs}[data-bs-toggle="pill"], .${Fs}[data-bs-toggle="list"]`;class Ks extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ss,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Cs,{relatedTarget:t}):null;N.trigger(t,xs,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Fs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,ks,{relatedTarget:e})):t.classList.add(Ws)}),t,t.classList.contains(Hs)))}_deactivate(t,e){t&&(t.classList.remove(Fs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Os,{relatedTarget:e})):t.classList.remove(Ws)}),t,t.classList.contains(Hs)))}_keydown(t){if(![$s,Is,Ns,Ps,js,Ms].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([js,Ms].includes(t.key))i=e[t.key===js?0:e.length-1];else{const n=[Is,Ps].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Ks.getOrCreateInstance(i).show())}_getChildren(){return z.find(qs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(Bs,Fs),n(".dropdown-menu",Ws),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Fs)}_getInnerElement(t){return t.matches(qs)?t:z.findOne(qs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Ks.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ls,Rs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Ks.getOrCreateInstance(this).show()})),N.on(window,Ds,(()=>{for(const t of z.find(Vs))Ks.getOrCreateInstance(t)})),m(Ks);const Qs=".bs.toast",Xs=`mouseover${Qs}`,Ys=`mouseout${Qs}`,Us=`focusin${Qs}`,Gs=`focusout${Qs}`,Js=`hide${Qs}`,Zs=`hidden${Qs}`,to=`show${Qs}`,eo=`shown${Qs}`,io="hide",no="show",so="showing",oo={animation:"boolean",autohide:"boolean",delay:"number"},ro={animation:!0,autohide:!0,delay:5e3};class ao extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return ro}static get DefaultType(){return oo}static get NAME(){return"toast"}show(){N.trigger(this._element,to).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(io),d(this._element),this._element.classList.add(no,so),this._queueCallback((()=>{this._element.classList.remove(so),N.trigger(this._element,eo),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,Js).defaultPrevented||(this._element.classList.add(so),this._queueCallback((()=>{this._element.classList.add(io),this._element.classList.remove(so,no),N.trigger(this._element,Zs)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(no),super.dispose()}isShown(){return this._element.classList.contains(no)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Xs,(t=>this._onInteraction(t,!0))),N.on(this._element,Ys,(t=>this._onInteraction(t,!1))),N.on(this._element,Us,(t=>this._onInteraction(t,!0))),N.on(this._element,Gs,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ao.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(ao),m(ao),{Alert:Q,Button:Y,Carousel:xt,Collapse:Bt,Dropdown:qi,Modal:On,Offcanvas:qn,Popover:us,ScrollSpy:Es,Tab:Ks,Toast:ao,Tooltip:cs}}));
7 +//# sourceMappingURL=bootstrap.bundle.min.js.map
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js +4447 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js +7 −0
@@ -0,0 +1,7 @@
1 +/*!
2 + * Bootstrap v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */
6 +import*as Popper from"@popperjs/core";const elementMap=new Map,Data={set(e,t,n){elementMap.has(e)||elementMap.set(e,new Map);const i=elementMap.get(e);i.has(t)||0===i.size?i.set(t,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(i.keys())[0]}.`)},get:(e,t)=>elementMap.has(e)&&elementMap.get(e).get(t)||null,remove(e,t){if(!elementMap.has(e))return;const n=elementMap.get(e);n.delete(t),0===n.size&&elementMap.delete(e)}},MAX_UID=1e6,MILLISECONDS_MULTIPLIER=1e3,TRANSITION_END="transitionend",parseSelector=e=>(e&&window.CSS&&window.CSS.escape&&(e=e.replace(/#([^\s"#']+)/g,((e,t)=>`#${CSS.escape(t)}`))),e),toType=e=>null==e?`${e}`:Object.prototype.toString.call(e).match(/\s([a-z]+)/i)[1].toLowerCase(),getUID=e=>{do{e+=Math.floor(1e6*Math.random())}while(document.getElementById(e));return e},getTransitionDurationFromElement=e=>{if(!e)return 0;let{transitionDuration:t,transitionDelay:n}=window.getComputedStyle(e);const i=Number.parseFloat(t),s=Number.parseFloat(n);return i||s?(t=t.split(",")[0],n=n.split(",")[0],1e3*(Number.parseFloat(t)+Number.parseFloat(n))):0},triggerTransitionEnd=e=>{e.dispatchEvent(new Event(TRANSITION_END))},isElement=e=>!(!e||"object"!=typeof e)&&(void 0!==e.jquery&&(e=e[0]),void 0!==e.nodeType),getElement=e=>isElement(e)?e.jquery?e[0]:e:"string"==typeof e&&e.length>0?document.querySelector(parseSelector(e)):null,isVisible=e=>{if(!isElement(e)||0===e.getClientRects().length)return!1;const t="visible"===getComputedStyle(e).getPropertyValue("visibility"),n=e.closest("details:not([open])");if(!n)return t;if(n!==e){const t=e.closest("summary");if(t&&t.parentNode!==n)return!1;if(null===t)return!1}return t},isDisabled=e=>!e||e.nodeType!==Node.ELEMENT_NODE||!!e.classList.contains("disabled")||(void 0!==e.disabled?e.disabled:e.hasAttribute("disabled")&&"false"!==e.getAttribute("disabled")),findShadowRoot=e=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof e.getRootNode){const t=e.getRootNode();return t instanceof ShadowRoot?t:null}return e instanceof ShadowRoot?e:e.parentNode?findShadowRoot(e.parentNode):null},noop=()=>{},reflow=e=>{e.offsetHeight},getjQuery=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,DOMContentLoadedCallbacks=[],onDOMContentLoaded=e=>{"loading"===document.readyState?(DOMContentLoadedCallbacks.length||document.addEventListener("DOMContentLoaded",(()=>{for(const e of DOMContentLoadedCallbacks)e()})),DOMContentLoadedCallbacks.push(e)):e()},isRTL=()=>"rtl"===document.documentElement.dir,defineJQueryPlugin=e=>{onDOMContentLoaded((()=>{const t=getjQuery();if(t){const n=e.NAME,i=t.fn[n];t.fn[n]=e.jQueryInterface,t.fn[n].Constructor=e,t.fn[n].noConflict=()=>(t.fn[n]=i,e.jQueryInterface)}}))},execute=(e,t=[],n=e)=>"function"==typeof e?e(...t):n,executeAfterTransition=(e,t,n=!0)=>{if(!n)return void execute(e);const i=getTransitionDurationFromElement(t)+5;let s=!1;const o=({target:n})=>{n===t&&(s=!0,t.removeEventListener(TRANSITION_END,o),execute(e))};t.addEventListener(TRANSITION_END,o),setTimeout((()=>{s||triggerTransitionEnd(t)}),i)},getNextActiveElement=(e,t,n,i)=>{const s=e.length;let o=e.indexOf(t);return-1===o?!n&&i?e[s-1]:e[0]:(o+=n?1:-1,i&&(o=(o+s)%s),e[Math.max(0,Math.min(o,s-1))])},namespaceRegex=/[^.]*(?=\..*)\.|.*/,stripNameRegex=/\..*/,stripUidRegex=/::\d+$/,eventRegistry={};let uidEvent=1;const customEvents={mouseenter:"mouseover",mouseleave:"mouseout"},nativeEvents=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function makeEventUid(e,t){return t&&`${t}::${uidEvent++}`||e.uidEvent||uidEvent++}function getElementEvents(e){const t=makeEventUid(e);return e.uidEvent=t,eventRegistry[t]=eventRegistry[t]||{},eventRegistry[t]}function bootstrapHandler(e,t){return function n(i){return hydrateObj(i,{delegateTarget:e}),n.oneOff&&EventHandler.off(e,i.type,t),t.apply(e,[i])}}function bootstrapDelegationHandler(e,t,n){return function i(s){const o=e.querySelectorAll(t);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return hydrateObj(s,{delegateTarget:r}),i.oneOff&&EventHandler.off(e,s.type,t,n),n.apply(r,[s])}}function findHandler(e,t,n=null){return Object.values(e).find((e=>e.callable===t&&e.delegationSelector===n))}function normalizeParameters(e,t,n){const i="string"==typeof t,s=i?n:t||n;let o=getTypeEvent(e);return nativeEvents.has(o)||(o=e),[i,s,o]}function addHandler(e,t,n,i,s){if("string"!=typeof t||!e)return;let[o,r,a]=normalizeParameters(t,n,i);if(t in customEvents){const e=e=>function(t){if(!t.relatedTarget||t.relatedTarget!==t.delegateTarget&&!t.delegateTarget.contains(t.relatedTarget))return e.call(this,t)};r=e(r)}const l=getElementEvents(e),c=l[a]||(l[a]={}),_=findHandler(c,r,o?n:null);if(_)return void(_.oneOff=_.oneOff&&s);const E=makeEventUid(r,t.replace(namespaceRegex,"")),h=o?bootstrapDelegationHandler(e,n,r):bootstrapHandler(e,r);h.delegationSelector=o?n:null,h.callable=r,h.oneOff=s,h.uidEvent=E,c[E]=h,e.addEventListener(a,h,o)}function removeHandler(e,t,n,i,s){const o=findHandler(t[n],i,s);o&&(e.removeEventListener(n,o,Boolean(s)),delete t[n][o.uidEvent])}function removeNamespacedHandlers(e,t,n,i){const s=t[n]||{};for(const[o,r]of Object.entries(s))o.includes(i)&&removeHandler(e,t,n,r.callable,r.delegationSelector)}function getTypeEvent(e){return e=e.replace(stripNameRegex,""),customEvents[e]||e}const EventHandler={on(e,t,n,i){addHandler(e,t,n,i,!1)},one(e,t,n,i){addHandler(e,t,n,i,!0)},off(e,t,n,i){if("string"!=typeof t||!e)return;const[s,o,r]=normalizeParameters(t,n,i),a=r!==t,l=getElementEvents(e),c=l[r]||{},_=t.startsWith(".");if(void 0===o){if(_)for(const n of Object.keys(l))removeNamespacedHandlers(e,l,n,t.slice(1));for(const[n,i]of Object.entries(c)){const s=n.replace(stripUidRegex,"");a&&!t.includes(s)||removeHandler(e,l,r,i.callable,i.delegationSelector)}}else{if(!Object.keys(c).length)return;removeHandler(e,l,r,o,s?n:null)}},trigger(e,t,n){if("string"!=typeof t||!e)return null;const i=getjQuery();let s=null,o=!0,r=!0,a=!1;t!==getTypeEvent(t)&&i&&(s=i.Event(t,n),i(e).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=hydrateObj(new Event(t,{bubbles:o,cancelable:!0}),n);return a&&l.preventDefault(),r&&e.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function hydrateObj(e,t={}){for(const[n,i]of Object.entries(t))try{e[n]=i}catch(t){Object.defineProperty(e,n,{configurable:!0,get:()=>i})}return e}function normalizeData(e){if("true"===e)return!0;if("false"===e)return!1;if(e===Number(e).toString())return Number(e);if(""===e||"null"===e)return null;if("string"!=typeof e)return e;try{return JSON.parse(decodeURIComponent(e))}catch(t){return e}}function normalizeDataKey(e){return e.replace(/[A-Z]/g,(e=>`-${e.toLowerCase()}`))}const Manipulator={setDataAttribute(e,t,n){e.setAttribute(`data-bs-${normalizeDataKey(t)}`,n)},removeDataAttribute(e,t){e.removeAttribute(`data-bs-${normalizeDataKey(t)}`)},getDataAttributes(e){if(!e)return{};const t={},n=Object.keys(e.dataset).filter((e=>e.startsWith("bs")&&!e.startsWith("bsConfig")));for(const i of n){let n=i.replace(/^bs/,"");n=n.charAt(0).toLowerCase()+n.slice(1,n.length),t[n]=normalizeData(e.dataset[i])}return t},getDataAttribute:(e,t)=>normalizeData(e.getAttribute(`data-bs-${normalizeDataKey(t)}`))};class Config{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(e){return e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e}_mergeConfigObj(e,t){const n=isElement(t)?Manipulator.getDataAttribute(t,"config"):{};return{...this.constructor.Default,..."object"==typeof n?n:{},...isElement(t)?Manipulator.getDataAttributes(t):{},..."object"==typeof e?e:{}}}_typeCheckConfig(e,t=this.constructor.DefaultType){for(const[n,i]of Object.entries(t)){const t=e[n],s=isElement(t)?"element":toType(t);if(!new RegExp(i).test(s))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${s}" but expected type "${i}".`)}}}const VERSION="5.3.3";class BaseComponent extends Config{constructor(e,t){super(),(e=getElement(e))&&(this._element=e,this._config=this._getConfig(t),Data.set(this._element,this.constructor.DATA_KEY,this))}dispose(){Data.remove(this._element,this.constructor.DATA_KEY),EventHandler.off(this._element,this.constructor.EVENT_KEY);for(const e of Object.getOwnPropertyNames(this))this[e]=null}_queueCallback(e,t,n=!0){executeAfterTransition(e,t,n)}_getConfig(e){return e=this._mergeConfigObj(e,this._element),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}static getInstance(e){return Data.get(getElement(e),this.DATA_KEY)}static getOrCreateInstance(e,t={}){return this.getInstance(e)||new this(e,"object"==typeof t?t:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(e){return`${e}${this.EVENT_KEY}`}}const getSelector=e=>{let t=e.getAttribute("data-bs-target");if(!t||"#"===t){let n=e.getAttribute("href");if(!n||!n.includes("#")&&!n.startsWith("."))return null;n.includes("#")&&!n.startsWith("#")&&(n=`#${n.split("#")[1]}`),t=n&&"#"!==n?n.trim():null}return t?t.split(",").map((e=>parseSelector(e))).join(","):null},SelectorEngine={find:(e,t=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(t,e)),findOne:(e,t=document.documentElement)=>Element.prototype.querySelector.call(t,e),children:(e,t)=>[].concat(...e.children).filter((e=>e.matches(t))),parents(e,t){const n=[];let i=e.parentNode.closest(t);for(;i;)n.push(i),i=i.parentNode.closest(t);return n},prev(e,t){let n=e.previousElementSibling;for(;n;){if(n.matches(t))return[n];n=n.previousElementSibling}return[]},next(e,t){let n=e.nextElementSibling;for(;n;){if(n.matches(t))return[n];n=n.nextElementSibling}return[]},focusableChildren(e){const t=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((e=>`${e}:not([tabindex^="-"])`)).join(",");return this.find(t,e).filter((e=>!isDisabled(e)&&isVisible(e)))},getSelectorFromElement(e){const t=getSelector(e);return t&&SelectorEngine.findOne(t)?t:null},getElementFromSelector(e){const t=getSelector(e);return t?SelectorEngine.findOne(t):null},getMultipleElementsFromSelector(e){const t=getSelector(e);return t?SelectorEngine.find(t):[]}},enableDismissTrigger=(e,t="hide")=>{const n=`click.dismiss${e.EVENT_KEY}`,i=e.NAME;EventHandler.on(document,n,`[data-bs-dismiss="${i}"]`,(function(n){if(["A","AREA"].includes(this.tagName)&&n.preventDefault(),isDisabled(this))return;const s=SelectorEngine.getElementFromSelector(this)||this.closest(`.${i}`);e.getOrCreateInstance(s)[t]()}))},NAME$f="alert",DATA_KEY$a="bs.alert",EVENT_KEY$b=".bs.alert",EVENT_CLOSE="close.bs.alert",EVENT_CLOSED="closed.bs.alert",CLASS_NAME_FADE$5="fade",CLASS_NAME_SHOW$8="show";class Alert extends BaseComponent{static get NAME(){return NAME$f}close(){if(EventHandler.trigger(this._element,EVENT_CLOSE).defaultPrevented)return;this._element.classList.remove("show");const e=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,e)}_destroyElement(){this._element.remove(),EventHandler.trigger(this._element,EVENT_CLOSED),this.dispose()}static jQueryInterface(e){return this.each((function(){const t=Alert.getOrCreateInstance(this);if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e](this)}}))}}enableDismissTrigger(Alert,"close"),defineJQueryPlugin(Alert);const NAME$e="button",DATA_KEY$9="bs.button",EVENT_KEY$a=`.${DATA_KEY$9}`,DATA_API_KEY$6=".data-api",CLASS_NAME_ACTIVE$3="active",SELECTOR_DATA_TOGGLE$5='[data-bs-toggle="button"]',EVENT_CLICK_DATA_API$6=`click${EVENT_KEY$a}.data-api`;class Button extends BaseComponent{static get NAME(){return NAME$e}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(e){return this.each((function(){const t=Button.getOrCreateInstance(this);"toggle"===e&&t[e]()}))}}EventHandler.on(document,EVENT_CLICK_DATA_API$6,SELECTOR_DATA_TOGGLE$5,(e=>{e.preventDefault();const t=e.target.closest(SELECTOR_DATA_TOGGLE$5);Button.getOrCreateInstance(t).toggle()})),defineJQueryPlugin(Button);const NAME$d="swipe",EVENT_KEY$9=".bs.swipe",EVENT_TOUCHSTART="touchstart.bs.swipe",EVENT_TOUCHMOVE="touchmove.bs.swipe",EVENT_TOUCHEND="touchend.bs.swipe",EVENT_POINTERDOWN="pointerdown.bs.swipe",EVENT_POINTERUP="pointerup.bs.swipe",POINTER_TYPE_TOUCH="touch",POINTER_TYPE_PEN="pen",CLASS_NAME_POINTER_EVENT="pointer-event",SWIPE_THRESHOLD=40,Default$c={endCallback:null,leftCallback:null,rightCallback:null},DefaultType$c={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class Swipe extends Config{constructor(e,t){super(),this._element=e,e&&Swipe.isSupported()&&(this._config=this._getConfig(t),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return Default$c}static get DefaultType(){return DefaultType$c}static get NAME(){return NAME$d}dispose(){EventHandler.off(this._element,".bs.swipe")}_start(e){this._supportPointerEvents?this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX):this._deltaX=e.touches[0].clientX}_end(e){this._eventIsPointerPenTouch(e)&&(this._deltaX=e.clientX-this._deltaX),this._handleSwipe(),execute(this._config.endCallback)}_move(e){this._deltaX=e.touches&&e.touches.length>1?0:e.touches[0].clientX-this._deltaX}_handleSwipe(){const e=Math.abs(this._deltaX);if(e<=40)return;const t=e/this._deltaX;this._deltaX=0,t&&execute(t>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(EventHandler.on(this._element,EVENT_POINTERDOWN,(e=>this._start(e))),EventHandler.on(this._element,EVENT_POINTERUP,(e=>this._end(e))),this._element.classList.add("pointer-event")):(EventHandler.on(this._element,EVENT_TOUCHSTART,(e=>this._start(e))),EventHandler.on(this._element,EVENT_TOUCHMOVE,(e=>this._move(e))),EventHandler.on(this._element,EVENT_TOUCHEND,(e=>this._end(e))))}_eventIsPointerPenTouch(e){return this._supportPointerEvents&&("pen"===e.pointerType||"touch"===e.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const NAME$c="carousel",DATA_KEY$8="bs.carousel",EVENT_KEY$8=`.${DATA_KEY$8}`,DATA_API_KEY$5=".data-api",ARROW_LEFT_KEY$1="ArrowLeft",ARROW_RIGHT_KEY$1="ArrowRight",TOUCHEVENT_COMPAT_WAIT=500,ORDER_NEXT="next",ORDER_PREV="prev",DIRECTION_LEFT="left",DIRECTION_RIGHT="right",EVENT_SLIDE=`slide${EVENT_KEY$8}`,EVENT_SLID=`slid${EVENT_KEY$8}`,EVENT_KEYDOWN$1=`keydown${EVENT_KEY$8}`,EVENT_MOUSEENTER$1=`mouseenter${EVENT_KEY$8}`,EVENT_MOUSELEAVE$1=`mouseleave${EVENT_KEY$8}`,EVENT_DRAG_START=`dragstart${EVENT_KEY$8}`,EVENT_LOAD_DATA_API$3=`load${EVENT_KEY$8}.data-api`,EVENT_CLICK_DATA_API$5=`click${EVENT_KEY$8}.data-api`,CLASS_NAME_CAROUSEL="carousel",CLASS_NAME_ACTIVE$2="active",CLASS_NAME_SLIDE="slide",CLASS_NAME_END="carousel-item-end",CLASS_NAME_START="carousel-item-start",CLASS_NAME_NEXT="carousel-item-next",CLASS_NAME_PREV="carousel-item-prev",SELECTOR_ACTIVE=".active",SELECTOR_ITEM=".carousel-item",SELECTOR_ACTIVE_ITEM=".active.carousel-item",SELECTOR_ITEM_IMG=".carousel-item img",SELECTOR_INDICATORS=".carousel-indicators",SELECTOR_DATA_SLIDE="[data-bs-slide], [data-bs-slide-to]",SELECTOR_DATA_RIDE='[data-bs-ride="carousel"]',KEY_TO_DIRECTION={ArrowLeft:"right",ArrowRight:"left"},Default$b={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},DefaultType$b={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class Carousel extends BaseComponent{constructor(e,t){super(e,t),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=SelectorEngine.findOne(SELECTOR_INDICATORS,this._element),this._addEventListeners(),"carousel"===this._config.ride&&this.cycle()}static get Default(){return Default$b}static get DefaultType(){return DefaultType$b}static get NAME(){return NAME$c}next(){this._slide("next")}nextWhenVisible(){!document.hidden&&isVisible(this._element)&&this.next()}prev(){this._slide("prev")}pause(){this._isSliding&&triggerTransitionEnd(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?EventHandler.one(this._element,EVENT_SLID,(()=>this.cycle())):this.cycle())}to(e){const t=this._getItems();if(e>t.length-1||e<0)return;if(this._isSliding)return void EventHandler.one(this._element,EVENT_SLID,(()=>this.to(e)));const n=this._getItemIndex(this._getActive());if(n===e)return;const i=e>n?"next":"prev";this._slide(i,t[e])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(e){return e.defaultInterval=e.interval,e}_addEventListeners(){this._config.keyboard&&EventHandler.on(this._element,EVENT_KEYDOWN$1,(e=>this._keydown(e))),"hover"===this._config.pause&&(EventHandler.on(this._element,EVENT_MOUSEENTER$1,(()=>this.pause())),EventHandler.on(this._element,EVENT_MOUSELEAVE$1,(()=>this._maybeEnableCycle()))),this._config.touch&&Swipe.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const e of SelectorEngine.find(SELECTOR_ITEM_IMG,this._element))EventHandler.on(e,EVENT_DRAG_START,(e=>e.preventDefault()));const e={leftCallback:()=>this._slide(this._directionToOrder("left")),rightCallback:()=>this._slide(this._directionToOrder("right")),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new Swipe(this._element,e)}_keydown(e){if(/input|textarea/i.test(e.target.tagName))return;const t=KEY_TO_DIRECTION[e.key];t&&(e.preventDefault(),this._slide(this._directionToOrder(t)))}_getItemIndex(e){return this._getItems().indexOf(e)}_setActiveIndicatorElement(e){if(!this._indicatorsElement)return;const t=SelectorEngine.findOne(".active",this._indicatorsElement);t.classList.remove("active"),t.removeAttribute("aria-current");const n=SelectorEngine.findOne(`[data-bs-slide-to="${e}"]`,this._indicatorsElement);n&&(n.classList.add("active"),n.setAttribute("aria-current","true"))}_updateInterval(){const e=this._activeElement||this._getActive();if(!e)return;const t=Number.parseInt(e.getAttribute("data-bs-interval"),10);this._config.interval=t||this._config.defaultInterval}_slide(e,t=null){if(this._isSliding)return;const n=this._getActive(),i="next"===e,s=t||getNextActiveElement(this._getItems(),n,i,this._config.wrap);if(s===n)return;const o=this._getItemIndex(s),r=t=>EventHandler.trigger(this._element,t,{relatedTarget:s,direction:this._orderToDirection(e),from:this._getItemIndex(n),to:o});if(r(EVENT_SLIDE).defaultPrevented)return;if(!n||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=i?CLASS_NAME_START:CLASS_NAME_END,c=i?CLASS_NAME_NEXT:CLASS_NAME_PREV;s.classList.add(c),reflow(s),n.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add("active"),n.classList.remove("active",c,l),this._isSliding=!1,r(EVENT_SLID)}),n,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return SelectorEngine.findOne(SELECTOR_ACTIVE_ITEM,this._element)}_getItems(){return SelectorEngine.find(SELECTOR_ITEM,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(e){return isRTL()?"left"===e?"prev":"next":"left"===e?"next":"prev"}_orderToDirection(e){return isRTL()?"prev"===e?"left":"right":"prev"===e?"right":"left"}static jQueryInterface(e){return this.each((function(){const t=Carousel.getOrCreateInstance(this,e);if("number"!=typeof e){if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e]()}}else t.to(e)}))}}EventHandler.on(document,EVENT_CLICK_DATA_API$5,SELECTOR_DATA_SLIDE,(function(e){const t=SelectorEngine.getElementFromSelector(this);if(!t||!t.classList.contains("carousel"))return;e.preventDefault();const n=Carousel.getOrCreateInstance(t),i=this.getAttribute("data-bs-slide-to");return i?(n.to(i),void n._maybeEnableCycle()):"next"===Manipulator.getDataAttribute(this,"slide")?(n.next(),void n._maybeEnableCycle()):(n.prev(),void n._maybeEnableCycle())})),EventHandler.on(window,EVENT_LOAD_DATA_API$3,(()=>{const e=SelectorEngine.find(SELECTOR_DATA_RIDE);for(const t of e)Carousel.getOrCreateInstance(t)})),defineJQueryPlugin(Carousel);const NAME$b="collapse",DATA_KEY$7="bs.collapse",EVENT_KEY$7=`.${DATA_KEY$7}`,DATA_API_KEY$4=".data-api",EVENT_SHOW$6=`show${EVENT_KEY$7}`,EVENT_SHOWN$6=`shown${EVENT_KEY$7}`,EVENT_HIDE$6=`hide${EVENT_KEY$7}`,EVENT_HIDDEN$6=`hidden${EVENT_KEY$7}`,EVENT_CLICK_DATA_API$4=`click${EVENT_KEY$7}.data-api`,CLASS_NAME_SHOW$7="show",CLASS_NAME_COLLAPSE="collapse",CLASS_NAME_COLLAPSING="collapsing",CLASS_NAME_COLLAPSED="collapsed",CLASS_NAME_DEEPER_CHILDREN=":scope .collapse .collapse",CLASS_NAME_HORIZONTAL="collapse-horizontal",WIDTH="width",HEIGHT="height",SELECTOR_ACTIVES=".collapse.show, .collapse.collapsing",SELECTOR_DATA_TOGGLE$4='[data-bs-toggle="collapse"]',Default$a={parent:null,toggle:!0},DefaultType$a={parent:"(null|element)",toggle:"boolean"};class Collapse extends BaseComponent{constructor(e,t){super(e,t),this._isTransitioning=!1,this._triggerArray=[];const n=SelectorEngine.find(SELECTOR_DATA_TOGGLE$4);for(const e of n){const t=SelectorEngine.getSelectorFromElement(e),n=SelectorEngine.find(t).filter((e=>e===this._element));null!==t&&n.length&&this._triggerArray.push(e)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Default$a}static get DefaultType(){return DefaultType$a}static get NAME(){return NAME$b}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let e=[];if(this._config.parent&&(e=this._getFirstLevelChildren(SELECTOR_ACTIVES).filter((e=>e!==this._element)).map((e=>Collapse.getOrCreateInstance(e,{toggle:!1})))),e.length&&e[0]._isTransitioning)return;if(EventHandler.trigger(this._element,EVENT_SHOW$6).defaultPrevented)return;for(const t of e)t.hide();const t=this._getDimension();this._element.classList.remove("collapse"),this._element.classList.add("collapsing"),this._element.style[t]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const n=`scroll${t[0].toUpperCase()+t.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove("collapsing"),this._element.classList.add("collapse","show"),this._element.style[t]="",EventHandler.trigger(this._element,EVENT_SHOWN$6)}),this._element,!0),this._element.style[t]=`${this._element[n]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(EventHandler.trigger(this._element,EVENT_HIDE$6).defaultPrevented)return;const e=this._getDimension();this._element.style[e]=`${this._element.getBoundingClientRect()[e]}px`,reflow(this._element),this._element.classList.add("collapsing"),this._element.classList.remove("collapse","show");for(const e of this._triggerArray){const t=SelectorEngine.getElementFromSelector(e);t&&!this._isShown(t)&&this._addAriaAndCollapsedClass([e],!1)}this._isTransitioning=!0,this._element.style[e]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove("collapsing"),this._element.classList.add("collapse"),EventHandler.trigger(this._element,EVENT_HIDDEN$6)}),this._element,!0)}_isShown(e=this._element){return e.classList.contains("show")}_configAfterMerge(e){return e.toggle=Boolean(e.toggle),e.parent=getElement(e.parent),e}_getDimension(){return this._element.classList.contains("collapse-horizontal")?WIDTH:HEIGHT}_initializeChildren(){if(!this._config.parent)return;const e=this._getFirstLevelChildren(SELECTOR_DATA_TOGGLE$4);for(const t of e){const e=SelectorEngine.getElementFromSelector(t);e&&this._addAriaAndCollapsedClass([t],this._isShown(e))}}_getFirstLevelChildren(e){const t=SelectorEngine.find(CLASS_NAME_DEEPER_CHILDREN,this._config.parent);return SelectorEngine.find(e,this._config.parent).filter((e=>!t.includes(e)))}_addAriaAndCollapsedClass(e,t){if(e.length)for(const n of e)n.classList.toggle("collapsed",!t),n.setAttribute("aria-expanded",t)}static jQueryInterface(e){const t={};return"string"==typeof e&&/show|hide/.test(e)&&(t.toggle=!1),this.each((function(){const n=Collapse.getOrCreateInstance(this,t);if("string"==typeof e){if(void 0===n[e])throw new TypeError(`No method named "${e}"`);n[e]()}}))}}EventHandler.on(document,EVENT_CLICK_DATA_API$4,SELECTOR_DATA_TOGGLE$4,(function(e){("A"===e.target.tagName||e.delegateTarget&&"A"===e.delegateTarget.tagName)&&e.preventDefault();for(const e of SelectorEngine.getMultipleElementsFromSelector(this))Collapse.getOrCreateInstance(e,{toggle:!1}).toggle()})),defineJQueryPlugin(Collapse);const NAME$a="dropdown",DATA_KEY$6="bs.dropdown",EVENT_KEY$6=`.${DATA_KEY$6}`,DATA_API_KEY$3=".data-api",ESCAPE_KEY$2="Escape",TAB_KEY$1="Tab",ARROW_UP_KEY$1="ArrowUp",ARROW_DOWN_KEY$1="ArrowDown",RIGHT_MOUSE_BUTTON=2,EVENT_HIDE$5=`hide${EVENT_KEY$6}`,EVENT_HIDDEN$5=`hidden${EVENT_KEY$6}`,EVENT_SHOW$5=`show${EVENT_KEY$6}`,EVENT_SHOWN$5=`shown${EVENT_KEY$6}`,EVENT_CLICK_DATA_API$3=`click${EVENT_KEY$6}.data-api`,EVENT_KEYDOWN_DATA_API=`keydown${EVENT_KEY$6}.data-api`,EVENT_KEYUP_DATA_API=`keyup${EVENT_KEY$6}.data-api`,CLASS_NAME_SHOW$6="show",CLASS_NAME_DROPUP="dropup",CLASS_NAME_DROPEND="dropend",CLASS_NAME_DROPSTART="dropstart",CLASS_NAME_DROPUP_CENTER="dropup-center",CLASS_NAME_DROPDOWN_CENTER="dropdown-center",SELECTOR_DATA_TOGGLE$3='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',SELECTOR_DATA_TOGGLE_SHOWN=`${SELECTOR_DATA_TOGGLE$3}.show`,SELECTOR_MENU=".dropdown-menu",SELECTOR_NAVBAR=".navbar",SELECTOR_NAVBAR_NAV=".navbar-nav",SELECTOR_VISIBLE_ITEMS=".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",PLACEMENT_TOP=isRTL()?"top-end":"top-start",PLACEMENT_TOPEND=isRTL()?"top-start":"top-end",PLACEMENT_BOTTOM=isRTL()?"bottom-end":"bottom-start",PLACEMENT_BOTTOMEND=isRTL()?"bottom-start":"bottom-end",PLACEMENT_RIGHT=isRTL()?"left-start":"right-start",PLACEMENT_LEFT=isRTL()?"right-start":"left-start",PLACEMENT_TOPCENTER="top",PLACEMENT_BOTTOMCENTER="bottom",Default$9={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},DefaultType$9={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"};class Dropdown extends BaseComponent{constructor(e,t){super(e,t),this._popper=null,this._parent=this._element.parentNode,this._menu=SelectorEngine.next(this._element,SELECTOR_MENU)[0]||SelectorEngine.prev(this._element,SELECTOR_MENU)[0]||SelectorEngine.findOne(SELECTOR_MENU,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return Default$9}static get DefaultType(){return DefaultType$9}static get NAME(){return NAME$a}toggle(){return this._isShown()?this.hide():this.show()}show(){if(isDisabled(this._element)||this._isShown())return;const e={relatedTarget:this._element};if(!EventHandler.trigger(this._element,EVENT_SHOW$5,e).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(".navbar-nav"))for(const e of[].concat(...document.body.children))EventHandler.on(e,"mouseover",noop);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add("show"),this._element.classList.add("show"),EventHandler.trigger(this._element,EVENT_SHOWN$5,e)}}hide(){if(isDisabled(this._element)||!this._isShown())return;const e={relatedTarget:this._element};this._completeHide(e)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(e){if(!EventHandler.trigger(this._element,EVENT_HIDE$5,e).defaultPrevented){if("ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))EventHandler.off(e,"mouseover",noop);this._popper&&this._popper.destroy(),this._menu.classList.remove("show"),this._element.classList.remove("show"),this._element.setAttribute("aria-expanded","false"),Manipulator.removeDataAttribute(this._menu,"popper"),EventHandler.trigger(this._element,EVENT_HIDDEN$5,e)}}_getConfig(e){if("object"==typeof(e=super._getConfig(e)).reference&&!isElement(e.reference)&&"function"!=typeof e.reference.getBoundingClientRect)throw new TypeError(`${NAME$a.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return e}_createPopper(){if(void 0===Popper)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let e=this._element;"parent"===this._config.reference?e=this._parent:isElement(this._config.reference)?e=getElement(this._config.reference):"object"==typeof this._config.reference&&(e=this._config.reference);const t=this._getPopperConfig();this._popper=Popper.createPopper(e,this._menu,t)}_isShown(){return this._menu.classList.contains("show")}_getPlacement(){const e=this._parent;if(e.classList.contains("dropend"))return PLACEMENT_RIGHT;if(e.classList.contains("dropstart"))return PLACEMENT_LEFT;if(e.classList.contains("dropup-center"))return"top";if(e.classList.contains("dropdown-center"))return"bottom";const t="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return e.classList.contains("dropup")?t?PLACEMENT_TOPEND:PLACEMENT_TOP:t?PLACEMENT_BOTTOMEND:PLACEMENT_BOTTOM}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:e}=this._config;return"string"==typeof e?e.split(",").map((e=>Number.parseInt(e,10))):"function"==typeof e?t=>e(t,this._element):e}_getPopperConfig(){const e={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(Manipulator.setDataAttribute(this._menu,"popper","static"),e.modifiers=[{name:"applyStyles",enabled:!1}]),{...e,...execute(this._config.popperConfig,[e])}}_selectMenuItem({key:e,target:t}){const n=SelectorEngine.find(SELECTOR_VISIBLE_ITEMS,this._menu).filter((e=>isVisible(e)));n.length&&getNextActiveElement(n,t,e===ARROW_DOWN_KEY$1,!n.includes(t)).focus()}static jQueryInterface(e){return this.each((function(){const t=Dropdown.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError(`No method named "${e}"`);t[e]()}}))}static clearMenus(e){if(2===e.button||"keyup"===e.type&&"Tab"!==e.key)return;const t=SelectorEngine.find(SELECTOR_DATA_TOGGLE_SHOWN);for(const n of t){const t=Dropdown.getInstance(n);if(!t||!1===t._config.autoClose)continue;const i=e.composedPath(),s=i.includes(t._menu);if(i.includes(t._element)||"inside"===t._config.autoClose&&!s||"outside"===t._config.autoClose&&s)continue;if(t._menu.contains(e.target)&&("keyup"===e.type&&"Tab"===e.key||/input|select|option|textarea|form/i.test(e.target.tagName)))continue;const o={relatedTarget:t._element};"click"===e.type&&(o.clickEvent=e),t._completeHide(o)}}static dataApiKeydownHandler(e){const t=/input|textarea/i.test(e.target.tagName),n="Escape"===e.key,i=[ARROW_UP_KEY$1,ARROW_DOWN_KEY$1].includes(e.key);if(!i&&!n)return;if(t&&!n)return;e.preventDefault();const s=this.matches(SELECTOR_DATA_TOGGLE$3)?this:SelectorEngine.prev(this,SELECTOR_DATA_TOGGLE$3)[0]||SelectorEngine.next(this,SELECTOR_DATA_TOGGLE$3)[0]||SelectorEngine.findOne(SELECTOR_DATA_TOGGLE$3,e.delegateTarget.parentNode),o=Dropdown.getOrCreateInstance(s);if(i)return e.stopPropagation(),o.show(),void o._selectMenuItem(e);o._isShown()&&(e.stopPropagation(),o.hide(),s.focus())}}EventHandler.on(document,EVENT_KEYDOWN_DATA_API,SELECTOR_DATA_TOGGLE$3,Dropdown.dataApiKeydownHandler),EventHandler.on(document,EVENT_KEYDOWN_DATA_API,SELECTOR_MENU,Dropdown.dataApiKeydownHandler),EventHandler.on(document,EVENT_CLICK_DATA_API$3,Dropdown.clearMenus),EventHandler.on(document,EVENT_KEYUP_DATA_API,Dropdown.clearMenus),EventHandler.on(document,EVENT_CLICK_DATA_API$3,SELECTOR_DATA_TOGGLE$3,(function(e){e.preventDefault(),Dropdown.getOrCreateInstance(this).toggle()})),defineJQueryPlugin(Dropdown);const NAME$9="backdrop",CLASS_NAME_FADE$4="fade",CLASS_NAME_SHOW$5="show",EVENT_MOUSEDOWN=`mousedown.bs.${NAME$9}`,Default$8={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},DefaultType$8={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Backdrop extends Config{constructor(e){super(),this._config=this._getConfig(e),this._isAppended=!1,this._element=null}static get Default(){return Default$8}static get DefaultType(){return DefaultType$8}static get NAME(){return NAME$9}show(e){if(!this._config.isVisible)return void execute(e);this._append();const t=this._getElement();this._config.isAnimated&&reflow(t),t.classList.add("show"),this._emulateAnimation((()=>{execute(e)}))}hide(e){this._config.isVisible?(this._getElement().classList.remove("show"),this._emulateAnimation((()=>{this.dispose(),execute(e)}))):execute(e)}dispose(){this._isAppended&&(EventHandler.off(this._element,EVENT_MOUSEDOWN),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const e=document.createElement("div");e.className=this._config.className,this._config.isAnimated&&e.classList.add("fade"),this._element=e}return this._element}_configAfterMerge(e){return e.rootElement=getElement(e.rootElement),e}_append(){if(this._isAppended)return;const e=this._getElement();this._config.rootElement.append(e),EventHandler.on(e,EVENT_MOUSEDOWN,(()=>{execute(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(e){executeAfterTransition(e,this._getElement(),this._config.isAnimated)}}const NAME$8="focustrap",DATA_KEY$5="bs.focustrap",EVENT_KEY$5=`.${DATA_KEY$5}`,EVENT_FOCUSIN$2=`focusin${EVENT_KEY$5}`,EVENT_KEYDOWN_TAB=`keydown.tab${EVENT_KEY$5}`,TAB_KEY="Tab",TAB_NAV_FORWARD="forward",TAB_NAV_BACKWARD="backward",Default$7={autofocus:!0,trapElement:null},DefaultType$7={autofocus:"boolean",trapElement:"element"};class FocusTrap extends Config{constructor(e){super(),this._config=this._getConfig(e),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return Default$7}static get DefaultType(){return DefaultType$7}static get NAME(){return NAME$8}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),EventHandler.off(document,EVENT_KEY$5),EventHandler.on(document,EVENT_FOCUSIN$2,(e=>this._handleFocusin(e))),EventHandler.on(document,EVENT_KEYDOWN_TAB,(e=>this._handleKeydown(e))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,EventHandler.off(document,EVENT_KEY$5))}_handleFocusin(e){const{trapElement:t}=this._config;if(e.target===document||e.target===t||t.contains(e.target))return;const n=SelectorEngine.focusableChildren(t);0===n.length?t.focus():"backward"===this._lastTabNavDirection?n[n.length-1].focus():n[0].focus()}_handleKeydown(e){"Tab"===e.key&&(this._lastTabNavDirection=e.shiftKey?"backward":"forward")}}const SELECTOR_FIXED_CONTENT=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",SELECTOR_STICKY_CONTENT=".sticky-top",PROPERTY_PADDING="padding-right",PROPERTY_MARGIN="margin-right";class ScrollBarHelper{constructor(){this._element=document.body}getWidth(){const e=document.documentElement.clientWidth;return Math.abs(window.innerWidth-e)}hide(){const e=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,"padding-right",(t=>t+e)),this._setElementAttributes(SELECTOR_FIXED_CONTENT,"padding-right",(t=>t+e)),this._setElementAttributes(".sticky-top","margin-right",(t=>t-e))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,"padding-right"),this._resetElementAttributes(SELECTOR_FIXED_CONTENT,"padding-right"),this._resetElementAttributes(".sticky-top","margin-right")}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(e,t,n){const i=this.getWidth();this._applyManipulationCallback(e,(e=>{if(e!==this._element&&window.innerWidth>e.clientWidth+i)return;this._saveInitialAttribute(e,t);const s=window.getComputedStyle(e).getPropertyValue(t);e.style.setProperty(t,`${n(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(e,t){const n=e.style.getPropertyValue(t);n&&Manipulator.setDataAttribute(e,t,n)}_resetElementAttributes(e,t){this._applyManipulationCallback(e,(e=>{const n=Manipulator.getDataAttribute(e,t);null!==n?(Manipulator.removeDataAttribute(e,t),e.style.setProperty(t,n)):e.style.removeProperty(t)}))}_applyManipulationCallback(e,t){if(isElement(e))t(e);else for(const n of SelectorEngine.find(e,this._element))t(n)}}const NAME$7="modal",DATA_KEY$4="bs.modal",EVENT_KEY$4=".bs.modal",DATA_API_KEY$2=".data-api",ESCAPE_KEY$1="Escape",EVENT_HIDE$4="hide.bs.modal",EVENT_HIDE_PREVENTED$1="hidePrevented.bs.modal",EVENT_HIDDEN$4="hidden.bs.modal",EVENT_SHOW$4="show.bs.modal",EVENT_SHOWN$4="shown.bs.modal",EVENT_RESIZE$1="resize.bs.modal",EVENT_CLICK_DISMISS="click.dismiss.bs.modal",EVENT_MOUSEDOWN_DISMISS="mousedown.dismiss.bs.modal",EVENT_KEYDOWN_DISMISS$1="keydown.dismiss.bs.modal",EVENT_CLICK_DATA_API$2="click.bs.modal.data-api",CLASS_NAME_OPEN="modal-open",CLASS_NAME_FADE$3="fade",CLASS_NAME_SHOW$4="show",CLASS_NAME_STATIC="modal-static",OPEN_SELECTOR$1=".modal.show",SELECTOR_DIALOG=".modal-dialog",SELECTOR_MODAL_BODY=".modal-body",SELECTOR_DATA_TOGGLE$2='[data-bs-toggle="modal"]',Default$6={backdrop:!0,focus:!0,keyboard:!0},DefaultType$6={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Modal extends BaseComponent{constructor(e,t){super(e,t),this._dialog=SelectorEngine.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new ScrollBarHelper,this._addEventListeners()}static get Default(){return Default$6}static get DefaultType(){return DefaultType$6}static get NAME(){return NAME$7}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){this._isShown||this._isTransitioning||EventHandler.trigger(this._element,EVENT_SHOW$4,{relatedTarget:e}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add("modal-open"),this._adjustDialog(),this._backdrop.show((()=>this._showElement(e))))}hide(){this._isShown&&!this._isTransitioning&&(EventHandler.trigger(this._element,EVENT_HIDE$4).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove("show"),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){EventHandler.off(window,".bs.modal"),EventHandler.off(this._dialog,".bs.modal"),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Backdrop({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new FocusTrap({trapElement:this._element})}_showElement(e){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const t=SelectorEngine.findOne(".modal-body",this._dialog);t&&(t.scrollTop=0),reflow(this._element),this._element.classList.add("show"),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,EventHandler.trigger(this._element,EVENT_SHOWN$4,{relatedTarget:e})}),this._dialog,this._isAnimated())}_addEventListeners(){EventHandler.on(this._element,EVENT_KEYDOWN_DISMISS$1,(e=>{"Escape"===e.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),EventHandler.on(window,EVENT_RESIZE$1,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),EventHandler.on(this._element,EVENT_MOUSEDOWN_DISMISS,(e=>{EventHandler.one(this._element,EVENT_CLICK_DISMISS,(t=>{this._element===e.target&&this._element===t.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove("modal-open"),this._resetAdjustments(),this._scrollBar.reset(),EventHandler.trigger(this._element,EVENT_HIDDEN$4)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(EventHandler.trigger(this._element,EVENT_HIDE_PREVENTED$1).defaultPrevented)return;const e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._element.style.overflowY;"hidden"===t||this._element.classList.contains("modal-static")||(e||(this._element.style.overflowY="hidden"),this._element.classList.add("modal-static"),this._queueCallback((()=>{this._element.classList.remove("modal-static"),this._queueCallback((()=>{this._element.style.overflowY=t}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const e=this._element.scrollHeight>document.documentElement.clientHeight,t=this._scrollBar.getWidth(),n=t>0;if(n&&!e){const e=isRTL()?"paddingLeft":"paddingRight";this._element.style[e]=`${t}px`}if(!n&&e){const e=isRTL()?"paddingRight":"paddingLeft";this._element.style[e]=`${t}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(e,t){return this.each((function(){const n=Modal.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===n[e])throw new TypeError(`No method named "${e}"`);n[e](t)}}))}}EventHandler.on(document,EVENT_CLICK_DATA_API$2,SELECTOR_DATA_TOGGLE$2,(function(e){const t=SelectorEngine.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&e.preventDefault(),EventHandler.one(t,EVENT_SHOW$4,(e=>{e.defaultPrevented||EventHandler.one(t,EVENT_HIDDEN$4,(()=>{isVisible(this)&&this.focus()}))}));const n=SelectorEngine.findOne(".modal.show");n&&Modal.getInstance(n).hide(),Modal.getOrCreateInstance(t).toggle(this)})),enableDismissTrigger(Modal),defineJQueryPlugin(Modal);const NAME$6="offcanvas",DATA_KEY$3="bs.offcanvas",EVENT_KEY$3=`.${DATA_KEY$3}`,DATA_API_KEY$1=".data-api",EVENT_LOAD_DATA_API$2=`load${EVENT_KEY$3}.data-api`,ESCAPE_KEY="Escape",CLASS_NAME_SHOW$3="show",CLASS_NAME_SHOWING$1="showing",CLASS_NAME_HIDING="hiding",CLASS_NAME_BACKDROP="offcanvas-backdrop",OPEN_SELECTOR=".offcanvas.show",EVENT_SHOW$3=`show${EVENT_KEY$3}`,EVENT_SHOWN$3=`shown${EVENT_KEY$3}`,EVENT_HIDE$3=`hide${EVENT_KEY$3}`,EVENT_HIDE_PREVENTED=`hidePrevented${EVENT_KEY$3}`,EVENT_HIDDEN$3=`hidden${EVENT_KEY$3}`,EVENT_RESIZE=`resize${EVENT_KEY$3}`,EVENT_CLICK_DATA_API$1=`click${EVENT_KEY$3}.data-api`,EVENT_KEYDOWN_DISMISS=`keydown.dismiss${EVENT_KEY$3}`,SELECTOR_DATA_TOGGLE$1='[data-bs-toggle="offcanvas"]',Default$5={backdrop:!0,keyboard:!0,scroll:!1},DefaultType$5={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class Offcanvas extends BaseComponent{constructor(e,t){super(e,t),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return Default$5}static get DefaultType(){return DefaultType$5}static get NAME(){return NAME$6}toggle(e){return this._isShown?this.hide():this.show(e)}show(e){this._isShown||EventHandler.trigger(this._element,EVENT_SHOW$3,{relatedTarget:e}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new ScrollBarHelper).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add("showing"),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add("show"),this._element.classList.remove("showing"),EventHandler.trigger(this._element,EVENT_SHOWN$3,{relatedTarget:e})}),this._element,!0))}hide(){this._isShown&&(EventHandler.trigger(this._element,EVENT_HIDE$3).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add("hiding"),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove("show","hiding"),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new ScrollBarHelper).reset(),EventHandler.trigger(this._element,EVENT_HIDDEN$3)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const e=Boolean(this._config.backdrop);return new Backdrop({className:CLASS_NAME_BACKDROP,isVisible:e,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:e?()=>{"static"!==this._config.backdrop?this.hide():EventHandler.trigger(this._element,EVENT_HIDE_PREVENTED)}:null})}_initializeFocusTrap(){return new FocusTrap({trapElement:this._element})}_addEventListeners(){EventHandler.on(this._element,EVENT_KEYDOWN_DISMISS,(e=>{"Escape"===e.key&&(this._config.keyboard?this.hide():EventHandler.trigger(this._element,EVENT_HIDE_PREVENTED))}))}static jQueryInterface(e){return this.each((function(){const t=Offcanvas.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e](this)}}))}}EventHandler.on(document,EVENT_CLICK_DATA_API$1,SELECTOR_DATA_TOGGLE$1,(function(e){const t=SelectorEngine.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&e.preventDefault(),isDisabled(this))return;EventHandler.one(t,EVENT_HIDDEN$3,(()=>{isVisible(this)&&this.focus()}));const n=SelectorEngine.findOne(OPEN_SELECTOR);n&&n!==t&&Offcanvas.getInstance(n).hide(),Offcanvas.getOrCreateInstance(t).toggle(this)})),EventHandler.on(window,EVENT_LOAD_DATA_API$2,(()=>{for(const e of SelectorEngine.find(OPEN_SELECTOR))Offcanvas.getOrCreateInstance(e).show()})),EventHandler.on(window,EVENT_RESIZE,(()=>{for(const e of SelectorEngine.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(e).position&&Offcanvas.getOrCreateInstance(e).hide()})),enableDismissTrigger(Offcanvas),defineJQueryPlugin(Offcanvas);const ARIA_ATTRIBUTE_PATTERN=/^aria-[\w-]*$/i,DefaultAllowlist={"*":["class","dir","id","lang","role",ARIA_ATTRIBUTE_PATTERN],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},uriAttributes=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),SAFE_URL_PATTERN=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,allowedAttribute=(e,t)=>{const n=e.nodeName.toLowerCase();return t.includes(n)?!uriAttributes.has(n)||Boolean(SAFE_URL_PATTERN.test(e.nodeValue)):t.filter((e=>e instanceof RegExp)).some((e=>e.test(n)))};function sanitizeHtml(e,t,n){if(!e.length)return e;if(n&&"function"==typeof n)return n(e);const i=(new window.DOMParser).parseFromString(e,"text/html"),s=[].concat(...i.body.querySelectorAll("*"));for(const e of s){const n=e.nodeName.toLowerCase();if(!Object.keys(t).includes(n)){e.remove();continue}const i=[].concat(...e.attributes),s=[].concat(t["*"]||[],t[n]||[]);for(const t of i)allowedAttribute(t,s)||e.removeAttribute(t.nodeName)}return i.body.innerHTML}const NAME$5="TemplateFactory",Default$4={allowList:DefaultAllowlist,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"<div></div>"},DefaultType$4={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},DefaultContentType={entry:"(string|element|function|null)",selector:"(string|element)"};class TemplateFactory extends Config{constructor(e){super(),this._config=this._getConfig(e)}static get Default(){return Default$4}static get DefaultType(){return DefaultType$4}static get NAME(){return NAME$5}getContent(){return Object.values(this._config.content).map((e=>this._resolvePossibleFunction(e))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(e){return this._checkContent(e),this._config.content={...this._config.content,...e},this}toHtml(){const e=document.createElement("div");e.innerHTML=this._maybeSanitize(this._config.template);for(const[t,n]of Object.entries(this._config.content))this._setContent(e,n,t);const t=e.children[0],n=this._resolvePossibleFunction(this._config.extraClass);return n&&t.classList.add(...n.split(" ")),t}_typeCheckConfig(e){super._typeCheckConfig(e),this._checkContent(e.content)}_checkContent(e){for(const[t,n]of Object.entries(e))super._typeCheckConfig({selector:t,entry:n},DefaultContentType)}_setContent(e,t,n){const i=SelectorEngine.findOne(n,e);i&&((t=this._resolvePossibleFunction(t))?isElement(t)?this._putElementInTemplate(getElement(t),i):this._config.html?i.innerHTML=this._maybeSanitize(t):i.textContent=t:i.remove())}_maybeSanitize(e){return this._config.sanitize?sanitizeHtml(e,this._config.allowList,this._config.sanitizeFn):e}_resolvePossibleFunction(e){return execute(e,[this])}_putElementInTemplate(e,t){if(this._config.html)return t.innerHTML="",void t.append(e);t.textContent=e.textContent}}const NAME$4="tooltip",DISALLOWED_ATTRIBUTES=new Set(["sanitize","allowList","sanitizeFn"]),CLASS_NAME_FADE$2="fade",CLASS_NAME_MODAL="modal",CLASS_NAME_SHOW$2="show",SELECTOR_TOOLTIP_INNER=".tooltip-inner",SELECTOR_MODAL=".modal",EVENT_MODAL_HIDE="hide.bs.modal",TRIGGER_HOVER="hover",TRIGGER_FOCUS="focus",TRIGGER_CLICK="click",TRIGGER_MANUAL="manual",EVENT_HIDE$2="hide",EVENT_HIDDEN$2="hidden",EVENT_SHOW$2="show",EVENT_SHOWN$2="shown",EVENT_INSERTED="inserted",EVENT_CLICK$1="click",EVENT_FOCUSIN$1="focusin",EVENT_FOCUSOUT$1="focusout",EVENT_MOUSEENTER="mouseenter",EVENT_MOUSELEAVE="mouseleave",AttachmentMap={AUTO:"auto",TOP:"top",RIGHT:isRTL()?"left":"right",BOTTOM:"bottom",LEFT:isRTL()?"right":"left"},Default$3={allowList:DefaultAllowlist,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',title:"",trigger:"hover focus"},DefaultType$3={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Tooltip extends BaseComponent{constructor(e,t){if(void 0===Popper)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(e,t),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Default$3}static get DefaultType(){return DefaultType$3}static get NAME(){return NAME$4}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),EventHandler.off(this._element.closest(".modal"),"hide.bs.modal",this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const e=EventHandler.trigger(this._element,this.constructor.eventName("show")),t=(findShadowRoot(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(e.defaultPrevented||!t)return;this._disposePopper();const n=this._getTipElement();this._element.setAttribute("aria-describedby",n.getAttribute("id"));const{container:i}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(i.append(n),EventHandler.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(n),n.classList.add("show"),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))EventHandler.on(e,"mouseover",noop);this._queueCallback((()=>{EventHandler.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!EventHandler.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove("show"),"ontouchstart"in document.documentElement)for(const e of[].concat(...document.body.children))EventHandler.off(e,"mouseover",noop);this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),EventHandler.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(e){const t=this._getTemplateFactory(e).toHtml();if(!t)return null;t.classList.remove("fade","show"),t.classList.add(`bs-${this.constructor.NAME}-auto`);const n=getUID(this.constructor.NAME).toString();return t.setAttribute("id",n),this._isAnimated()&&t.classList.add("fade"),t}setContent(e){this._newContent=e,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(e){return this._templateFactory?this._templateFactory.changeContent(e):this._templateFactory=new TemplateFactory({...this._config,content:e,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(e){return this.constructor.getOrCreateInstance(e.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains("fade")}_isShown(){return this.tip&&this.tip.classList.contains("show")}_createPopper(e){const t=execute(this._config.placement,[this,e,this._element]),n=AttachmentMap[t.toUpperCase()];return Popper.createPopper(this._element,e,this._getPopperConfig(n))}_getOffset(){const{offset:e}=this._config;return"string"==typeof e?e.split(",").map((e=>Number.parseInt(e,10))):"function"==typeof e?t=>e(t,this._element):e}_resolvePossibleFunction(e){return execute(e,[this._element])}_getPopperConfig(e){const t={placement:e,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:e=>{this._getTipElement().setAttribute("data-popper-placement",e.state.placement)}}]};return{...t,...execute(this._config.popperConfig,[t])}}_setListeners(){const e=this._config.trigger.split(" ");for(const t of e)if("click"===t)EventHandler.on(this._element,this.constructor.eventName("click"),this._config.selector,(e=>{this._initializeOnDelegatedTarget(e).toggle()}));else if("manual"!==t){const e="hover"===t?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),n="hover"===t?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");EventHandler.on(this._element,e,this._config.selector,(e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusin"===e.type?"focus":"hover"]=!0,t._enter()})),EventHandler.on(this._element,n,this._config.selector,(e=>{const t=this._initializeOnDelegatedTarget(e);t._activeTrigger["focusout"===e.type?"focus":"hover"]=t._element.contains(e.relatedTarget),t._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},EventHandler.on(this._element.closest(".modal"),"hide.bs.modal",this._hideModalHandler)}_fixTitle(){const e=this._element.getAttribute("title");e&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",e),this._element.setAttribute("data-bs-original-title",e),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(e,t){clearTimeout(this._timeout),this._timeout=setTimeout(e,t)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(e){const t=Manipulator.getDataAttributes(this._element);for(const e of Object.keys(t))DISALLOWED_ATTRIBUTES.has(e)&&delete t[e];return e={...t,..."object"==typeof e&&e?e:{}},e=this._mergeConfigObj(e),e=this._configAfterMerge(e),this._typeCheckConfig(e),e}_configAfterMerge(e){return e.container=!1===e.container?document.body:getElement(e.container),"number"==typeof e.delay&&(e.delay={show:e.delay,hide:e.delay}),"number"==typeof e.title&&(e.title=e.title.toString()),"number"==typeof e.content&&(e.content=e.content.toString()),e}_getDelegateConfig(){const e={};for(const[t,n]of Object.entries(this._config))this.constructor.Default[t]!==n&&(e[t]=n);return e.selector=!1,e.trigger="manual",e}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(e){return this.each((function(){const t=Tooltip.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError(`No method named "${e}"`);t[e]()}}))}}defineJQueryPlugin(Tooltip);const NAME$3="popover",SELECTOR_TITLE=".popover-header",SELECTOR_CONTENT=".popover-body",Default$2={...Tooltip.Default,content:"",offset:[0,8],placement:"right",template:'<div class="popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>',trigger:"click"},DefaultType$2={...Tooltip.DefaultType,content:"(null|string|element|function)"};class Popover extends Tooltip{static get Default(){return Default$2}static get DefaultType(){return DefaultType$2}static get NAME(){return NAME$3}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{[SELECTOR_TITLE]:this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(e){return this.each((function(){const t=Popover.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError(`No method named "${e}"`);t[e]()}}))}}defineJQueryPlugin(Popover);const NAME$2="scrollspy",DATA_KEY$2="bs.scrollspy",EVENT_KEY$2=`.${DATA_KEY$2}`,DATA_API_KEY=".data-api",EVENT_ACTIVATE=`activate${EVENT_KEY$2}`,EVENT_CLICK=`click${EVENT_KEY$2}`,EVENT_LOAD_DATA_API$1=`load${EVENT_KEY$2}.data-api`,CLASS_NAME_DROPDOWN_ITEM="dropdown-item",CLASS_NAME_ACTIVE$1="active",SELECTOR_DATA_SPY='[data-bs-spy="scroll"]',SELECTOR_TARGET_LINKS="[href]",SELECTOR_NAV_LIST_GROUP=".nav, .list-group",SELECTOR_NAV_LINKS=".nav-link",SELECTOR_NAV_ITEMS=".nav-item",SELECTOR_LIST_ITEMS=".list-group-item",SELECTOR_LINK_ITEMS=".nav-link, .nav-item > .nav-link, .list-group-item",SELECTOR_DROPDOWN=".dropdown",SELECTOR_DROPDOWN_TOGGLE$1=".dropdown-toggle",Default$1={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},DefaultType$1={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class ScrollSpy extends BaseComponent{constructor(e,t){super(e,t),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return Default$1}static get DefaultType(){return DefaultType$1}static get NAME(){return NAME$2}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const e of this._observableSections.values())this._observer.observe(e)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(e){return e.target=getElement(e.target)||document.body,e.rootMargin=e.offset?`${e.offset}px 0px -30%`:e.rootMargin,"string"==typeof e.threshold&&(e.threshold=e.threshold.split(",").map((e=>Number.parseFloat(e)))),e}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(EventHandler.off(this._config.target,EVENT_CLICK),EventHandler.on(this._config.target,EVENT_CLICK,"[href]",(e=>{const t=this._observableSections.get(e.target.hash);if(t){e.preventDefault();const n=this._rootElement||window,i=t.offsetTop-this._element.offsetTop;if(n.scrollTo)return void n.scrollTo({top:i,behavior:"smooth"});n.scrollTop=i}})))}_getNewObserver(){const e={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((e=>this._observerCallback(e)),e)}_observerCallback(e){const t=e=>this._targetLinks.get(`#${e.target.id}`),n=e=>{this._previousScrollData.visibleEntryTop=e.target.offsetTop,this._process(t(e))},i=(this._rootElement||document.documentElement).scrollTop,s=i>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=i;for(const o of e){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(t(o));continue}const e=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&e){if(n(o),!i)return}else s||e||n(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const e=SelectorEngine.find("[href]",this._config.target);for(const t of e){if(!t.hash||isDisabled(t))continue;const e=SelectorEngine.findOne(decodeURI(t.hash),this._element);isVisible(e)&&(this._targetLinks.set(decodeURI(t.hash),t),this._observableSections.set(t.hash,e))}}_process(e){this._activeTarget!==e&&(this._clearActiveClass(this._config.target),this._activeTarget=e,e.classList.add("active"),this._activateParents(e),EventHandler.trigger(this._element,EVENT_ACTIVATE,{relatedTarget:e}))}_activateParents(e){if(e.classList.contains("dropdown-item"))SelectorEngine.findOne(".dropdown-toggle",e.closest(".dropdown")).classList.add("active");else for(const t of SelectorEngine.parents(e,".nav, .list-group"))for(const e of SelectorEngine.prev(t,SELECTOR_LINK_ITEMS))e.classList.add("active")}_clearActiveClass(e){e.classList.remove("active");const t=SelectorEngine.find("[href].active",e);for(const e of t)e.classList.remove("active")}static jQueryInterface(e){return this.each((function(){const t=ScrollSpy.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e]()}}))}}EventHandler.on(window,EVENT_LOAD_DATA_API$1,(()=>{for(const e of SelectorEngine.find(SELECTOR_DATA_SPY))ScrollSpy.getOrCreateInstance(e)})),defineJQueryPlugin(ScrollSpy);const NAME$1="tab",DATA_KEY$1="bs.tab",EVENT_KEY$1=".bs.tab",EVENT_HIDE$1="hide.bs.tab",EVENT_HIDDEN$1="hidden.bs.tab",EVENT_SHOW$1="show.bs.tab",EVENT_SHOWN$1="shown.bs.tab",EVENT_CLICK_DATA_API="click.bs.tab",EVENT_KEYDOWN="keydown.bs.tab",EVENT_LOAD_DATA_API="load.bs.tab",ARROW_LEFT_KEY="ArrowLeft",ARROW_RIGHT_KEY="ArrowRight",ARROW_UP_KEY="ArrowUp",ARROW_DOWN_KEY="ArrowDown",HOME_KEY="Home",END_KEY="End",CLASS_NAME_ACTIVE="active",CLASS_NAME_FADE$1="fade",CLASS_NAME_SHOW$1="show",CLASS_DROPDOWN="dropdown",SELECTOR_DROPDOWN_TOGGLE=".dropdown-toggle",SELECTOR_DROPDOWN_MENU=".dropdown-menu",NOT_SELECTOR_DROPDOWN_TOGGLE=":not(.dropdown-toggle)",SELECTOR_TAB_PANEL='.list-group, .nav, [role="tablist"]',SELECTOR_OUTER=".nav-item, .list-group-item",SELECTOR_INNER='.nav-link:not(.dropdown-toggle), .list-group-item:not(.dropdown-toggle), [role="tab"]:not(.dropdown-toggle)',SELECTOR_DATA_TOGGLE='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',SELECTOR_INNER_ELEM=`${SELECTOR_INNER}, ${SELECTOR_DATA_TOGGLE}`,SELECTOR_DATA_TOGGLE_ACTIVE='.active[data-bs-toggle="tab"], .active[data-bs-toggle="pill"], .active[data-bs-toggle="list"]';class Tab extends BaseComponent{constructor(e){super(e),this._parent=this._element.closest(SELECTOR_TAB_PANEL),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),EventHandler.on(this._element,EVENT_KEYDOWN,(e=>this._keydown(e))))}static get NAME(){return"tab"}show(){const e=this._element;if(this._elemIsActive(e))return;const t=this._getActiveElem(),n=t?EventHandler.trigger(t,EVENT_HIDE$1,{relatedTarget:e}):null;EventHandler.trigger(e,EVENT_SHOW$1,{relatedTarget:t}).defaultPrevented||n&&n.defaultPrevented||(this._deactivate(t,e),this._activate(e,t))}_activate(e,t){e&&(e.classList.add("active"),this._activate(SelectorEngine.getElementFromSelector(e)),this._queueCallback((()=>{"tab"===e.getAttribute("role")?(e.removeAttribute("tabindex"),e.setAttribute("aria-selected",!0),this._toggleDropDown(e,!0),EventHandler.trigger(e,EVENT_SHOWN$1,{relatedTarget:t})):e.classList.add("show")}),e,e.classList.contains("fade")))}_deactivate(e,t){e&&(e.classList.remove("active"),e.blur(),this._deactivate(SelectorEngine.getElementFromSelector(e)),this._queueCallback((()=>{"tab"===e.getAttribute("role")?(e.setAttribute("aria-selected",!1),e.setAttribute("tabindex","-1"),this._toggleDropDown(e,!1),EventHandler.trigger(e,EVENT_HIDDEN$1,{relatedTarget:t})):e.classList.remove("show")}),e,e.classList.contains("fade")))}_keydown(e){if(![ARROW_LEFT_KEY,ARROW_RIGHT_KEY,ARROW_UP_KEY,ARROW_DOWN_KEY,HOME_KEY,END_KEY].includes(e.key))return;e.stopPropagation(),e.preventDefault();const t=this._getChildren().filter((e=>!isDisabled(e)));let n;if([HOME_KEY,END_KEY].includes(e.key))n=t[e.key===HOME_KEY?0:t.length-1];else{const i=[ARROW_RIGHT_KEY,ARROW_DOWN_KEY].includes(e.key);n=getNextActiveElement(t,e.target,i,!0)}n&&(n.focus({preventScroll:!0}),Tab.getOrCreateInstance(n).show())}_getChildren(){return SelectorEngine.find(SELECTOR_INNER_ELEM,this._parent)}_getActiveElem(){return this._getChildren().find((e=>this._elemIsActive(e)))||null}_setInitialAttributes(e,t){this._setAttributeIfNotExists(e,"role","tablist");for(const e of t)this._setInitialAttributesOnChild(e)}_setInitialAttributesOnChild(e){e=this._getInnerElement(e);const t=this._elemIsActive(e),n=this._getOuterElement(e);e.setAttribute("aria-selected",t),n!==e&&this._setAttributeIfNotExists(n,"role","presentation"),t||e.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(e,"role","tab"),this._setInitialAttributesOnTargetPanel(e)}_setInitialAttributesOnTargetPanel(e){const t=SelectorEngine.getElementFromSelector(e);t&&(this._setAttributeIfNotExists(t,"role","tabpanel"),e.id&&this._setAttributeIfNotExists(t,"aria-labelledby",`${e.id}`))}_toggleDropDown(e,t){const n=this._getOuterElement(e);if(!n.classList.contains("dropdown"))return;const i=(e,i)=>{const s=SelectorEngine.findOne(e,n);s&&s.classList.toggle(i,t)};i(".dropdown-toggle","active"),i(".dropdown-menu","show"),n.setAttribute("aria-expanded",t)}_setAttributeIfNotExists(e,t,n){e.hasAttribute(t)||e.setAttribute(t,n)}_elemIsActive(e){return e.classList.contains("active")}_getInnerElement(e){return e.matches(SELECTOR_INNER_ELEM)?e:SelectorEngine.findOne(SELECTOR_INNER_ELEM,e)}_getOuterElement(e){return e.closest(SELECTOR_OUTER)||e}static jQueryInterface(e){return this.each((function(){const t=Tab.getOrCreateInstance(this);if("string"==typeof e){if(void 0===t[e]||e.startsWith("_")||"constructor"===e)throw new TypeError(`No method named "${e}"`);t[e]()}}))}}EventHandler.on(document,"click.bs.tab",SELECTOR_DATA_TOGGLE,(function(e){["A","AREA"].includes(this.tagName)&&e.preventDefault(),isDisabled(this)||Tab.getOrCreateInstance(this).show()})),EventHandler.on(window,"load.bs.tab",(()=>{for(const e of SelectorEngine.find(SELECTOR_DATA_TOGGLE_ACTIVE))Tab.getOrCreateInstance(e)})),defineJQueryPlugin(Tab);const NAME="toast",DATA_KEY="bs.toast",EVENT_KEY=`.${DATA_KEY}`,EVENT_MOUSEOVER=`mouseover${EVENT_KEY}`,EVENT_MOUSEOUT=`mouseout${EVENT_KEY}`,EVENT_FOCUSIN=`focusin${EVENT_KEY}`,EVENT_FOCUSOUT=`focusout${EVENT_KEY}`,EVENT_HIDE=`hide${EVENT_KEY}`,EVENT_HIDDEN=`hidden${EVENT_KEY}`,EVENT_SHOW=`show${EVENT_KEY}`,EVENT_SHOWN=`shown${EVENT_KEY}`,CLASS_NAME_FADE="fade",CLASS_NAME_HIDE="hide",CLASS_NAME_SHOW="show",CLASS_NAME_SHOWING="showing",DefaultType={animation:"boolean",autohide:"boolean",delay:"number"},Default={animation:!0,autohide:!0,delay:5e3};class Toast extends BaseComponent{constructor(e,t){super(e,t),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return Default}static get DefaultType(){return DefaultType}static get NAME(){return NAME}show(){EventHandler.trigger(this._element,EVENT_SHOW).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove("hide"),reflow(this._element),this._element.classList.add("show","showing"),this._queueCallback((()=>{this._element.classList.remove("showing"),EventHandler.trigger(this._element,EVENT_SHOWN),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(EventHandler.trigger(this._element,EVENT_HIDE).defaultPrevented||(this._element.classList.add("showing"),this._queueCallback((()=>{this._element.classList.add("hide"),this._element.classList.remove("showing","show"),EventHandler.trigger(this._element,EVENT_HIDDEN)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove("show"),super.dispose()}isShown(){return this._element.classList.contains("show")}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(e,t){switch(e.type){case"mouseover":case"mouseout":this._hasMouseInteraction=t;break;case"focusin":case"focusout":this._hasKeyboardInteraction=t}if(t)return void this._clearTimeout();const n=e.relatedTarget;this._element===n||this._element.contains(n)||this._maybeScheduleHide()}_setListeners(){EventHandler.on(this._element,EVENT_MOUSEOVER,(e=>this._onInteraction(e,!0))),EventHandler.on(this._element,EVENT_MOUSEOUT,(e=>this._onInteraction(e,!1))),EventHandler.on(this._element,EVENT_FOCUSIN,(e=>this._onInteraction(e,!0))),EventHandler.on(this._element,EVENT_FOCUSOUT,(e=>this._onInteraction(e,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(e){return this.each((function(){const t=Toast.getOrCreateInstance(this,e);if("string"==typeof e){if(void 0===t[e])throw new TypeError(`No method named "${e}"`);t[e](this)}}))}}enableDismissTrigger(Toast),defineJQueryPlugin(Toast);export{Alert,Button,Carousel,Collapse,Dropdown,Modal,Offcanvas,Popover,ScrollSpy,Tab,Toast,Tooltip};
7 +//# sourceMappingURL=bootstrap.esm.min.js.map
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js +4494 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js +7 −0
@@ -0,0 +1,7 @@
1 +/*!
2 + * Bootstrap v5.3.3 (https://getbootstrap.com/)
3 + * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
4 + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
5 + */
6 +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("@popperjs/core")):"function"==typeof define&&define.amd?define(["@popperjs/core"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e(t.Popper)}(this,(function(t){"use strict";function e(t){const e=Object.create(null,{[Symbol.toStringTag]:{value:"Module"}});if(t)for(const i in t)if("default"!==i){const s=Object.getOwnPropertyDescriptor(t,i);Object.defineProperty(e,i,s.get?s:{enumerable:!0,get:()=>t[i]})}return e.default=t,Object.freeze(e)}const i=e(t),s=new Map,n={set(t,e,i){s.has(t)||s.set(t,new Map);const n=s.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>s.has(t)&&s.get(t).get(e)||null,remove(t,e){if(!s.has(t))return;const i=s.get(t);i.delete(e),0===i.size&&s.delete(t)}},o="transitionend",r=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),a=t=>{t.dispatchEvent(new Event(o))},l=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),c=t=>l(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(r(t)):null,h=t=>{if(!l(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},d=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),u=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?u(t.parentNode):null},_=()=>{},g=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,m=[],p=()=>"rtl"===document.documentElement.dir,b=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,s=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=s,t.jQueryInterface)}},"loading"===document.readyState?(m.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of m)t()})),m.push(e)):e()},v=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,y=(t,e,i=!0)=>{if(!i)return void v(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const s=Number.parseFloat(e),n=Number.parseFloat(i);return s||n?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let n=!1;const r=({target:i})=>{i===e&&(n=!0,e.removeEventListener(o,r),v(t))};e.addEventListener(o,r),setTimeout((()=>{n||a(e)}),s)},w=(t,e,i,s)=>{const n=t.length;let o=t.indexOf(e);return-1===o?!i&&s?t[n-1]:t[0]:(o+=i?1:-1,s&&(o=(o+n)%n),t[Math.max(0,Math.min(o,n-1))])},A=/[^.]*(?=\..*)\.|.*/,E=/\..*/,C=/::\d+$/,T={};let k=1;const $={mouseenter:"mouseover",mouseleave:"mouseout"},S=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${k++}`||t.uidEvent||k++}function O(t){const e=L(t);return t.uidEvent=e,T[e]=T[e]||{},T[e]}function I(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function D(t,e,i){const s="string"==typeof e,n=s?i:e||i;let o=M(t);return S.has(o)||(o=t),[s,n,o]}function N(t,e,i,s,n){if("string"!=typeof e||!t)return;let[o,r,a]=D(e,i,s);if(e in $){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=O(t),c=l[a]||(l[a]={}),h=I(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&n);const d=L(r,e.replace(A,"")),u=o?function(t,e,i){return function s(n){const o=t.querySelectorAll(e);for(let{target:r}=n;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return F(n,{delegateTarget:r}),s.oneOff&&j.off(t,n.type,e,i),i.apply(r,[n])}}(t,i,r):function(t,e){return function i(s){return F(s,{delegateTarget:t}),i.oneOff&&j.off(t,s.type,e),e.apply(t,[s])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=n,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function P(t,e,i,s,n){const o=I(e[i],s,n);o&&(t.removeEventListener(i,o,Boolean(n)),delete e[i][o.uidEvent])}function x(t,e,i,s){const n=e[i]||{};for(const[o,r]of Object.entries(n))o.includes(s)&&P(t,e,i,r.callable,r.delegationSelector)}function M(t){return t=t.replace(E,""),$[t]||t}const j={on(t,e,i,s){N(t,e,i,s,!1)},one(t,e,i,s){N(t,e,i,s,!0)},off(t,e,i,s){if("string"!=typeof e||!t)return;const[n,o,r]=D(e,i,s),a=r!==e,l=O(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))x(t,l,i,e.slice(1));for(const[i,s]of Object.entries(c)){const n=i.replace(C,"");a&&!e.includes(n)||P(t,l,r,s.callable,s.delegationSelector)}}else{if(!Object.keys(c).length)return;P(t,l,r,o,n?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const s=f();let n=null,o=!0,r=!0,a=!1;e!==M(e)&&s&&(n=s.Event(e,i),s(t).trigger(n),o=!n.isPropagationStopped(),r=!n.isImmediatePropagationStopped(),a=n.isDefaultPrevented());const l=F(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&n&&n.preventDefault(),l}};function F(t,e={}){for(const[i,s]of Object.entries(e))try{t[i]=s}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>s})}return t}function z(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function H(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const B={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${H(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${H(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const s of i){let i=s.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=z(t.dataset[s])}return e},getDataAttribute:(t,e)=>z(t.getAttribute(`data-bs-${H(e)}`))};class q{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=l(e)?B.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...l(e)?B.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[s,n]of Object.entries(e)){const e=t[s],o=l(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(n).test(o))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${s}" provided type "${o}" but expected type "${n}".`)}var i}}class W extends q{constructor(t,e){super(),(t=c(t))&&(this._element=t,this._config=this._getConfig(e),n.set(this._element,this.constructor.DATA_KEY,this))}dispose(){n.remove(this._element,this.constructor.DATA_KEY),j.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){y(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return n.get(c(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const R=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>r(t))).join(","):null},K={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let s=t.parentNode.closest(e);for(;s;)i.push(s),s=s.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!d(t)&&h(t)))},getSelectorFromElement(t){const e=R(t);return e&&K.findOne(e)?e:null},getElementFromSelector(t){const e=R(t);return e?K.findOne(e):null},getMultipleElementsFromSelector(t){const e=R(t);return e?K.find(e):[]}},V=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,s=t.NAME;j.on(document,i,`[data-bs-dismiss="${s}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),d(this))return;const n=K.getElementFromSelector(this)||this.closest(`.${s}`);t.getOrCreateInstance(n)[e]()}))},Q=".bs.alert",X=`close${Q}`,Y=`closed${Q}`;class U extends W{static get NAME(){return"alert"}close(){if(j.trigger(this._element,X).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),j.trigger(this._element,Y),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=U.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}V(U,"close"),b(U);const G='[data-bs-toggle="button"]';class J extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=J.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}j.on(document,"click.bs.button.data-api",G,(t=>{t.preventDefault();const e=t.target.closest(G);J.getOrCreateInstance(e).toggle()})),b(J);const Z=".bs.swipe",tt=`touchstart${Z}`,et=`touchmove${Z}`,it=`touchend${Z}`,st=`pointerdown${Z}`,nt=`pointerup${Z}`,ot={endCallback:null,leftCallback:null,rightCallback:null},rt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class at extends q{constructor(t,e){super(),this._element=t,t&&at.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return ot}static get DefaultType(){return rt}static get NAME(){return"swipe"}dispose(){j.off(this._element,Z)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),v(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&v(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(j.on(this._element,st,(t=>this._start(t))),j.on(this._element,nt,(t=>this._end(t))),this._element.classList.add("pointer-event")):(j.on(this._element,tt,(t=>this._start(t))),j.on(this._element,et,(t=>this._move(t))),j.on(this._element,it,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const lt=".bs.carousel",ct=".data-api",ht="next",dt="prev",ut="left",_t="right",gt=`slide${lt}`,ft=`slid${lt}`,mt=`keydown${lt}`,pt=`mouseenter${lt}`,bt=`mouseleave${lt}`,vt=`dragstart${lt}`,yt=`load${lt}${ct}`,wt=`click${lt}${ct}`,At="carousel",Et="active",Ct=".active",Tt=".carousel-item",kt=Ct+Tt,$t={ArrowLeft:_t,ArrowRight:ut},St={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Lt={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class Ot extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=K.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===At&&this.cycle()}static get Default(){return St}static get DefaultType(){return Lt}static get NAME(){return"carousel"}next(){this._slide(ht)}nextWhenVisible(){!document.hidden&&h(this._element)&&this.next()}prev(){this._slide(dt)}pause(){this._isSliding&&a(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?j.one(this._element,ft,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void j.one(this._element,ft,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const s=t>i?ht:dt;this._slide(s,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&j.on(this._element,mt,(t=>this._keydown(t))),"hover"===this._config.pause&&(j.on(this._element,pt,(()=>this.pause())),j.on(this._element,bt,(()=>this._maybeEnableCycle()))),this._config.touch&&at.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of K.find(".carousel-item img",this._element))j.on(t,vt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ut)),rightCallback:()=>this._slide(this._directionToOrder(_t)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new at(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=$t[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=K.findOne(Ct,this._indicatorsElement);e.classList.remove(Et),e.removeAttribute("aria-current");const i=K.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(Et),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),s=t===ht,n=e||w(this._getItems(),i,s,this._config.wrap);if(n===i)return;const o=this._getItemIndex(n),r=e=>j.trigger(this._element,e,{relatedTarget:n,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(gt).defaultPrevented)return;if(!i||!n)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=n;const l=s?"carousel-item-start":"carousel-item-end",c=s?"carousel-item-next":"carousel-item-prev";n.classList.add(c),g(n),i.classList.add(l),n.classList.add(l),this._queueCallback((()=>{n.classList.remove(l,c),n.classList.add(Et),i.classList.remove(Et,c,l),this._isSliding=!1,r(ft)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return K.findOne(kt,this._element)}_getItems(){return K.find(Tt,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ut?dt:ht:t===ut?ht:dt}_orderToDirection(t){return p()?t===dt?ut:_t:t===dt?_t:ut}static jQueryInterface(t){return this.each((function(){const e=Ot.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}j.on(document,wt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=K.getElementFromSelector(this);if(!e||!e.classList.contains(At))return;t.preventDefault();const i=Ot.getOrCreateInstance(e),s=this.getAttribute("data-bs-slide-to");return s?(i.to(s),void i._maybeEnableCycle()):"next"===B.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),j.on(window,yt,(()=>{const t=K.find('[data-bs-ride="carousel"]');for(const e of t)Ot.getOrCreateInstance(e)})),b(Ot);const It=".bs.collapse",Dt=`show${It}`,Nt=`shown${It}`,Pt=`hide${It}`,xt=`hidden${It}`,Mt=`click${It}.data-api`,jt="show",Ft="collapse",zt="collapsing",Ht=`:scope .${Ft} .${Ft}`,Bt='[data-bs-toggle="collapse"]',qt={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Rt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=K.find(Bt);for(const t of i){const e=K.getSelectorFromElement(t),i=K.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return qt}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Rt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(j.trigger(this._element,Dt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Ft),this._element.classList.add(zt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(zt),this._element.classList.add(Ft,jt),this._element.style[e]="",j.trigger(this._element,Nt)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(j.trigger(this._element,Pt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,g(this._element),this._element.classList.add(zt),this._element.classList.remove(Ft,jt);for(const t of this._triggerArray){const e=K.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(zt),this._element.classList.add(Ft),j.trigger(this._element,xt)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(jt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=c(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Bt);for(const e of t){const t=K.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=K.find(Ht,this._config.parent);return K.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Rt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}j.on(document,Mt,Bt,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of K.getMultipleElementsFromSelector(this))Rt.getOrCreateInstance(t,{toggle:!1}).toggle()})),b(Rt);const Kt="dropdown",Vt=".bs.dropdown",Qt=".data-api",Xt="ArrowUp",Yt="ArrowDown",Ut=`hide${Vt}`,Gt=`hidden${Vt}`,Jt=`show${Vt}`,Zt=`shown${Vt}`,te=`click${Vt}${Qt}`,ee=`keydown${Vt}${Qt}`,ie=`keyup${Vt}${Qt}`,se="show",ne='[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)',oe=`${ne}.${se}`,re=".dropdown-menu",ae=p()?"top-end":"top-start",le=p()?"top-start":"top-end",ce=p()?"bottom-end":"bottom-start",he=p()?"bottom-start":"bottom-end",de=p()?"left-start":"right-start",ue=p()?"right-start":"left-start",_e={autoClose:!0,boundary:"clippingParents",display:"dynamic",offset:[0,2],popperConfig:null,reference:"toggle"},ge={autoClose:"(boolean|string)",boundary:"(string|element)",display:"string",offset:"(array|string|function)",popperConfig:"(null|object|function)",reference:"(string|element|object)"};class fe extends W{constructor(t,e){super(t,e),this._popper=null,this._parent=this._element.parentNode,this._menu=K.next(this._element,re)[0]||K.prev(this._element,re)[0]||K.findOne(re,this._parent),this._inNavbar=this._detectNavbar()}static get Default(){return _e}static get DefaultType(){return ge}static get NAME(){return Kt}toggle(){return this._isShown()?this.hide():this.show()}show(){if(d(this._element)||this._isShown())return;const t={relatedTarget:this._element};if(!j.trigger(this._element,Jt,t).defaultPrevented){if(this._createPopper(),"ontouchstart"in document.documentElement&&!this._parent.closest(".navbar-nav"))for(const t of[].concat(...document.body.children))j.on(t,"mouseover",_);this._element.focus(),this._element.setAttribute("aria-expanded",!0),this._menu.classList.add(se),this._element.classList.add(se),j.trigger(this._element,Zt,t)}}hide(){if(d(this._element)||!this._isShown())return;const t={relatedTarget:this._element};this._completeHide(t)}dispose(){this._popper&&this._popper.destroy(),super.dispose()}update(){this._inNavbar=this._detectNavbar(),this._popper&&this._popper.update()}_completeHide(t){if(!j.trigger(this._element,Ut,t).defaultPrevented){if("ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.off(t,"mouseover",_);this._popper&&this._popper.destroy(),this._menu.classList.remove(se),this._element.classList.remove(se),this._element.setAttribute("aria-expanded","false"),B.removeDataAttribute(this._menu,"popper"),j.trigger(this._element,Gt,t)}}_getConfig(t){if("object"==typeof(t=super._getConfig(t)).reference&&!l(t.reference)&&"function"!=typeof t.reference.getBoundingClientRect)throw new TypeError(`${Kt.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);return t}_createPopper(){if(void 0===i)throw new TypeError("Bootstrap's dropdowns require Popper (https://popper.js.org)");let t=this._element;"parent"===this._config.reference?t=this._parent:l(this._config.reference)?t=c(this._config.reference):"object"==typeof this._config.reference&&(t=this._config.reference);const e=this._getPopperConfig();this._popper=i.createPopper(t,this._menu,e)}_isShown(){return this._menu.classList.contains(se)}_getPlacement(){const t=this._parent;if(t.classList.contains("dropend"))return de;if(t.classList.contains("dropstart"))return ue;if(t.classList.contains("dropup-center"))return"top";if(t.classList.contains("dropdown-center"))return"bottom";const e="end"===getComputedStyle(this._menu).getPropertyValue("--bs-position").trim();return t.classList.contains("dropup")?e?le:ae:e?he:ce}_detectNavbar(){return null!==this._element.closest(".navbar")}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(B.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...v(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=K.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>h(t)));i.length&&w(i,e,t===Yt,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=fe.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=K.find(oe);for(const i of e){const e=fe.getInstance(i);if(!e||!1===e._config.autoClose)continue;const s=t.composedPath(),n=s.includes(e._menu);if(s.includes(e._element)||"inside"===e._config.autoClose&&!n||"outside"===e._config.autoClose&&n)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,s=[Xt,Yt].includes(t.key);if(!s&&!i)return;if(e&&!i)return;t.preventDefault();const n=this.matches(ne)?this:K.prev(this,ne)[0]||K.next(this,ne)[0]||K.findOne(ne,t.delegateTarget.parentNode),o=fe.getOrCreateInstance(n);if(s)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),n.focus())}}j.on(document,ee,ne,fe.dataApiKeydownHandler),j.on(document,ee,re,fe.dataApiKeydownHandler),j.on(document,te,fe.clearMenus),j.on(document,ie,fe.clearMenus),j.on(document,te,ne,(function(t){t.preventDefault(),fe.getOrCreateInstance(this).toggle()})),b(fe);const me="backdrop",pe="show",be=`mousedown.bs.${me}`,ve={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},ye={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class we extends q{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return ve}static get DefaultType(){return ye}static get NAME(){return me}show(t){if(!this._config.isVisible)return void v(t);this._append();const e=this._getElement();this._config.isAnimated&&g(e),e.classList.add(pe),this._emulateAnimation((()=>{v(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(pe),this._emulateAnimation((()=>{this.dispose(),v(t)}))):v(t)}dispose(){this._isAppended&&(j.off(this._element,be),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=c(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),j.on(t,be,(()=>{v(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){y(t,this._getElement(),this._config.isAnimated)}}const Ae=".bs.focustrap",Ee=`focusin${Ae}`,Ce=`keydown.tab${Ae}`,Te="backward",ke={autofocus:!0,trapElement:null},$e={autofocus:"boolean",trapElement:"element"};class Se extends q{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return ke}static get DefaultType(){return $e}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),j.off(document,Ae),j.on(document,Ee,(t=>this._handleFocusin(t))),j.on(document,Ce,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,j.off(document,Ae))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=K.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===Te?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?Te:"forward")}}const Le=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",Oe=".sticky-top",Ie="padding-right",De="margin-right";class Ne{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Ie,(e=>e+t)),this._setElementAttributes(Le,Ie,(e=>e+t)),this._setElementAttributes(Oe,De,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,Ie),this._resetElementAttributes(Le,Ie),this._resetElementAttributes(Oe,De)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const s=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+s)return;this._saveInitialAttribute(t,e);const n=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(n))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&B.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=B.getDataAttribute(t,e);null!==i?(B.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(l(t))e(t);else for(const i of K.find(t,this._element))e(i)}}const Pe=".bs.modal",xe=`hide${Pe}`,Me=`hidePrevented${Pe}`,je=`hidden${Pe}`,Fe=`show${Pe}`,ze=`shown${Pe}`,He=`resize${Pe}`,Be=`click.dismiss${Pe}`,qe=`mousedown.dismiss${Pe}`,We=`keydown.dismiss${Pe}`,Re=`click${Pe}.data-api`,Ke="modal-open",Ve="show",Qe="modal-static",Xe={backdrop:!0,focus:!0,keyboard:!0},Ye={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ue extends W{constructor(t,e){super(t,e),this._dialog=K.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new Ne,this._addEventListeners()}static get Default(){return Xe}static get DefaultType(){return Ye}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||j.trigger(this._element,Fe,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(Ke),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(j.trigger(this._element,xe).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Ve),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){j.off(window,Pe),j.off(this._dialog,Pe),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new we({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Se({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=K.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),g(this._element),this._element.classList.add(Ve),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,j.trigger(this._element,ze,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){j.on(this._element,We,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),j.on(window,He,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),j.on(this._element,qe,(t=>{j.one(this._element,Be,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(Ke),this._resetAdjustments(),this._scrollBar.reset(),j.trigger(this._element,je)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(j.trigger(this._element,Me).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Qe)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Qe),this._queueCallback((()=>{this._element.classList.remove(Qe),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ue.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}j.on(document,Re,'[data-bs-toggle="modal"]',(function(t){const e=K.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),j.one(e,Fe,(t=>{t.defaultPrevented||j.one(e,je,(()=>{h(this)&&this.focus()}))}));const i=K.findOne(".modal.show");i&&Ue.getInstance(i).hide(),Ue.getOrCreateInstance(e).toggle(this)})),V(Ue),b(Ue);const Ge=".bs.offcanvas",Je=".data-api",Ze=`load${Ge}${Je}`,ti="show",ei="showing",ii="hiding",si=".offcanvas.show",ni=`show${Ge}`,oi=`shown${Ge}`,ri=`hide${Ge}`,ai=`hidePrevented${Ge}`,li=`hidden${Ge}`,ci=`resize${Ge}`,hi=`click${Ge}${Je}`,di=`keydown.dismiss${Ge}`,ui={backdrop:!0,keyboard:!0,scroll:!1},_i={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class gi extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return ui}static get DefaultType(){return _i}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||j.trigger(this._element,ni,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new Ne).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(ei),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(ti),this._element.classList.remove(ei),j.trigger(this._element,oi,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(j.trigger(this._element,ri).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ii),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(ti,ii),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new Ne).reset(),j.trigger(this._element,li)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new we({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():j.trigger(this._element,ai)}:null})}_initializeFocusTrap(){return new Se({trapElement:this._element})}_addEventListeners(){j.on(this._element,di,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():j.trigger(this._element,ai))}))}static jQueryInterface(t){return this.each((function(){const e=gi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}j.on(document,hi,'[data-bs-toggle="offcanvas"]',(function(t){const e=K.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),d(this))return;j.one(e,li,(()=>{h(this)&&this.focus()}));const i=K.findOne(si);i&&i!==e&&gi.getInstance(i).hide(),gi.getOrCreateInstance(e).toggle(this)})),j.on(window,Ze,(()=>{for(const t of K.find(si))gi.getOrCreateInstance(t).show()})),j.on(window,ci,(()=>{for(const t of K.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&gi.getOrCreateInstance(t).hide()})),V(gi),b(gi);const fi={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},mi=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),pi=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,bi=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!mi.has(i)||Boolean(pi.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},vi={allowList:fi,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"<div></div>"},yi={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},wi={entry:"(string|element|function|null)",selector:"(string|element)"};class Ai extends q{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return vi}static get DefaultType(){return yi}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},wi)}_setContent(t,e,i){const s=K.findOne(i,t);s&&((e=this._resolvePossibleFunction(e))?l(e)?this._putElementInTemplate(c(e),s):this._config.html?s.innerHTML=this._maybeSanitize(e):s.textContent=e:s.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const s=(new window.DOMParser).parseFromString(t,"text/html"),n=[].concat(...s.body.querySelectorAll("*"));for(const t of n){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const s=[].concat(...t.attributes),n=[].concat(e["*"]||[],e[i]||[]);for(const e of s)bi(e,n)||t.removeAttribute(e.nodeName)}return s.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return v(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Ei=new Set(["sanitize","allowList","sanitizeFn"]),Ci="fade",Ti="show",ki=".modal",$i="hide.bs.modal",Si="hover",Li="focus",Oi={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},Ii={allowList:fi,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',title:"",trigger:"hover focus"},Di={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class Ni extends W{constructor(t,e){if(void 0===i)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return Ii}static get DefaultType(){return Di}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),j.off(this._element.closest(ki),$i,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=j.trigger(this._element,this.constructor.eventName("show")),e=(u(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:s}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(s.append(i),j.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(Ti),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.on(t,"mouseover",_);this._queueCallback((()=>{j.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!j.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(Ti),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))j.off(t,"mouseover",_);this._activeTrigger.click=!1,this._activeTrigger[Li]=!1,this._activeTrigger[Si]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),j.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ci,Ti),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(Ci),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Ai({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ci)}_isShown(){return this.tip&&this.tip.classList.contains(Ti)}_createPopper(t){const e=v(this._config.placement,[this,t,this._element]),s=Oi[e.toUpperCase()];return i.createPopper(this._element,t,this._getPopperConfig(s))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return v(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...v(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)j.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===Si?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===Si?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");j.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?Li:Si]=!0,e._enter()})),j.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?Li:Si]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},j.on(this._element.closest(ki),$i,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=B.getDataAttributes(this._element);for(const t of Object.keys(e))Ei.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:c(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=Ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Ni);const Pi={...Ni.Default,content:"",offset:[0,8],placement:"right",template:'<div class="popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-header"></h3><div class="popover-body"></div></div>',trigger:"click"},xi={...Ni.DefaultType,content:"(null|string|element|function)"};class Mi extends Ni{static get Default(){return Pi}static get DefaultType(){return xi}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=Mi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}b(Mi);const ji=".bs.scrollspy",Fi=`activate${ji}`,zi=`click${ji}`,Hi=`load${ji}.data-api`,Bi="active",qi="[href]",Wi=".nav-link",Ri=`${Wi}, .nav-item > ${Wi}, .list-group-item`,Ki={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},Vi={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Qi extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return Ki}static get DefaultType(){return Vi}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=c(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(j.off(this._config.target,zi),j.on(this._config.target,zi,qi,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,s=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:s,behavior:"smooth"});i.scrollTop=s}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},s=(this._rootElement||document.documentElement).scrollTop,n=s>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=s;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(n&&t){if(i(o),!s)return}else n||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=K.find(qi,this._config.target);for(const e of t){if(!e.hash||d(e))continue;const t=K.findOne(decodeURI(e.hash),this._element);h(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(Bi),this._activateParents(t),j.trigger(this._element,Fi,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))K.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(Bi);else for(const e of K.parents(t,".nav, .list-group"))for(const t of K.prev(e,Ri))t.classList.add(Bi)}_clearActiveClass(t){t.classList.remove(Bi);const e=K.find(`${qi}.${Bi}`,t);for(const t of e)t.classList.remove(Bi)}static jQueryInterface(t){return this.each((function(){const e=Qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(window,Hi,(()=>{for(const t of K.find('[data-bs-spy="scroll"]'))Qi.getOrCreateInstance(t)})),b(Qi);const Xi=".bs.tab",Yi=`hide${Xi}`,Ui=`hidden${Xi}`,Gi=`show${Xi}`,Ji=`shown${Xi}`,Zi=`click${Xi}`,ts=`keydown${Xi}`,es=`load${Xi}`,is="ArrowLeft",ss="ArrowRight",ns="ArrowUp",os="ArrowDown",rs="Home",as="End",ls="active",cs="fade",hs="show",ds=".dropdown-toggle",us=`:not(${ds})`,_s='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',gs=`.nav-link${us}, .list-group-item${us}, [role="tab"]${us}, ${_s}`,fs=`.${ls}[data-bs-toggle="tab"], .${ls}[data-bs-toggle="pill"], .${ls}[data-bs-toggle="list"]`;class ms extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),j.on(this._element,ts,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?j.trigger(e,Yi,{relatedTarget:t}):null;j.trigger(t,Gi,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(ls),this._activate(K.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),j.trigger(t,Ji,{relatedTarget:e})):t.classList.add(hs)}),t,t.classList.contains(cs)))}_deactivate(t,e){t&&(t.classList.remove(ls),t.blur(),this._deactivate(K.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),j.trigger(t,Ui,{relatedTarget:e})):t.classList.remove(hs)}),t,t.classList.contains(cs)))}_keydown(t){if(![is,ss,ns,os,rs,as].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!d(t)));let i;if([rs,as].includes(t.key))i=e[t.key===rs?0:e.length-1];else{const s=[ss,os].includes(t.key);i=w(e,t.target,s,!0)}i&&(i.focus({preventScroll:!0}),ms.getOrCreateInstance(i).show())}_getChildren(){return K.find(gs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=K.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const s=(t,s)=>{const n=K.findOne(t,i);n&&n.classList.toggle(s,e)};s(ds,ls),s(".dropdown-menu",hs),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(ls)}_getInnerElement(t){return t.matches(gs)?t:K.findOne(gs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=ms.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}j.on(document,Zi,_s,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),d(this)||ms.getOrCreateInstance(this).show()})),j.on(window,es,(()=>{for(const t of K.find(fs))ms.getOrCreateInstance(t)})),b(ms);const ps=".bs.toast",bs=`mouseover${ps}`,vs=`mouseout${ps}`,ys=`focusin${ps}`,ws=`focusout${ps}`,As=`hide${ps}`,Es=`hidden${ps}`,Cs=`show${ps}`,Ts=`shown${ps}`,ks="hide",$s="show",Ss="showing",Ls={animation:"boolean",autohide:"boolean",delay:"number"},Os={animation:!0,autohide:!0,delay:5e3};class Is extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return Os}static get DefaultType(){return Ls}static get NAME(){return"toast"}show(){j.trigger(this._element,Cs).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(ks),g(this._element),this._element.classList.add($s,Ss),this._queueCallback((()=>{this._element.classList.remove(Ss),j.trigger(this._element,Ts),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(j.trigger(this._element,As).defaultPrevented||(this._element.classList.add(Ss),this._queueCallback((()=>{this._element.classList.add(ks),this._element.classList.remove(Ss,$s),j.trigger(this._element,Es)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove($s),super.dispose()}isShown(){return this._element.classList.contains($s)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){j.on(this._element,bs,(t=>this._onInteraction(t,!0))),j.on(this._element,vs,(t=>this._onInteraction(t,!1))),j.on(this._element,ys,(t=>this._onInteraction(t,!0))),j.on(this._element,ws,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Is.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return V(Is),b(Is),{Alert:U,Button:J,Carousel:Ot,Collapse:Rt,Dropdown:fe,Modal:Ue,Offcanvas:gi,Popover:Mi,ScrollSpy:Qi,Tab:ms,Toast:Is,Tooltip:Ni}}));
7 +//# sourceMappingURL=bootstrap.min.js.map
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt +23 −0
@@ -0,0 +1,23 @@
1 +The MIT License (MIT)
2 +
3 +Copyright (c) .NET Foundation and Contributors
4 +
5 +All rights reserved.
6 +
7 +Permission is hereby granted, free of charge, to any person obtaining a copy
8 +of this software and associated documentation files (the "Software"), to deal
9 +in the Software without restriction, including without limitation the rights
10 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 +copies of the Software, and to permit persons to whom the Software is
12 +furnished to do so, subject to the following conditions:
13 +
14 +The above copyright notice and this permission notice shall be included in all
15 +copies or substantial portions of the Software.
16 +
17 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 +SOFTWARE.
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.js +435 −0
@@ -0,0 +1,435 @@
1 +/**
2 + * @license
3 + * Unobtrusive validation support library for jQuery and jQuery Validate
4 + * Copyright (c) .NET Foundation. All rights reserved.
5 + * Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
6 + * @version v4.0.0
7 + */
8 +
9 +/*jslint white: true, browser: true, onevar: true, undef: true, nomen: true, eqeqeq: true, plusplus: true, bitwise: true, regexp: true, newcap: true, immed: true, strict: false */
10 +/*global document: false, jQuery: false */
11 +
12 +(function (factory) {
13 + if (typeof define === 'function' && define.amd) {
14 + // AMD. Register as an anonymous module.
15 + define("jquery.validate.unobtrusive", ['jquery-validation'], factory);
16 + } else if (typeof module === 'object' && module.exports) {
17 + // CommonJS-like environments that support module.exports
18 + module.exports = factory(require('jquery-validation'));
19 + } else {
20 + // Browser global
21 + jQuery.validator.unobtrusive = factory(jQuery);
22 + }
23 +}(function ($) {
24 + var $jQval = $.validator,
25 + adapters,
26 + data_validation = "unobtrusiveValidation";
27 +
28 + function setValidationValues(options, ruleName, value) {
29 + options.rules[ruleName] = value;
30 + if (options.message) {
31 + options.messages[ruleName] = options.message;
32 + }
33 + }
34 +
35 + function splitAndTrim(value) {
36 + return value.replace(/^\s+|\s+$/g, "").split(/\s*,\s*/g);
37 + }
38 +
39 + function escapeAttributeValue(value) {
40 + // As mentioned on http://api.jquery.com/category/selectors/
41 + return value.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g, "\\$1");
42 + }
43 +
44 + function getModelPrefix(fieldName) {
45 + return fieldName.substr(0, fieldName.lastIndexOf(".") + 1);
46 + }
47 +
48 + function appendModelPrefix(value, prefix) {
49 + if (value.indexOf("*.") === 0) {
50 + value = value.replace("*.", prefix);
51 + }
52 + return value;
53 + }
54 +
55 + function onError(error, inputElement) { // 'this' is the form element
56 + var container = $(this).find("[data-valmsg-for='" + escapeAttributeValue(inputElement[0].name) + "']"),
57 + replaceAttrValue = container.attr("data-valmsg-replace"),
58 + replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) !== false : null;
59 +
60 + container.removeClass("field-validation-valid").addClass("field-validation-error");
61 + error.data("unobtrusiveContainer", container);
62 +
63 + if (replace) {
64 + container.empty();
65 + error.removeClass("input-validation-error").appendTo(container);
66 + }
67 + else {
68 + error.hide();
69 + }
70 + }
71 +
72 + function onErrors(event, validator) { // 'this' is the form element
73 + var container = $(this).find("[data-valmsg-summary=true]"),
74 + list = container.find("ul");
75 +
76 + if (list && list.length && validator.errorList.length) {
77 + list.empty();
78 + container.addClass("validation-summary-errors").removeClass("validation-summary-valid");
79 +
80 + $.each(validator.errorList, function () {
81 + $("<li />").html(this.message).appendTo(list);
82 + });
83 + }
84 + }
85 +
86 + function onSuccess(error) { // 'this' is the form element
87 + var container = error.data("unobtrusiveContainer");
88 +
89 + if (container) {
90 + var replaceAttrValue = container.attr("data-valmsg-replace"),
91 + replace = replaceAttrValue ? $.parseJSON(replaceAttrValue) : null;
92 +
93 + container.addClass("field-validation-valid").removeClass("field-validation-error");
94 + error.removeData("unobtrusiveContainer");
95 +
96 + if (replace) {
97 + container.empty();
98 + }
99 + }
100 + }
101 +
102 + function onReset(event) { // 'this' is the form element
103 + var $form = $(this),
104 + key = '__jquery_unobtrusive_validation_form_reset';
105 + if ($form.data(key)) {
106 + return;
107 + }
108 + // Set a flag that indicates we're currently resetting the form.
109 + $form.data(key, true);
110 + try {
111 + $form.data("validator").resetForm();
112 + } finally {
113 + $form.removeData(key);
114 + }
115 +
116 + $form.find(".validation-summary-errors")
117 + .addClass("validation-summary-valid")
118 + .removeClass("validation-summary-errors");
119 + $form.find(".field-validation-error")
120 + .addClass("field-validation-valid")
121 + .removeClass("field-validation-error")
122 + .removeData("unobtrusiveContainer")
123 + .find(">*") // If we were using valmsg-replace, get the underlying error
124 + .removeData("unobtrusiveContainer");
125 + }
126 +
127 + function validationInfo(form) {
128 + var $form = $(form),
129 + result = $form.data(data_validation),
130 + onResetProxy = $.proxy(onReset, form),
131 + defaultOptions = $jQval.unobtrusive.options || {},
132 + execInContext = function (name, args) {
133 + var func = defaultOptions[name];
134 + func && $.isFunction(func) && func.apply(form, args);
135 + };
136 +
137 + if (!result) {
138 + result = {
139 + options: { // options structure passed to jQuery Validate's validate() method
140 + errorClass: defaultOptions.errorClass || "input-validation-error",
141 + errorElement: defaultOptions.errorElement || "span",
142 + errorPlacement: function () {
143 + onError.apply(form, arguments);
144 + execInContext("errorPlacement", arguments);
145 + },
146 + invalidHandler: function () {
147 + onErrors.apply(form, arguments);
148 + execInContext("invalidHandler", arguments);
149 + },
150 + messages: {},
151 + rules: {},
152 + success: function () {
153 + onSuccess.apply(form, arguments);
154 + execInContext("success", arguments);
155 + }
156 + },
157 + attachValidation: function () {
158 + $form
159 + .off("reset." + data_validation, onResetProxy)
160 + .on("reset." + data_validation, onResetProxy)
161 + .validate(this.options);
162 + },
163 + validate: function () { // a validation function that is called by unobtrusive Ajax
164 + $form.validate();
165 + return $form.valid();
166 + }
167 + };
168 + $form.data(data_validation, result);
169 + }
170 +
171 + return result;
172 + }
173 +
174 + $jQval.unobtrusive = {
175 + adapters: [],
176 +
177 + parseElement: function (element, skipAttach) {
178 + /// <summary>
179 + /// Parses a single HTML element for unobtrusive validation attributes.
180 + /// </summary>
181 + /// <param name="element" domElement="true">The HTML element to be parsed.</param>
182 + /// <param name="skipAttach" type="Boolean">[Optional] true to skip attaching the
183 + /// validation to the form. If parsing just this single element, you should specify true.
184 + /// If parsing several elements, you should specify false, and manually attach the validation
185 + /// to the form when you are finished. The default is false.</param>
186 + var $element = $(element),
187 + form = $element.parents("form")[0],
188 + valInfo, rules, messages;
189 +
190 + if (!form) { // Cannot do client-side validation without a form
191 + return;
192 + }
193 +
194 + valInfo = validationInfo(form);
195 + valInfo.options.rules[element.name] = rules = {};
196 + valInfo.options.messages[element.name] = messages = {};
197 +
198 + $.each(this.adapters, function () {
199 + var prefix = "data-val-" + this.name,
200 + message = $element.attr(prefix),
201 + paramValues = {};
202 +
203 + if (message !== undefined) { // Compare against undefined, because an empty message is legal (and falsy)
204 + prefix += "-";
205 +
206 + $.each(this.params, function () {
207 + paramValues[this] = $element.attr(prefix + this);
208 + });
209 +
210 + this.adapt({
211 + element: element,
212 + form: form,
213 + message: message,
214 + params: paramValues,
215 + rules: rules,
216 + messages: messages
217 + });
218 + }
219 + });
220 +
221 + $.extend(rules, { "__dummy__": true });
222 +
223 + if (!skipAttach) {
224 + valInfo.attachValidation();
225 + }
226 + },
227 +
228 + parse: function (selector) {
229 + /// <summary>
230 + /// Parses all the HTML elements in the specified selector. It looks for input elements decorated
231 + /// with the [data-val=true] attribute value and enables validation according to the data-val-*
232 + /// attribute values.
233 + /// </summary>
234 + /// <param name="selector" type="String">Any valid jQuery selector.</param>
235 +
236 + // $forms includes all forms in selector's DOM hierarchy (parent, children and self) that have at least one
237 + // element with data-val=true
238 + var $selector = $(selector),
239 + $forms = $selector.parents()
240 + .addBack()
241 + .filter("form")
242 + .add($selector.find("form"))
243 + .has("[data-val=true]");
244 +
245 + $selector.find("[data-val=true]").each(function () {
246 + $jQval.unobtrusive.parseElement(this, true);
247 + });
248 +
249 + $forms.each(function () {
250 + var info = validationInfo(this);
251 + if (info) {
252 + info.attachValidation();
253 + }
254 + });
255 + }
256 + };
257 +
258 + adapters = $jQval.unobtrusive.adapters;
259 +
260 + adapters.add = function (adapterName, params, fn) {
261 + /// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation.</summary>
262 + /// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
263 + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
264 + /// <param name="params" type="Array" optional="true">[Optional] An array of parameter names (strings) that will
265 + /// be extracted from the data-val-nnnn-mmmm HTML attributes (where nnnn is the adapter name, and
266 + /// mmmm is the parameter name).</param>
267 + /// <param name="fn" type="Function">The function to call, which adapts the values from the HTML
268 + /// attributes into jQuery Validate rules and/or messages.</param>
269 + /// <returns type="jQuery.validator.unobtrusive.adapters" />
270 + if (!fn) { // Called with no params, just a function
271 + fn = params;
272 + params = [];
273 + }
274 + this.push({ name: adapterName, params: params, adapt: fn });
275 + return this;
276 + };
277 +
278 + adapters.addBool = function (adapterName, ruleName) {
279 + /// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
280 + /// the jQuery Validate validation rule has no parameter values.</summary>
281 + /// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
282 + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
283 + /// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
284 + /// of adapterName will be used instead.</param>
285 + /// <returns type="jQuery.validator.unobtrusive.adapters" />
286 + return this.add(adapterName, function (options) {
287 + setValidationValues(options, ruleName || adapterName, true);
288 + });
289 + };
290 +
291 + adapters.addMinMax = function (adapterName, minRuleName, maxRuleName, minMaxRuleName, minAttribute, maxAttribute) {
292 + /// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
293 + /// the jQuery Validate validation has three potential rules (one for min-only, one for max-only, and
294 + /// one for min-and-max). The HTML parameters are expected to be named -min and -max.</summary>
295 + /// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
296 + /// in the data-val-nnnn HTML attribute (where nnnn is the adapter name).</param>
297 + /// <param name="minRuleName" type="String">The name of the jQuery Validate rule to be used when you only
298 + /// have a minimum value.</param>
299 + /// <param name="maxRuleName" type="String">The name of the jQuery Validate rule to be used when you only
300 + /// have a maximum value.</param>
301 + /// <param name="minMaxRuleName" type="String">The name of the jQuery Validate rule to be used when you
302 + /// have both a minimum and maximum value.</param>
303 + /// <param name="minAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
304 + /// contains the minimum value. The default is "min".</param>
305 + /// <param name="maxAttribute" type="String" optional="true">[Optional] The name of the HTML attribute that
306 + /// contains the maximum value. The default is "max".</param>
307 + /// <returns type="jQuery.validator.unobtrusive.adapters" />
308 + return this.add(adapterName, [minAttribute || "min", maxAttribute || "max"], function (options) {
309 + var min = options.params.min,
310 + max = options.params.max;
311 +
312 + if (min && max) {
313 + setValidationValues(options, minMaxRuleName, [min, max]);
314 + }
315 + else if (min) {
316 + setValidationValues(options, minRuleName, min);
317 + }
318 + else if (max) {
319 + setValidationValues(options, maxRuleName, max);
320 + }
321 + });
322 + };
323 +
324 + adapters.addSingleVal = function (adapterName, attribute, ruleName) {
325 + /// <summary>Adds a new adapter to convert unobtrusive HTML into a jQuery Validate validation, where
326 + /// the jQuery Validate validation rule has a single value.</summary>
327 + /// <param name="adapterName" type="String">The name of the adapter to be added. This matches the name used
328 + /// in the data-val-nnnn HTML attribute(where nnnn is the adapter name).</param>
329 + /// <param name="attribute" type="String">[Optional] The name of the HTML attribute that contains the value.
330 + /// The default is "val".</param>
331 + /// <param name="ruleName" type="String" optional="true">[Optional] The name of the jQuery Validate rule. If not provided, the value
332 + /// of adapterName will be used instead.</param>
333 + /// <returns type="jQuery.validator.unobtrusive.adapters" />
334 + return this.add(adapterName, [attribute || "val"], function (options) {
335 + setValidationValues(options, ruleName || adapterName, options.params[attribute]);
336 + });
337 + };
338 +
339 + $jQval.addMethod("__dummy__", function (value, element, params) {
340 + return true;
341 + });
342 +
343 + $jQval.addMethod("regex", function (value, element, params) {
344 + var match;
345 + if (this.optional(element)) {
346 + return true;
347 + }
348 +
349 + match = new RegExp(params).exec(value);
350 + return (match && (match.index === 0) && (match[0].length === value.length));
351 + });
352 +
353 + $jQval.addMethod("nonalphamin", function (value, element, nonalphamin) {
354 + var match;
355 + if (nonalphamin) {
356 + match = value.match(/\W/g);
357 + match = match && match.length >= nonalphamin;
358 + }
359 + return match;
360 + });
361 +
362 + if ($jQval.methods.extension) {
363 + adapters.addSingleVal("accept", "mimtype");
364 + adapters.addSingleVal("extension", "extension");
365 + } else {
366 + // for backward compatibility, when the 'extension' validation method does not exist, such as with versions
367 + // of JQuery Validation plugin prior to 1.10, we should use the 'accept' method for
368 + // validating the extension, and ignore mime-type validations as they are not supported.
369 + adapters.addSingleVal("extension", "extension", "accept");
370 + }
371 +
372 + adapters.addSingleVal("regex", "pattern");
373 + adapters.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url");
374 + adapters.addMinMax("length", "minlength", "maxlength", "rangelength").addMinMax("range", "min", "max", "range");
375 + adapters.addMinMax("minlength", "minlength").addMinMax("maxlength", "minlength", "maxlength");
376 + adapters.add("equalto", ["other"], function (options) {
377 + var prefix = getModelPrefix(options.element.name),
378 + other = options.params.other,
379 + fullOtherName = appendModelPrefix(other, prefix),
380 + element = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(fullOtherName) + "']")[0];
381 +
382 + setValidationValues(options, "equalTo", element);
383 + });
384 + adapters.add("required", function (options) {
385 + // jQuery Validate equates "required" with "mandatory" for checkbox elements
386 + if (options.element.tagName.toUpperCase() !== "INPUT" || options.element.type.toUpperCase() !== "CHECKBOX") {
387 + setValidationValues(options, "required", true);
388 + }
389 + });
390 + adapters.add("remote", ["url", "type", "additionalfields"], function (options) {
391 + var value = {
392 + url: options.params.url,
393 + type: options.params.type || "GET",
394 + data: {}
395 + },
396 + prefix = getModelPrefix(options.element.name);
397 +
398 + $.each(splitAndTrim(options.params.additionalfields || options.element.name), function (i, fieldName) {
399 + var paramName = appendModelPrefix(fieldName, prefix);
400 + value.data[paramName] = function () {
401 + var field = $(options.form).find(":input").filter("[name='" + escapeAttributeValue(paramName) + "']");
402 + // For checkboxes and radio buttons, only pick up values from checked fields.
403 + if (field.is(":checkbox")) {
404 + return field.filter(":checked").val() || field.filter(":hidden").val() || '';
405 + }
406 + else if (field.is(":radio")) {
407 + return field.filter(":checked").val() || '';
408 + }
409 + return field.val();
410 + };
411 + });
412 +
413 + setValidationValues(options, "remote", value);
414 + });
415 + adapters.add("password", ["min", "nonalphamin", "regex"], function (options) {
416 + if (options.params.min) {
417 + setValidationValues(options, "minlength", options.params.min);
418 + }
419 + if (options.params.nonalphamin) {
420 + setValidationValues(options, "nonalphamin", options.params.nonalphamin);
421 + }
422 + if (options.params.regex) {
423 + setValidationValues(options, "regex", options.params.regex);
424 + }
425 + });
426 + adapters.add("fileextensions", ["extensions"], function (options) {
427 + setValidationValues(options, "extension", options.params.extensions);
428 + });
429 +
430 + $(function () {
431 + $jQval.unobtrusive.parse(document);
432 + });
433 +
434 + return $jQval.unobtrusive;
435 +}));
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js +8 −0
@@ -0,0 +1,8 @@
1 +/**
2 + * @license
3 + * Unobtrusive validation support library for jQuery and jQuery Validate
4 + * Copyright (c) .NET Foundation. All rights reserved.
5 + * Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
6 + * @version v4.0.0
7 + */
8 +!function(a){"function"==typeof define&&define.amd?define("jquery.validate.unobtrusive",["jquery-validation"],a):"object"==typeof module&&module.exports?module.exports=a(require("jquery-validation")):jQuery.validator.unobtrusive=a(jQuery)}(function(s){var a,o=s.validator,d="unobtrusiveValidation";function l(a,e,n){a.rules[e]=n,a.message&&(a.messages[e]=a.message)}function u(a){return a.replace(/([!"#$%&'()*+,./:;<=>?@\[\\\]^`{|}~])/g,"\\$1")}function n(a){return a.substr(0,a.lastIndexOf(".")+1)}function m(a,e){return a=0===a.indexOf("*.")?a.replace("*.",e):a}function f(a){var e=s(this),n="__jquery_unobtrusive_validation_form_reset";if(!e.data(n)){e.data(n,!0);try{e.data("validator").resetForm()}finally{e.removeData(n)}e.find(".validation-summary-errors").addClass("validation-summary-valid").removeClass("validation-summary-errors"),e.find(".field-validation-error").addClass("field-validation-valid").removeClass("field-validation-error").removeData("unobtrusiveContainer").find(">*").removeData("unobtrusiveContainer")}}function p(n){function a(a,e){(a=r[a])&&s.isFunction(a)&&a.apply(n,e)}var e=s(n),t=e.data(d),i=s.proxy(f,n),r=o.unobtrusive.options||{};return t||(t={options:{errorClass:r.errorClass||"input-validation-error",errorElement:r.errorElement||"span",errorPlacement:function(){!function(a,e){var e=s(this).find("[data-valmsg-for='"+u(e[0].name)+"']"),n=(n=e.attr("data-valmsg-replace"))?!1!==s.parseJSON(n):null;e.removeClass("field-validation-valid").addClass("field-validation-error"),a.data("unobtrusiveContainer",e),n?(e.empty(),a.removeClass("input-validation-error").appendTo(e)):a.hide()}.apply(n,arguments),a("errorPlacement",arguments)},invalidHandler:function(){!function(a,e){var n=s(this).find("[data-valmsg-summary=true]"),t=n.find("ul");t&&t.length&&e.errorList.length&&(t.empty(),n.addClass("validation-summary-errors").removeClass("validation-summary-valid"),s.each(e.errorList,function(){s("<li />").html(this.message).appendTo(t)}))}.apply(n,arguments),a("invalidHandler",arguments)},messages:{},rules:{},success:function(){!function(a){var e,n=a.data("unobtrusiveContainer");n&&(e=(e=n.attr("data-valmsg-replace"))?s.parseJSON(e):null,n.addClass("field-validation-valid").removeClass("field-validation-error"),a.removeData("unobtrusiveContainer"),e&&n.empty())}.apply(n,arguments),a("success",arguments)}},attachValidation:function(){e.off("reset."+d,i).on("reset."+d,i).validate(this.options)},validate:function(){return e.validate(),e.valid()}},e.data(d,t)),t}return o.unobtrusive={adapters:[],parseElement:function(t,a){var e,i,r,o=s(t),d=o.parents("form")[0];d&&((e=p(d)).options.rules[t.name]=i={},e.options.messages[t.name]=r={},s.each(this.adapters,function(){var a="data-val-"+this.name,e=o.attr(a),n={};void 0!==e&&(a+="-",s.each(this.params,function(){n[this]=o.attr(a+this)}),this.adapt({element:t,form:d,message:e,params:n,rules:i,messages:r}))}),s.extend(i,{__dummy__:!0}),a||e.attachValidation())},parse:function(a){var a=s(a),e=a.parents().addBack().filter("form").add(a.find("form")).has("[data-val=true]");a.find("[data-val=true]").each(function(){o.unobtrusive.parseElement(this,!0)}),e.each(function(){var a=p(this);a&&a.attachValidation()})}},(a=o.unobtrusive.adapters).add=function(a,e,n){return n||(n=e,e=[]),this.push({name:a,params:e,adapt:n}),this},a.addBool=function(e,n){return this.add(e,function(a){l(a,n||e,!0)})},a.addMinMax=function(a,t,i,r,e,n){return this.add(a,[e||"min",n||"max"],function(a){var e=a.params.min,n=a.params.max;e&&n?l(a,r,[e,n]):e?l(a,t,e):n&&l(a,i,n)})},a.addSingleVal=function(e,n,t){return this.add(e,[n||"val"],function(a){l(a,t||e,a.params[n])})},o.addMethod("__dummy__",function(a,e,n){return!0}),o.addMethod("regex",function(a,e,n){return!!this.optional(e)||(e=new RegExp(n).exec(a))&&0===e.index&&e[0].length===a.length}),o.addMethod("nonalphamin",function(a,e,n){var t;return t=n?(t=a.match(/\W/g))&&t.length>=n:t}),o.methods.extension?(a.addSingleVal("accept","mimtype"),a.addSingleVal("extension","extension")):a.addSingleVal("extension","extension","accept"),a.addSingleVal("regex","pattern"),a.addBool("creditcard").addBool("date").addBool("digits").addBool("email").addBool("number").addBool("url"),a.addMinMax("length","minlength","maxlength","rangelength").addMinMax("range","min","max","range"),a.addMinMax("minlength","minlength").addMinMax("maxlength","minlength","maxlength"),a.add("equalto",["other"],function(a){var e=n(a.element.name),e=m(a.params.other,e);l(a,"equalTo",s(a.form).find(":input").filter("[name='"+u(e)+"']")[0])}),a.add("required",function(a){"INPUT"===a.element.tagName.toUpperCase()&&"CHECKBOX"===a.element.type.toUpperCase()||l(a,"required",!0)}),a.add("remote",["url","type","additionalfields"],function(t){var i={url:t.params.url,type:t.params.type||"GET",data:{}},r=n(t.element.name);s.each((t.params.additionalfields||t.element.name).replace(/^\s+|\s+$/g,"").split(/\s*,\s*/g),function(a,e){var n=m(e,r);i.data[n]=function(){var a=s(t.form).find(":input").filter("[name='"+u(n)+"']");return a.is(":checkbox")?a.filter(":checked").val()||a.filter(":hidden").val()||"":a.is(":radio")?a.filter(":checked").val()||"":a.val()}}),l(t,"remote",i)}),a.add("password",["min","nonalphamin","regex"],function(a){a.params.min&&l(a,"minlength",a.params.min),a.params.nonalphamin&&l(a,"nonalphamin",a.params.nonalphamin),a.params.regex&&l(a,"regex",a.params.regex)}),a.add("fileextensions",["extensions"],function(a){l(a,"extension",a.params.extensions)}),s(function(){o.unobtrusive.parse(document)}),o.unobtrusive});
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/LICENSE.md +22 −0
@@ -0,0 +1,22 @@
1 +The MIT License (MIT)
2 +=====================
3 +
4 +Copyright Jörn Zaefferer
5 +
6 +Permission is hereby granted, free of charge, to any person obtaining a copy
7 +of this software and associated documentation files (the "Software"), to deal
8 +in the Software without restriction, including without limitation the rights
9 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 +copies of the Software, and to permit persons to whom the Software is
11 +furnished to do so, subject to the following conditions:
12 +
13 +The above copyright notice and this permission notice shall be included in
14 +all copies or substantial portions of the Software.
15 +
16 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 +THE SOFTWARE.
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/dist/additional-methods.js +1505 −0
@@ -0,0 +1,1505 @@
1 +/*!
2 + * jQuery Validation Plugin v1.21.0
3 + *
4 + * https://jqueryvalidation.org/
5 + *
6 + * Copyright (c) 2024 Jörn Zaefferer
7 + * Released under the MIT license
8 + */
9 +(function( factory ) {
10 + if ( typeof define === "function" && define.amd ) {
11 + define( ["jquery", "./jquery.validate"], factory );
12 + } else if (typeof module === "object" && module.exports) {
13 + module.exports = factory( require( "jquery" ) );
14 + } else {
15 + factory( jQuery );
16 + }
17 +}(function( $ ) {
18 +
19 +( function() {
20 +
21 + function stripHtml( value ) {
22 +
23 + // Remove html tags and space chars
24 + return value.replace( /<.[^<>]*?>/g, " " ).replace( /&nbsp;|&#160;/gi, " " )
25 +
26 + // Remove punctuation
27 + .replace( /[.(),;:!?%#$'\"_+=\/\-“”’]*/g, "" );
28 + }
29 +
30 + $.validator.addMethod( "maxWords", function( value, element, params ) {
31 + return this.optional( element ) || stripHtml( value ).match( /\b\w+\b/g ).length <= params;
32 + }, $.validator.format( "Please enter {0} words or less." ) );
33 +
34 + $.validator.addMethod( "minWords", function( value, element, params ) {
35 + return this.optional( element ) || stripHtml( value ).match( /\b\w+\b/g ).length >= params;
36 + }, $.validator.format( "Please enter at least {0} words." ) );
37 +
38 + $.validator.addMethod( "rangeWords", function( value, element, params ) {
39 + var valueStripped = stripHtml( value ),
40 + regex = /\b\w+\b/g;
41 + return this.optional( element ) || valueStripped.match( regex ).length >= params[ 0 ] && valueStripped.match( regex ).length <= params[ 1 ];
42 + }, $.validator.format( "Please enter between {0} and {1} words." ) );
43 +
44 +}() );
45 +
46 +/**
47 + * This is used in the United States to process payments, deposits,
48 + * or transfers using the Automated Clearing House (ACH) or Fedwire
49 + * systems. A very common use case would be to validate a form for
50 + * an ACH bill payment.
51 + */
52 +$.validator.addMethod( "abaRoutingNumber", function( value ) {
53 + var checksum = 0;
54 + var tokens = value.split( "" );
55 + var length = tokens.length;
56 +
57 + // Length Check
58 + if ( length !== 9 ) {
59 + return false;
60 + }
61 +
62 + // Calc the checksum
63 + // https://en.wikipedia.org/wiki/ABA_routing_transit_number
64 + for ( var i = 0; i < length; i += 3 ) {
65 + checksum += parseInt( tokens[ i ], 10 ) * 3 +
66 + parseInt( tokens[ i + 1 ], 10 ) * 7 +
67 + parseInt( tokens[ i + 2 ], 10 );
68 + }
69 +
70 + // If not zero and divisible by 10 then valid
71 + if ( checksum !== 0 && checksum % 10 === 0 ) {
72 + return true;
73 + }
74 +
75 + return false;
76 +}, "Please enter a valid routing number." );
77 +
78 +// Accept a value from a file input based on a required mimetype
79 +$.validator.addMethod( "accept", function( value, element, param ) {
80 +
81 + // Split mime on commas in case we have multiple types we can accept
82 + var typeParam = typeof param === "string" ? param.replace( /\s/g, "" ) : "image/*",
83 + optionalValue = this.optional( element ),
84 + i, file, regex;
85 +
86 + // Element is optional
87 + if ( optionalValue ) {
88 + return optionalValue;
89 + }
90 +
91 + if ( $( element ).attr( "type" ) === "file" ) {
92 +
93 + // Escape string to be used in the regex
94 + // see: https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
95 + // Escape also "/*" as "/.*" as a wildcard
96 + typeParam = typeParam
97 + .replace( /[\-\[\]\/\{\}\(\)\+\?\.\\\^\$\|]/g, "\\$&" )
98 + .replace( /,/g, "|" )
99 + .replace( /\/\*/g, "/.*" );
100 +
101 + // Check if the element has a FileList before checking each file
102 + if ( element.files && element.files.length ) {
103 + regex = new RegExp( ".?(" + typeParam + ")$", "i" );
104 + for ( i = 0; i < element.files.length; i++ ) {
105 + file = element.files[ i ];
106 +
107 + // Grab the mimetype from the loaded file, verify it matches
108 + if ( !file.type.match( regex ) ) {
109 + return false;
110 + }
111 + }
112 + }
113 + }
114 +
115 + // Either return true because we've validated each file, or because the
116 + // browser does not support element.files and the FileList feature
117 + return true;
118 +}, $.validator.format( "Please enter a value with a valid mimetype." ) );
119 +
120 +$.validator.addMethod( "alphanumeric", function( value, element ) {
121 + return this.optional( element ) || /^\w+$/i.test( value );
122 +}, "Letters, numbers, and underscores only please." );
123 +
124 +/*
125 + * Dutch bank account numbers (not 'giro' numbers) have 9 digits
126 + * and pass the '11 check'.
127 + * We accept the notation with spaces, as that is common.
128 + * acceptable: 123456789 or 12 34 56 789
129 + */
130 +$.validator.addMethod( "bankaccountNL", function( value, element ) {
131 + if ( this.optional( element ) ) {
132 + return true;
133 + }
134 + if ( !( /^[0-9]{9}|([0-9]{2} ){3}[0-9]{3}$/.test( value ) ) ) {
135 + return false;
136 + }
137 +
138 + // Now '11 check'
139 + var account = value.replace( / /g, "" ), // Remove spaces
140 + sum = 0,
141 + len = account.length,
142 + pos, factor, digit;
143 + for ( pos = 0; pos < len; pos++ ) {
144 + factor = len - pos;
145 + digit = account.substring( pos, pos + 1 );
146 + sum = sum + factor * digit;
147 + }
148 + return sum % 11 === 0;
149 +}, "Please specify a valid bank account number." );
150 +
151 +$.validator.addMethod( "bankorgiroaccountNL", function( value, element ) {
152 + return this.optional( element ) ||
153 + ( $.validator.methods.bankaccountNL.call( this, value, element ) ) ||
154 + ( $.validator.methods.giroaccountNL.call( this, value, element ) );
155 +}, "Please specify a valid bank or giro account number." );
156 +
157 +/**
158 + * BIC is the business identifier code (ISO 9362). This BIC check is not a guarantee for authenticity.
159 + *
160 + * BIC pattern: BBBBCCLLbbb (8 or 11 characters long; bbb is optional)
161 + *
162 + * Validation is case-insensitive. Please make sure to normalize input yourself.
163 + *
164 + * BIC definition in detail:
165 + * - First 4 characters - bank code (only letters)
166 + * - Next 2 characters - ISO 3166-1 alpha-2 country code (only letters)
167 + * - Next 2 characters - location code (letters and digits)
168 + * a. shall not start with '0' or '1'
169 + * b. second character must be a letter ('O' is not allowed) or digit ('0' for test (therefore not allowed), '1' denoting passive participant, '2' typically reverse-billing)
170 + * - Last 3 characters - branch code, optional (shall not start with 'X' except in case of 'XXX' for primary office) (letters and digits)
171 + */
172 +$.validator.addMethod( "bic", function( value, element ) {
173 + return this.optional( element ) || /^([A-Z]{6}[A-Z2-9][A-NP-Z1-9])(X{3}|[A-WY-Z0-9][A-Z0-9]{2})?$/.test( value.toUpperCase() );
174 +}, "Please specify a valid BIC code." );
175 +
176 +/*
177 + * Código de identificación fiscal ( CIF ) is the tax identification code for Spanish legal entities
178 + * Further rules can be found in Spanish on http://es.wikipedia.org/wiki/C%C3%B3digo_de_identificaci%C3%B3n_fiscal
179 + *
180 + * Spanish CIF structure:
181 + *
182 + * [ T ][ P ][ P ][ N ][ N ][ N ][ N ][ N ][ C ]
183 + *
184 + * Where:
185 + *
186 + * T: 1 character. Kind of Organization Letter: [ABCDEFGHJKLMNPQRSUVW]
187 + * P: 2 characters. Province.
188 + * N: 5 characters. Secuencial Number within the province.
189 + * C: 1 character. Control Digit: [0-9A-J].
190 + *
191 + * [ T ]: Kind of Organizations. Possible values:
192 + *
193 + * A. Corporations
194 + * B. LLCs
195 + * C. General partnerships
196 + * D. Companies limited partnerships
197 + * E. Communities of goods
198 + * F. Cooperative Societies
199 + * G. Associations
200 + * H. Communities of homeowners in horizontal property regime
201 + * J. Civil Societies
202 + * K. Old format
203 + * L. Old format
204 + * M. Old format
205 + * N. Nonresident entities
206 + * P. Local authorities
207 + * Q. Autonomous bodies, state or not, and the like, and congregations and religious institutions
208 + * R. Congregations and religious institutions (since 2008 ORDER EHA/451/2008)
209 + * S. Organs of State Administration and regions
210 + * V. Agrarian Transformation
211 + * W. Permanent establishments of non-resident in Spain
212 + *
213 + * [ C ]: Control Digit. It can be a number or a letter depending on T value:
214 + * [ T ] --> [ C ]
215 + * ------ ----------
216 + * A Number
217 + * B Number
218 + * E Number
219 + * H Number
220 + * K Letter
221 + * P Letter
222 + * Q Letter
223 + * S Letter
224 + *
225 + */
226 +$.validator.addMethod( "cifES", function( value, element ) {
227 + "use strict";
228 +
229 + if ( this.optional( element ) ) {
230 + return true;
231 + }
232 +
233 + var cifRegEx = new RegExp( /^([ABCDEFGHJKLMNPQRSUVW])(\d{7})([0-9A-J])$/gi );
234 + var letter = value.substring( 0, 1 ), // [ T ]
235 + number = value.substring( 1, 8 ), // [ P ][ P ][ N ][ N ][ N ][ N ][ N ]
236 + control = value.substring( 8, 9 ), // [ C ]
237 + all_sum = 0,
238 + even_sum = 0,
239 + odd_sum = 0,
240 + i, n,
241 + control_digit,
242 + control_letter;
243 +
244 + function isOdd( n ) {
245 + return n % 2 === 0;
246 + }
247 +
248 + // Quick format test
249 + if ( value.length !== 9 || !cifRegEx.test( value ) ) {
250 + return false;
251 + }
252 +
253 + for ( i = 0; i < number.length; i++ ) {
254 + n = parseInt( number[ i ], 10 );
255 +
256 + // Odd positions
257 + if ( isOdd( i ) ) {
258 +
259 + // Odd positions are multiplied first.
260 + n *= 2;
261 +
262 + // If the multiplication is bigger than 10 we need to adjust
263 + odd_sum += n < 10 ? n : n - 9;
264 +
265 + // Even positions
266 + // Just sum them
267 + } else {
268 + even_sum += n;
269 + }
270 + }
271 +
272 + all_sum = even_sum + odd_sum;
273 + control_digit = ( 10 - ( all_sum ).toString().substr( -1 ) ).toString();
274 + control_digit = parseInt( control_digit, 10 ) > 9 ? "0" : control_digit;
275 + control_letter = "JABCDEFGHI".substr( control_digit, 1 ).toString();
276 +
277 + // Control must be a digit
278 + if ( letter.match( /[ABEH]/ ) ) {
279 + return control === control_digit;
280 +
281 + // Control must be a letter
282 + } else if ( letter.match( /[KPQS]/ ) ) {
283 + return control === control_letter;
284 + }
285 +
286 + // Can be either
287 + return control === control_digit || control === control_letter;
288 +
289 +}, "Please specify a valid CIF number." );
290 +
291 +/*
292 + * Brazillian CNH number (Carteira Nacional de Habilitacao) is the License Driver number.
293 + * CNH numbers have 11 digits in total: 9 numbers followed by 2 check numbers that are being used for validation.
294 + */
295 +$.validator.addMethod( "cnhBR", function( value ) {
296 +
297 + // Removing special characters from value
298 + value = value.replace( /([~!@#$%^&*()_+=`{}\[\]\-|\\:;'<>,.\/? ])+/g, "" );
299 +
300 + // Checking value to have 11 digits only
301 + if ( value.length !== 11 ) {
302 + return false;
303 + }
304 +
305 + var sum = 0, dsc = 0, firstChar,
306 + firstCN, secondCN, i, j, v;
307 +
308 + firstChar = value.charAt( 0 );
309 +
310 + if ( new Array( 12 ).join( firstChar ) === value ) {
311 + return false;
312 + }
313 +
314 + // Step 1 - using first Check Number:
315 + for ( i = 0, j = 9, v = 0; i < 9; ++i, --j ) {
316 + sum += +( value.charAt( i ) * j );
317 + }
318 +
319 + firstCN = sum % 11;
320 + if ( firstCN >= 10 ) {
321 + firstCN = 0;
322 + dsc = 2;
323 + }
324 +
325 + sum = 0;
326 + for ( i = 0, j = 1, v = 0; i < 9; ++i, ++j ) {
327 + sum += +( value.charAt( i ) * j );
328 + }
329 +
330 + secondCN = sum % 11;
331 + if ( secondCN >= 10 ) {
332 + secondCN = 0;
333 + } else {
334 + secondCN = secondCN - dsc;
335 + }
336 +
337 + return ( String( firstCN ).concat( secondCN ) === value.substr( -2 ) );
338 +
339 +}, "Please specify a valid CNH number." );
340 +
341 +/*
342 + * Brazillian value number (Cadastrado de Pessoas Juridica).
343 + * value numbers have 14 digits in total: 12 numbers followed by 2 check numbers that are being used for validation.
344 + */
345 +$.validator.addMethod( "cnpjBR", function( value, element ) {
346 + "use strict";
347 +
348 + if ( this.optional( element ) ) {
349 + return true;
350 + }
351 +
352 + // Removing no number
353 + value = value.replace( /[^\d]+/g, "" );
354 +
355 + // Checking value to have 14 digits only
356 + if ( value.length !== 14 ) {
357 + return false;
358 + }
359 +
360 + // Elimina values invalidos conhecidos
361 + if ( value === "00000000000000" ||
362 + value === "11111111111111" ||
363 + value === "22222222222222" ||
364 + value === "33333333333333" ||
365 + value === "44444444444444" ||
366 + value === "55555555555555" ||
367 + value === "66666666666666" ||
368 + value === "77777777777777" ||
369 + value === "88888888888888" ||
370 + value === "99999999999999" ) {
371 + return false;
372 + }
373 +
374 + // Valida DVs
375 + var tamanho = ( value.length - 2 );
376 + var numeros = value.substring( 0, tamanho );
377 + var digitos = value.substring( tamanho );
378 + var soma = 0;
379 + var pos = tamanho - 7;
380 +
381 + for ( var i = tamanho; i >= 1; i-- ) {
382 + soma += numeros.charAt( tamanho - i ) * pos--;
383 + if ( pos < 2 ) {
384 + pos = 9;
385 + }
386 + }
387 +
388 + var resultado = soma % 11 < 2 ? 0 : 11 - soma % 11;
389 +
390 + if ( resultado !== parseInt( digitos.charAt( 0 ), 10 ) ) {
391 + return false;
392 + }
393 +
394 + tamanho = tamanho + 1;
395 + numeros = value.substring( 0, tamanho );
396 + soma = 0;
397 + pos = tamanho - 7;
398 +
399 + for ( var il = tamanho; il >= 1; il-- ) {
400 + soma += numeros.charAt( tamanho - il ) * pos--;
401 + if ( pos < 2 ) {
402 + pos = 9;
403 + }
404 + }
405 +
406 + resultado = soma % 11 < 2 ? 0 : 11 - soma % 11;
407 +
408 + if ( resultado !== parseInt( digitos.charAt( 1 ), 10 ) ) {
409 + return false;
410 + }
411 +
412 + return true;
413 +
414 +}, "Please specify a CNPJ value number." );
415 +
416 +/*
417 + * Brazillian CPF number (Cadastrado de Pessoas Físicas) is the equivalent of a Brazilian tax registration number.
418 + * CPF numbers have 11 digits in total: 9 numbers followed by 2 check numbers that are being used for validation.
419 + */
420 +$.validator.addMethod( "cpfBR", function( value, element ) {
421 + "use strict";
422 +
423 + if ( this.optional( element ) ) {
424 + return true;
425 + }
426 +
427 + // Removing special characters from value
428 + value = value.replace( /([~!@#$%^&*()_+=`{}\[\]\-|\\:;'<>,.\/? ])+/g, "" );
429 +
430 + // Checking value to have 11 digits only
431 + if ( value.length !== 11 ) {
432 + return false;
433 + }
434 +
435 + var sum = 0,
436 + firstCN, secondCN, checkResult, i;
437 +
438 + firstCN = parseInt( value.substring( 9, 10 ), 10 );
439 + secondCN = parseInt( value.substring( 10, 11 ), 10 );
440 +
441 + checkResult = function( sum, cn ) {
442 + var result = ( sum * 10 ) % 11;
443 + if ( ( result === 10 ) || ( result === 11 ) ) {
444 + result = 0;
445 + }
446 + return ( result === cn );
447 + };
448 +
449 + // Checking for dump data
450 + if ( value === "" ||
451 + value === "00000000000" ||
452 + value === "11111111111" ||
453 + value === "22222222222" ||
454 + value === "33333333333" ||
455 + value === "44444444444" ||
456 + value === "55555555555" ||
457 + value === "66666666666" ||
458 + value === "77777777777" ||
459 + value === "88888888888" ||
460 + value === "99999999999"
461 + ) {
462 + return false;
463 + }
464 +
465 + // Step 1 - using first Check Number:
466 + for ( i = 1; i <= 9; i++ ) {
467 + sum = sum + parseInt( value.substring( i - 1, i ), 10 ) * ( 11 - i );
468 + }
469 +
470 + // If first Check Number (CN) is valid, move to Step 2 - using second Check Number:
471 + if ( checkResult( sum, firstCN ) ) {
472 + sum = 0;
473 + for ( i = 1; i <= 10; i++ ) {
474 + sum = sum + parseInt( value.substring( i - 1, i ), 10 ) * ( 12 - i );
475 + }
476 + return checkResult( sum, secondCN );
477 + }
478 + return false;
479 +
480 +}, "Please specify a valid CPF number." );
481 +
482 +// https://jqueryvalidation.org/creditcard-method/
483 +// based on https://en.wikipedia.org/wiki/Luhn_algorithm
484 +$.validator.addMethod( "creditcard", function( value, element ) {
485 + if ( this.optional( element ) ) {
486 + return "dependency-mismatch";
487 + }
488 +
489 + // Accept only spaces, digits and dashes
490 + if ( /[^0-9 \-]+/.test( value ) ) {
491 + return false;
492 + }
493 +
494 + var nCheck = 0,
495 + nDigit = 0,
496 + bEven = false,
497 + n, cDigit;
498 +
499 + value = value.replace( /\D/g, "" );
500 +
501 + // Basing min and max length on
502 + // https://dev.ean.com/general-info/valid-card-types/
503 + if ( value.length < 13 || value.length > 19 ) {
504 + return false;
505 + }
506 +
507 + for ( n = value.length - 1; n >= 0; n-- ) {
508 + cDigit = value.charAt( n );
509 + nDigit = parseInt( cDigit, 10 );
510 + if ( bEven ) {
511 + if ( ( nDigit *= 2 ) > 9 ) {
512 + nDigit -= 9;
513 + }
514 + }
515 +
516 + nCheck += nDigit;
517 + bEven = !bEven;
518 + }
519 +
520 + return ( nCheck % 10 ) === 0;
521 +}, "Please enter a valid credit card number." );
522 +
523 +/* NOTICE: Modified version of Castle.Components.Validator.CreditCardValidator
524 + * Redistributed under the Apache License 2.0 at http://www.apache.org/licenses/LICENSE-2.0
525 + * Valid Types: mastercard, visa, amex, dinersclub, enroute, discover, jcb, unknown, all (overrides all other settings)
526 + */
527 +$.validator.addMethod( "creditcardtypes", function( value, element, param ) {
528 + if ( /[^0-9\-]+/.test( value ) ) {
529 + return false;
530 + }
531 +
532 + value = value.replace( /\D/g, "" );
533 +
534 + var validTypes = 0x0000;
535 +
536 + if ( param.mastercard ) {
537 + validTypes |= 0x0001;
538 + }
539 + if ( param.visa ) {
540 + validTypes |= 0x0002;
541 + }
542 + if ( param.amex ) {
543 + validTypes |= 0x0004;
544 + }
545 + if ( param.dinersclub ) {
546 + validTypes |= 0x0008;
547 + }
548 + if ( param.enroute ) {
549 + validTypes |= 0x0010;
550 + }
551 + if ( param.discover ) {
552 + validTypes |= 0x0020;
553 + }
554 + if ( param.jcb ) {
555 + validTypes |= 0x0040;
556 + }
557 + if ( param.unknown ) {
558 + validTypes |= 0x0080;
559 + }
560 + if ( param.all ) {
561 + validTypes = 0x0001 | 0x0002 | 0x0004 | 0x0008 | 0x0010 | 0x0020 | 0x0040 | 0x0080;
562 + }
563 + if ( validTypes & 0x0001 && ( /^(5[12345])/.test( value ) || /^(2[234567])/.test( value ) ) ) { // Mastercard
564 + return value.length === 16;
565 + }
566 + if ( validTypes & 0x0002 && /^(4)/.test( value ) ) { // Visa
567 + return value.length === 16;
568 + }
569 + if ( validTypes & 0x0004 && /^(3[47])/.test( value ) ) { // Amex
570 + return value.length === 15;
571 + }
572 + if ( validTypes & 0x0008 && /^(3(0[012345]|[68]))/.test( value ) ) { // Dinersclub
573 + return value.length === 14;
574 + }
575 + if ( validTypes & 0x0010 && /^(2(014|149))/.test( value ) ) { // Enroute
576 + return value.length === 15;
577 + }
578 + if ( validTypes & 0x0020 && /^(6011)/.test( value ) ) { // Discover
579 + return value.length === 16;
580 + }
581 + if ( validTypes & 0x0040 && /^(3)/.test( value ) ) { // Jcb
582 + return value.length === 16;
583 + }
584 + if ( validTypes & 0x0040 && /^(2131|1800)/.test( value ) ) { // Jcb
585 + return value.length === 15;
586 + }
587 + if ( validTypes & 0x0080 ) { // Unknown
588 + return true;
589 + }
590 + return false;
591 +}, "Please enter a valid credit card number." );
592 +
593 +/**
594 + * Validates currencies with any given symbols by @jameslouiz
595 + * Symbols can be optional or required. Symbols required by default
596 + *
597 + * Usage examples:
598 + * currency: ["£", false] - Use false for soft currency validation
599 + * currency: ["$", false]
600 + * currency: ["RM", false] - also works with text based symbols such as "RM" - Malaysia Ringgit etc
601 + *
602 + * <input class="currencyInput" name="currencyInput">
603 + *
604 + * Soft symbol checking
605 + * currencyInput: {
606 + * currency: ["$", false]
607 + * }
608 + *
609 + * Strict symbol checking (default)
610 + * currencyInput: {
611 + * currency: "$"
612 + * //OR
613 + * currency: ["$", true]
614 + * }
615 + *
616 + * Multiple Symbols
617 + * currencyInput: {
618 + * currency: "$,£,¢"
619 + * }
620 + */
621 +$.validator.addMethod( "currency", function( value, element, param ) {
622 + var isParamString = typeof param === "string",
623 + symbol = isParamString ? param : param[ 0 ],
624 + soft = isParamString ? true : param[ 1 ],
625 + regex;
626 +
627 + symbol = symbol.replace( /,/g, "" );
628 + symbol = soft ? symbol + "]" : symbol + "]?";
629 + regex = "^[" + symbol + "([1-9]{1}[0-9]{0,2}(\\,[0-9]{3})*(\\.[0-9]{0,2})?|[1-9]{1}[0-9]{0,}(\\.[0-9]{0,2})?|0(\\.[0-9]{0,2})?|(\\.[0-9]{1,2})?)$";
630 + regex = new RegExp( regex );
631 + return this.optional( element ) || regex.test( value );
632 +
633 +}, "Please specify a valid currency." );
634 +
635 +$.validator.addMethod( "dateFA", function( value, element ) {
636 + return this.optional( element ) || /^[1-4]\d{3}\/((0?[1-6]\/((3[0-1])|([1-2][0-9])|(0?[1-9])))|((1[0-2]|(0?[7-9]))\/(30|([1-2][0-9])|(0?[1-9]))))$/.test( value );
637 +}, $.validator.messages.date );
638 +
639 +/**
640 + * Return true, if the value is a valid date, also making this formal check dd/mm/yyyy.
641 + *
642 + * @example $.validator.methods.date("01/01/1900")
643 + * @result true
644 + *
645 + * @example $.validator.methods.date("01/13/1990")
646 + * @result false
647 + *
648 + * @example $.validator.methods.date("01.01.1900")
649 + * @result false
650 + *
651 + * @example <input name="pippo" class="{dateITA:true}" />
652 + * @desc Declares an optional input element whose value must be a valid date.
653 + *
654 + * @name $.validator.methods.dateITA
655 + * @type Boolean
656 + * @cat Plugins/Validate/Methods
657 + */
658 +$.validator.addMethod( "dateITA", function( value, element ) {
659 + var check = false,
660 + re = /^\d{1,2}\/\d{1,2}\/\d{4}$/,
661 + adata, gg, mm, aaaa, xdata;
662 + if ( re.test( value ) ) {
663 + adata = value.split( "/" );
664 + gg = parseInt( adata[ 0 ], 10 );
665 + mm = parseInt( adata[ 1 ], 10 );
666 + aaaa = parseInt( adata[ 2 ], 10 );
667 + xdata = new Date( Date.UTC( aaaa, mm - 1, gg, 12, 0, 0, 0 ) );
668 + if ( ( xdata.getUTCFullYear() === aaaa ) && ( xdata.getUTCMonth() === mm - 1 ) && ( xdata.getUTCDate() === gg ) ) {
669 + check = true;
670 + } else {
671 + check = false;
672 + }
673 + } else {
674 + check = false;
675 + }
676 + return this.optional( element ) || check;
677 +}, $.validator.messages.date );
678 +
679 +$.validator.addMethod( "dateNL", function( value, element ) {
680 + return this.optional( element ) || /^(0?[1-9]|[12]\d|3[01])[\.\/\-](0?[1-9]|1[012])[\.\/\-]([12]\d)?(\d\d)$/.test( value );
681 +}, $.validator.messages.date );
682 +
683 +// Older "accept" file extension method. Old docs: http://docs.jquery.com/Plugins/Validation/Methods/accept
684 +$.validator.addMethod( "extension", function( value, element, param ) {
685 + param = typeof param === "string" ? param.replace( /,/g, "|" ) : "png|jpe?g|gif";
686 + return this.optional( element ) || value.match( new RegExp( "\\.(" + param + ")$", "i" ) );
687 +}, $.validator.format( "Please enter a value with a valid extension." ) );
688 +
689 +/**
690 + * Dutch giro account numbers (not bank numbers) have max 7 digits
691 + */
692 +$.validator.addMethod( "giroaccountNL", function( value, element ) {
693 + return this.optional( element ) || /^[0-9]{1,7}$/.test( value );
694 +}, "Please specify a valid giro account number." );
695 +
696 +$.validator.addMethod( "greaterThan", function( value, element, param ) {
697 + var target = $( param );
698 +
699 + if ( this.settings.onfocusout && target.not( ".validate-greaterThan-blur" ).length ) {
700 + target.addClass( "validate-greaterThan-blur" ).on( "blur.validate-greaterThan", function() {
701 + $( element ).valid();
702 + } );
703 + }
704 +
705 + return value > target.val();
706 +}, "Please enter a greater value." );
707 +
708 +$.validator.addMethod( "greaterThanEqual", function( value, element, param ) {
709 + var target = $( param );
710 +
711 + if ( this.settings.onfocusout && target.not( ".validate-greaterThanEqual-blur" ).length ) {
712 + target.addClass( "validate-greaterThanEqual-blur" ).on( "blur.validate-greaterThanEqual", function() {
713 + $( element ).valid();
714 + } );
715 + }
716 +
717 + return value >= target.val();
718 +}, "Please enter a greater value." );
719 +
720 +/**
721 + * IBAN is the international bank account number.
722 + * It has a country - specific format, that is checked here too
723 + *
724 + * Validation is case-insensitive. Please make sure to normalize input yourself.
725 + */
726 +$.validator.addMethod( "iban", function( value, element ) {
727 +
728 + // Some quick simple tests to prevent needless work
729 + if ( this.optional( element ) ) {
730 + return true;
731 + }
732 +
733 + // Remove spaces and to upper case
734 + var iban = value.replace( / /g, "" ).toUpperCase(),
735 + ibancheckdigits = "",
736 + leadingZeroes = true,
737 + cRest = "",
738 + cOperator = "",
739 + countrycode, ibancheck, charAt, cChar, bbanpattern, bbancountrypatterns, ibanregexp, i, p;
740 +
741 + // Check for IBAN code length.
742 + // It contains:
743 + // country code ISO 3166-1 - two letters,
744 + // two check digits,
745 + // Basic Bank Account Number (BBAN) - up to 30 chars
746 + var minimalIBANlength = 5;
747 + if ( iban.length < minimalIBANlength ) {
748 + return false;
749 + }
750 +
751 + // Check the country code and find the country specific format
752 + countrycode = iban.substring( 0, 2 );
753 + bbancountrypatterns = {
754 + "AL": "\\d{8}[\\dA-Z]{16}",
755 + "AD": "\\d{8}[\\dA-Z]{12}",
756 + "AT": "\\d{16}",
757 + "AZ": "[\\dA-Z]{4}\\d{20}",
758 + "BE": "\\d{12}",
759 + "BH": "[A-Z]{4}[\\dA-Z]{14}",
760 + "BA": "\\d{16}",
761 + "BR": "\\d{23}[A-Z][\\dA-Z]",
762 + "BG": "[A-Z]{4}\\d{6}[\\dA-Z]{8}",
763 + "CR": "\\d{17}",
764 + "HR": "\\d{17}",
765 + "CY": "\\d{8}[\\dA-Z]{16}",
766 + "CZ": "\\d{20}",
767 + "DK": "\\d{14}",
768 + "DO": "[A-Z]{4}\\d{20}",
769 + "EE": "\\d{16}",
770 + "FO": "\\d{14}",
771 + "FI": "\\d{14}",
772 + "FR": "\\d{10}[\\dA-Z]{11}\\d{2}",
773 + "GE": "[\\dA-Z]{2}\\d{16}",
774 + "DE": "\\d{18}",
775 + "GI": "[A-Z]{4}[\\dA-Z]{15}",
776 + "GR": "\\d{7}[\\dA-Z]{16}",
777 + "GL": "\\d{14}",
778 + "GT": "[\\dA-Z]{4}[\\dA-Z]{20}",
779 + "HU": "\\d{24}",
780 + "IS": "\\d{22}",
781 + "IE": "[\\dA-Z]{4}\\d{14}",
782 + "IL": "\\d{19}",
783 + "IT": "[A-Z]\\d{10}[\\dA-Z]{12}",
784 + "KZ": "\\d{3}[\\dA-Z]{13}",
785 + "KW": "[A-Z]{4}[\\dA-Z]{22}",
786 + "LV": "[A-Z]{4}[\\dA-Z]{13}",
787 + "LB": "\\d{4}[\\dA-Z]{20}",
788 + "LI": "\\d{5}[\\dA-Z]{12}",
789 + "LT": "\\d{16}",
790 + "LU": "\\d{3}[\\dA-Z]{13}",
791 + "MK": "\\d{3}[\\dA-Z]{10}\\d{2}",
792 + "MT": "[A-Z]{4}\\d{5}[\\dA-Z]{18}",
793 + "MR": "\\d{23}",
794 + "MU": "[A-Z]{4}\\d{19}[A-Z]{3}",
795 + "MC": "\\d{10}[\\dA-Z]{11}\\d{2}",
796 + "MD": "[\\dA-Z]{2}\\d{18}",
797 + "ME": "\\d{18}",
798 + "NL": "[A-Z]{4}\\d{10}",
799 + "NO": "\\d{11}",
800 + "PK": "[\\dA-Z]{4}\\d{16}",
801 + "PS": "[\\dA-Z]{4}\\d{21}",
802 + "PL": "\\d{24}",
803 + "PT": "\\d{21}",
804 + "RO": "[A-Z]{4}[\\dA-Z]{16}",
805 + "SM": "[A-Z]\\d{10}[\\dA-Z]{12}",
806 + "SA": "\\d{2}[\\dA-Z]{18}",
807 + "RS": "\\d{18}",
808 + "SK": "\\d{20}",
809 + "SI": "\\d{15}",
810 + "ES": "\\d{20}",
811 + "SE": "\\d{20}",
812 + "CH": "\\d{5}[\\dA-Z]{12}",
813 + "TN": "\\d{20}",
814 + "TR": "\\d{5}[\\dA-Z]{17}",
815 + "AE": "\\d{3}\\d{16}",
816 + "GB": "[A-Z]{4}\\d{14}",
817 + "VG": "[\\dA-Z]{4}\\d{16}"
818 + };
819 +
820 + bbanpattern = bbancountrypatterns[ countrycode ];
821 +
822 + // As new countries will start using IBAN in the
823 + // future, we only check if the countrycode is known.
824 + // This prevents false negatives, while almost all
825 + // false positives introduced by this, will be caught
826 + // by the checksum validation below anyway.
827 + // Strict checking should return FALSE for unknown
828 + // countries.
829 + if ( typeof bbanpattern !== "undefined" ) {
830 + ibanregexp = new RegExp( "^[A-Z]{2}\\d{2}" + bbanpattern + "$", "" );
831 + if ( !( ibanregexp.test( iban ) ) ) {
832 + return false; // Invalid country specific format
833 + }
834 + }
835 +
836 + // Now check the checksum, first convert to digits
837 + ibancheck = iban.substring( 4, iban.length ) + iban.substring( 0, 4 );
838 + for ( i = 0; i < ibancheck.length; i++ ) {
839 + charAt = ibancheck.charAt( i );
840 + if ( charAt !== "0" ) {
841 + leadingZeroes = false;
842 + }
843 + if ( !leadingZeroes ) {
844 + ibancheckdigits += "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".indexOf( charAt );
845 + }
846 + }
847 +
848 + // Calculate the result of: ibancheckdigits % 97
849 + for ( p = 0; p < ibancheckdigits.length; p++ ) {
850 + cChar = ibancheckdigits.charAt( p );
851 + cOperator = "" + cRest + "" + cChar;
852 + cRest = cOperator % 97;
853 + }
854 + return cRest === 1;
855 +}, "Please specify a valid IBAN." );
856 +
857 +$.validator.addMethod( "integer", function( value, element ) {
858 + return this.optional( element ) || /^-?\d+$/.test( value );
859 +}, "A positive or negative non-decimal number please." );
860 +
861 +$.validator.addMethod( "ipv4", function( value, element ) {
862 + return this.optional( element ) || /^(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)$/i.test( value );
863 +}, "Please enter a valid IP v4 address." );
864 +
865 +$.validator.addMethod( "ipv6", function( value, element ) {
866 + return this.optional( element ) || /^((([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){6}:[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){5}:([0-9A-Fa-f]{1,4}:)?[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){4}:([0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){3}:([0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){2}:([0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){6}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|(([0-9A-Fa-f]{1,4}:){0,5}:((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|(::([0-9A-Fa-f]{1,4}:){0,5}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|([0-9A-Fa-f]{1,4}::([0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})|(::([0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){1,7}:))$/i.test( value );
867 +}, "Please enter a valid IP v6 address." );
868 +
869 +$.validator.addMethod( "lessThan", function( value, element, param ) {
870 + var target = $( param );
871 +
872 + if ( this.settings.onfocusout && target.not( ".validate-lessThan-blur" ).length ) {
873 + target.addClass( "validate-lessThan-blur" ).on( "blur.validate-lessThan", function() {
874 + $( element ).valid();
875 + } );
876 + }
877 +
878 + return value < target.val();
879 +}, "Please enter a lesser value." );
880 +
881 +$.validator.addMethod( "lessThanEqual", function( value, element, param ) {
882 + var target = $( param );
883 +
884 + if ( this.settings.onfocusout && target.not( ".validate-lessThanEqual-blur" ).length ) {
885 + target.addClass( "validate-lessThanEqual-blur" ).on( "blur.validate-lessThanEqual", function() {
886 + $( element ).valid();
887 + } );
888 + }
889 +
890 + return value <= target.val();
891 +}, "Please enter a lesser value." );
892 +
893 +$.validator.addMethod( "lettersonly", function( value, element ) {
894 + return this.optional( element ) || /^[a-z]+$/i.test( value );
895 +}, "Letters only please." );
896 +
897 +$.validator.addMethod( "letterswithbasicpunc", function( value, element ) {
898 + return this.optional( element ) || /^[a-z\-.,()'"\s]+$/i.test( value );
899 +}, "Letters or punctuation only please." );
900 +
901 +// Limit the number of files in a FileList.
902 +$.validator.addMethod( "maxfiles", function( value, element, param ) {
903 + if ( this.optional( element ) ) {
904 + return true;
905 + }
906 +
907 + if ( $( element ).attr( "type" ) === "file" ) {
908 + if ( element.files && element.files.length > param ) {
909 + return false;
910 + }
911 + }
912 +
913 + return true;
914 +}, $.validator.format( "Please select no more than {0} files." ) );
915 +
916 +// Limit the size of each individual file in a FileList.
917 +$.validator.addMethod( "maxsize", function( value, element, param ) {
918 + if ( this.optional( element ) ) {
919 + return true;
920 + }
921 +
922 + if ( $( element ).attr( "type" ) === "file" ) {
923 + if ( element.files && element.files.length ) {
924 + for ( var i = 0; i < element.files.length; i++ ) {
925 + if ( element.files[ i ].size > param ) {
926 + return false;
927 + }
928 + }
929 + }
930 + }
931 +
932 + return true;
933 +}, $.validator.format( "File size must not exceed {0} bytes each." ) );
934 +
935 +// Limit the size of all files in a FileList.
936 +$.validator.addMethod( "maxsizetotal", function( value, element, param ) {
937 + if ( this.optional( element ) ) {
938 + return true;
939 + }
940 +
941 + if ( $( element ).attr( "type" ) === "file" ) {
942 + if ( element.files && element.files.length ) {
943 + var totalSize = 0;
944 +
945 + for ( var i = 0; i < element.files.length; i++ ) {
946 + totalSize += element.files[ i ].size;
947 + if ( totalSize > param ) {
948 + return false;
949 + }
950 + }
951 + }
952 + }
953 +
954 + return true;
955 +}, $.validator.format( "Total size of all files must not exceed {0} bytes." ) );
956 +
957 +
958 +$.validator.addMethod( "mobileNL", function( value, element ) {
959 + return this.optional( element ) || /^((\+|00(\s|\s?\-\s?)?)31(\s|\s?\-\s?)?(\(0\)[\-\s]?)?|0)6((\s|\s?\-\s?)?[0-9]){8}$/.test( value );
960 +}, "Please specify a valid mobile number." );
961 +
962 +$.validator.addMethod( "mobileRU", function( phone_number, element ) {
963 + var ruPhone_number = phone_number.replace( /\(|\)|\s+|-/g, "" );
964 + return this.optional( element ) || ruPhone_number.length > 9 && /^((\+7|7|8)+([0-9]){10})$/.test( ruPhone_number );
965 +}, "Please specify a valid mobile number." );
966 +
967 +/* For UK phone functions, do the following server side processing:
968 + * Compare original input with this RegEx pattern:
969 + * ^\(?(?:(?:00\)?[\s\-]?\(?|\+)(44)\)?[\s\-]?\(?(?:0\)?[\s\-]?\(?)?|0)([1-9]\d{1,4}\)?[\s\d\-]+)$
970 + * Extract $1 and set $prefix to '+44<space>' if $1 is '44', otherwise set $prefix to '0'
971 + * Extract $2 and remove hyphens, spaces and parentheses. Phone number is combined $prefix and $2.
972 + * A number of very detailed GB telephone number RegEx patterns can also be found at:
973 + * http://www.aa-asterisk.org.uk/index.php/Regular_Expressions_for_Validating_and_Formatting_GB_Telephone_Numbers
974 + */
975 +$.validator.addMethod( "mobileUK", function( phone_number, element ) {
976 + phone_number = phone_number.replace( /\(|\)|\s+|-/g, "" );
977 + return this.optional( element ) || phone_number.length > 9 &&
978 + phone_number.match( /^(?:(?:(?:00\s?|\+)44\s?|0)7(?:[1345789]\d{2}|624)\s?\d{3}\s?\d{3})$/ );
979 +}, "Please specify a valid mobile number." );
980 +
981 +$.validator.addMethod( "netmask", function( value, element ) {
982 + return this.optional( element ) || /^(254|252|248|240|224|192|128)\.0\.0\.0|255\.(254|252|248|240|224|192|128|0)\.0\.0|255\.255\.(254|252|248|240|224|192|128|0)\.0|255\.255\.255\.(254|252|248|240|224|192|128|0)/i.test( value );
983 +}, "Please enter a valid netmask." );
984 +
985 +/*
986 + * The NIE (Número de Identificación de Extranjero) is a Spanish tax identification number assigned by the Spanish
987 + * authorities to any foreigner.
988 + *
989 + * The NIE is the equivalent of a Spaniards Número de Identificación Fiscal (NIF) which serves as a fiscal
990 + * identification number. The CIF number (Certificado de Identificación Fiscal) is equivalent to the NIF, but applies to
991 + * companies rather than individuals. The NIE consists of an 'X' or 'Y' followed by 7 or 8 digits then another letter.
992 + */
993 +$.validator.addMethod( "nieES", function( value, element ) {
994 + "use strict";
995 +
996 + if ( this.optional( element ) ) {
997 + return true;
998 + }
999 +
1000 + var nieRegEx = new RegExp( /^[MXYZ]{1}[0-9]{7,8}[TRWAGMYFPDXBNJZSQVHLCKET]{1}$/gi );
1001 + var validChars = "TRWAGMYFPDXBNJZSQVHLCKET",
1002 + letter = value.substr( value.length - 1 ).toUpperCase(),
1003 + number;
1004 +
1005 + value = value.toString().toUpperCase();
1006 +
1007 + // Quick format test
1008 + if ( value.length > 10 || value.length < 9 || !nieRegEx.test( value ) ) {
1009 + return false;
1010 + }
1011 +
1012 + // X means same number
1013 + // Y means number + 10000000
1014 + // Z means number + 20000000
1015 + value = value.replace( /^[X]/, "0" )
1016 + .replace( /^[Y]/, "1" )
1017 + .replace( /^[Z]/, "2" );
1018 +
1019 + number = value.length === 9 ? value.substr( 0, 8 ) : value.substr( 0, 9 );
1020 +
1021 + return validChars.charAt( parseInt( number, 10 ) % 23 ) === letter;
1022 +
1023 +}, "Please specify a valid NIE number." );
1024 +
1025 +/*
1026 + * The Número de Identificación Fiscal ( NIF ) is the way tax identification used in Spain for individuals
1027 + */
1028 +$.validator.addMethod( "nifES", function( value, element ) {
1029 + "use strict";
1030 +
1031 + if ( this.optional( element ) ) {
1032 + return true;
1033 + }
1034 +
1035 + value = value.toUpperCase();
1036 +
1037 + // Basic format test
1038 + if ( !value.match( "((^[A-Z]{1}[0-9]{7}[A-Z0-9]{1}$|^[T]{1}[A-Z0-9]{8}$)|^[0-9]{8}[A-Z]{1}$)" ) ) {
1039 + return false;
1040 + }
1041 +
1042 + // Test NIF
1043 + if ( /^[0-9]{8}[A-Z]{1}$/.test( value ) ) {
1044 + return ( "TRWAGMYFPDXBNJZSQVHLCKE".charAt( value.substring( 8, 0 ) % 23 ) === value.charAt( 8 ) );
1045 + }
1046 +
1047 + // Test specials NIF (starts with K, L or M)
1048 + if ( /^[KLM]{1}/.test( value ) ) {
1049 + return ( value[ 8 ] === "TRWAGMYFPDXBNJZSQVHLCKE".charAt( value.substring( 8, 1 ) % 23 ) );
1050 + }
1051 +
1052 + return false;
1053 +
1054 +}, "Please specify a valid NIF number." );
1055 +
1056 +/*
1057 + * Numer identyfikacji podatkowej ( NIP ) is the way tax identification used in Poland for companies
1058 + */
1059 +$.validator.addMethod( "nipPL", function( value ) {
1060 + "use strict";
1061 +
1062 + value = value.replace( /[^0-9]/g, "" );
1063 +
1064 + if ( value.length !== 10 ) {
1065 + return false;
1066 + }
1067 +
1068 + var arrSteps = [ 6, 5, 7, 2, 3, 4, 5, 6, 7 ];
1069 + var intSum = 0;
1070 + for ( var i = 0; i < 9; i++ ) {
1071 + intSum += arrSteps[ i ] * value[ i ];
1072 + }
1073 + var int2 = intSum % 11;
1074 + var intControlNr = ( int2 === 10 ) ? 0 : int2;
1075 +
1076 + return ( intControlNr === parseInt( value[ 9 ], 10 ) );
1077 +}, "Please specify a valid NIP number." );
1078 +
1079 +/**
1080 + * Created for project jquery-validation.
1081 + * @Description Brazillian PIS or NIS number (Número de Identificação Social Pis ou Pasep) is the equivalent of a
1082 + * Brazilian tax registration number NIS of PIS numbers have 11 digits in total: 10 numbers followed by 1 check numbers
1083 + * that are being used for validation.
1084 + * @copyright (c) 21/08/2018 13:14, Cleiton da Silva Mendonça
1085 + * @author Cleiton da Silva Mendonça <cleiton.mendonca@gmail.com>
1086 + * @link http://gitlab.com/csmendonca Gitlab of Cleiton da Silva Mendonça
1087 + * @link http://github.com/csmendonca Github of Cleiton da Silva Mendonça
1088 + */
1089 +$.validator.addMethod( "nisBR", function( value ) {
1090 + var number;
1091 + var cn;
1092 + var sum = 0;
1093 + var dv;
1094 + var count;
1095 + var multiplier;
1096 +
1097 + // Removing special characters from value
1098 + value = value.replace( /([~!@#$%^&*()_+=`{}\[\]\-|\\:;'<>,.\/? ])+/g, "" );
1099 +
1100 + // Checking value to have 11 digits only
1101 + if ( value.length !== 11 ) {
1102 + return false;
1103 + }
1104 +
1105 + //Get check number of value
1106 + cn = parseInt( value.substring( 10, 11 ), 10 );
1107 +
1108 + //Get number with 10 digits of the value
1109 + number = parseInt( value.substring( 0, 10 ), 10 );
1110 +
1111 + for ( count = 2; count < 12; count++ ) {
1112 + multiplier = count;
1113 + if ( count === 10 ) {
1114 + multiplier = 2;
1115 + }
1116 + if ( count === 11 ) {
1117 + multiplier = 3;
1118 + }
1119 + sum += ( ( number % 10 ) * multiplier );
1120 + number = parseInt( number / 10, 10 );
1121 + }
1122 + dv = ( sum % 11 );
1123 +
1124 + if ( dv > 1 ) {
1125 + dv = ( 11 - dv );
1126 + } else {
1127 + dv = 0;
1128 + }
1129 +
1130 + if ( cn === dv ) {
1131 + return true;
1132 + } else {
1133 + return false;
1134 + }
1135 +}, "Please specify a valid NIS/PIS number." );
1136 +
1137 +$.validator.addMethod( "notEqualTo", function( value, element, param ) {
1138 + return this.optional( element ) || !$.validator.methods.equalTo.call( this, value, element, param );
1139 +}, "Please enter a different value, values must not be the same." );
1140 +
1141 +$.validator.addMethod( "nowhitespace", function( value, element ) {
1142 + return this.optional( element ) || /^\S+$/i.test( value );
1143 +}, "No white space please." );
1144 +
1145 +/**
1146 +* Return true if the field value matches the given format RegExp
1147 +*
1148 +* @example $.validator.methods.pattern("AR1004",element,/^AR\d{4}$/)
1149 +* @result true
1150 +*
1151 +* @example $.validator.methods.pattern("BR1004",element,/^AR\d{4}$/)
1152 +* @result false
1153 +*
1154 +* @name $.validator.methods.pattern
1155 +* @type Boolean
1156 +* @cat Plugins/Validate/Methods
1157 +*/
1158 +$.validator.addMethod( "pattern", function( value, element, param ) {
1159 + if ( this.optional( element ) ) {
1160 + return true;
1161 + }
1162 + if ( typeof param === "string" ) {
1163 + param = new RegExp( "^(?:" + param + ")$" );
1164 + }
1165 + return param.test( value );
1166 +}, "Invalid format." );
1167 +
1168 +/**
1169 + * Dutch phone numbers have 10 digits (or 11 and start with +31).
1170 + */
1171 +$.validator.addMethod( "phoneNL", function( value, element ) {
1172 + return this.optional( element ) || /^((\+|00(\s|\s?\-\s?)?)31(\s|\s?\-\s?)?(\(0\)[\-\s]?)?|0)[1-9]((\s|\s?\-\s?)?[0-9]){8}$/.test( value );
1173 +}, "Please specify a valid phone number." );
1174 +
1175 +/**
1176 + * Polish telephone numbers have 9 digits.
1177 + *
1178 + * Mobile phone numbers starts with following digits:
1179 + * 45, 50, 51, 53, 57, 60, 66, 69, 72, 73, 78, 79, 88.
1180 + *
1181 + * Fixed-line numbers starts with area codes:
1182 + * 12, 13, 14, 15, 16, 17, 18, 22, 23, 24, 25, 29, 32, 33,
1183 + * 34, 41, 42, 43, 44, 46, 48, 52, 54, 55, 56, 58, 59, 61,
1184 + * 62, 63, 65, 67, 68, 71, 74, 75, 76, 77, 81, 82, 83, 84,
1185 + * 85, 86, 87, 89, 91, 94, 95.
1186 + *
1187 + * Ministry of National Defence numbers and VoIP numbers starts with 26 and 39.
1188 + *
1189 + * Excludes intelligent networks (premium rate, shared cost, free phone numbers).
1190 + *
1191 + * Poland National Numbering Plan http://www.itu.int/oth/T02020000A8/en
1192 + */
1193 +$.validator.addMethod( "phonePL", function( phone_number, element ) {
1194 + phone_number = phone_number.replace( /\s+/g, "" );
1195 + var regexp = /^(?:(?:(?:\+|00)?48)|(?:\(\+?48\)))?(?:1[2-8]|2[2-69]|3[2-49]|4[1-68]|5[0-9]|6[0-35-9]|[7-8][1-9]|9[145])\d{7}$/;
1196 + return this.optional( element ) || regexp.test( phone_number );
1197 +}, "Please specify a valid phone number." );
1198 +
1199 +/* For UK phone functions, do the following server side processing:
1200 + * Compare original input with this RegEx pattern:
1201 + * ^\(?(?:(?:00\)?[\s\-]?\(?|\+)(44)\)?[\s\-]?\(?(?:0\)?[\s\-]?\(?)?|0)([1-9]\d{1,4}\)?[\s\d\-]+)$
1202 + * Extract $1 and set $prefix to '+44<space>' if $1 is '44', otherwise set $prefix to '0'
1203 + * Extract $2 and remove hyphens, spaces and parentheses. Phone number is combined $prefix and $2.
1204 + * A number of very detailed GB telephone number RegEx patterns can also be found at:
1205 + * http://www.aa-asterisk.org.uk/index.php/Regular_Expressions_for_Validating_and_Formatting_GB_Telephone_Numbers
1206 + */
1207 +
1208 +// Matches UK landline + mobile, accepting only 01-3 for landline or 07 for mobile to exclude many premium numbers
1209 +$.validator.addMethod( "phonesUK", function( phone_number, element ) {
1210 + phone_number = phone_number.replace( /\(|\)|\s+|-/g, "" );
1211 + return this.optional( element ) || phone_number.length > 9 &&
1212 + phone_number.match( /^(?:(?:(?:00\s?|\+)44\s?|0)(?:1\d{8,9}|[23]\d{9}|7(?:[1345789]\d{8}|624\d{6})))$/ );
1213 +}, "Please specify a valid uk phone number." );
1214 +
1215 +/* For UK phone functions, do the following server side processing:
1216 + * Compare original input with this RegEx pattern:
1217 + * ^\(?(?:(?:00\)?[\s\-]?\(?|\+)(44)\)?[\s\-]?\(?(?:0\)?[\s\-]?\(?)?|0)([1-9]\d{1,4}\)?[\s\d\-]+)$
1218 + * Extract $1 and set $prefix to '+44<space>' if $1 is '44', otherwise set $prefix to '0'
1219 + * Extract $2 and remove hyphens, spaces and parentheses. Phone number is combined $prefix and $2.
1220 + * A number of very detailed GB telephone number RegEx patterns can also be found at:
1221 + * http://www.aa-asterisk.org.uk/index.php/Regular_Expressions_for_Validating_and_Formatting_GB_Telephone_Numbers
1222 + */
1223 +$.validator.addMethod( "phoneUK", function( phone_number, element ) {
1224 + phone_number = phone_number.replace( /\(|\)|\s+|-/g, "" );
1225 + return this.optional( element ) || phone_number.length > 9 &&
1226 + phone_number.match( /^(?:(?:(?:00\s?|\+)44\s?)|(?:\(?0))(?:\d{2}\)?\s?\d{4}\s?\d{4}|\d{3}\)?\s?\d{3}\s?\d{3,4}|\d{4}\)?\s?(?:\d{5}|\d{3}\s?\d{3})|\d{5}\)?\s?\d{4,5})$/ );
1227 +}, "Please specify a valid phone number." );
1228 +
1229 +/**
1230 + * Matches US phone number format
1231 + *
1232 + * where the area code may not start with 1 and the prefix may not start with 1
1233 + * allows '-' or ' ' as a separator and allows parens around area code
1234 + * some people may want to put a '1' in front of their number
1235 + *
1236 + * 1(212)-999-2345 or
1237 + * 212 999 2344 or
1238 + * 212-999-0983
1239 + *
1240 + * but not
1241 + * 111-123-5434
1242 + * and not
1243 + * 212 123 4567
1244 + */
1245 +$.validator.addMethod( "phoneUS", function( phone_number, element ) {
1246 + phone_number = phone_number.replace( /\s+/g, "" );
1247 + return this.optional( element ) || phone_number.length > 9 &&
1248 + phone_number.match( /^(\+?1-?)?(\([2-9]([02-9]\d|1[02-9])\)|[2-9]([02-9]\d|1[02-9]))-?[2-9]\d{2}-?\d{4}$/ );
1249 +}, "Please specify a valid phone number." );
1250 +
1251 +/*
1252 +* Valida CEPs do brasileiros:
1253 +*
1254 +* Formatos aceitos:
1255 +* 99999-999
1256 +* 99.999-999
1257 +* 99999999
1258 +*/
1259 +$.validator.addMethod( "postalcodeBR", function( cep_value, element ) {
1260 + return this.optional( element ) || /^\d{2}.\d{3}-\d{3}?$|^\d{5}-?\d{3}?$/.test( cep_value );
1261 +}, "Informe um CEP válido." );
1262 +
1263 +/**
1264 + * Matches a valid Canadian Postal Code
1265 + *
1266 + * @example jQuery.validator.methods.postalCodeCA( "H0H 0H0", element )
1267 + * @result true
1268 + *
1269 + * @example jQuery.validator.methods.postalCodeCA( "H0H0H0", element )
1270 + * @result false
1271 + *
1272 + * @name jQuery.validator.methods.postalCodeCA
1273 + * @type Boolean
1274 + * @cat Plugins/Validate/Methods
1275 + */
1276 +$.validator.addMethod( "postalCodeCA", function( value, element ) {
1277 + return this.optional( element ) || /^[ABCEGHJKLMNPRSTVXY]\d[ABCEGHJKLMNPRSTVWXYZ] *\d[ABCEGHJKLMNPRSTVWXYZ]\d$/i.test( value );
1278 +}, "Please specify a valid postal code." );
1279 +
1280 +/* Matches Italian postcode (CAP) */
1281 +$.validator.addMethod( "postalcodeIT", function( value, element ) {
1282 + return this.optional( element ) || /^\d{5}$/.test( value );
1283 +}, "Please specify a valid postal code." );
1284 +
1285 +$.validator.addMethod( "postalcodeNL", function( value, element ) {
1286 + return this.optional( element ) || /^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/.test( value );
1287 +}, "Please specify a valid postal code." );
1288 +
1289 +// Matches UK postcode. Does not match to UK Channel Islands that have their own postcodes (non standard UK)
1290 +$.validator.addMethod( "postcodeUK", function( value, element ) {
1291 + return this.optional( element ) || /^((([A-PR-UWYZ][0-9])|([A-PR-UWYZ][0-9][0-9])|([A-PR-UWYZ][A-HK-Y][0-9])|([A-PR-UWYZ][A-HK-Y][0-9][0-9])|([A-PR-UWYZ][0-9][A-HJKSTUW])|([A-PR-UWYZ][A-HK-Y][0-9][ABEHMNPRVWXY]))\s?([0-9][ABD-HJLNP-UW-Z]{2})|(GIR)\s?(0AA))$/i.test( value );
1292 +}, "Please specify a valid UK postcode." );
1293 +
1294 +/*
1295 + * Lets you say "at least X inputs that match selector Y must be filled."
1296 + *
1297 + * The end result is that neither of these inputs:
1298 + *
1299 + * <input class="productinfo" name="partnumber">
1300 + * <input class="productinfo" name="description">
1301 + *
1302 + * ...will validate unless at least one of them is filled.
1303 + *
1304 + * partnumber: {require_from_group: [1,".productinfo"]},
1305 + * description: {require_from_group: [1,".productinfo"]}
1306 + *
1307 + * options[0]: number of fields that must be filled in the group
1308 + * options[1]: CSS selector that defines the group of conditionally required fields
1309 + */
1310 +$.validator.addMethod( "require_from_group", function( value, element, options ) {
1311 + var $fields = $( options[ 1 ], element.form ),
1312 + $fieldsFirst = $fields.eq( 0 ),
1313 + validator = $fieldsFirst.data( "valid_req_grp" ) ? $fieldsFirst.data( "valid_req_grp" ) : $.extend( {}, this ),
1314 + isValid = $fields.filter( function() {
1315 + return validator.elementValue( this );
1316 + } ).length >= options[ 0 ];
1317 +
1318 + // Store the cloned validator for future validation
1319 + $fieldsFirst.data( "valid_req_grp", validator );
1320 +
1321 + // If element isn't being validated, run each require_from_group field's validation rules
1322 + if ( !$( element ).data( "being_validated" ) ) {
1323 + $fields.data( "being_validated", true );
1324 + $fields.each( function() {
1325 + validator.element( this );
1326 + } );
1327 + $fields.data( "being_validated", false );
1328 + }
1329 + return isValid;
1330 +}, $.validator.format( "Please fill at least {0} of these fields." ) );
1331 +
1332 +/*
1333 + * Lets you say "either at least X inputs that match selector Y must be filled,
1334 + * OR they must all be skipped (left blank)."
1335 + *
1336 + * The end result, is that none of these inputs:
1337 + *
1338 + * <input class="productinfo" name="partnumber">
1339 + * <input class="productinfo" name="description">
1340 + * <input class="productinfo" name="color">
1341 + *
1342 + * ...will validate unless either at least two of them are filled,
1343 + * OR none of them are.
1344 + *
1345 + * partnumber: {skip_or_fill_minimum: [2,".productinfo"]},
1346 + * description: {skip_or_fill_minimum: [2,".productinfo"]},
1347 + * color: {skip_or_fill_minimum: [2,".productinfo"]}
1348 + *
1349 + * options[0]: number of fields that must be filled in the group
1350 + * options[1]: CSS selector that defines the group of conditionally required fields
1351 + *
1352 + */
1353 +$.validator.addMethod( "skip_or_fill_minimum", function( value, element, options ) {
1354 + var $fields = $( options[ 1 ], element.form ),
1355 + $fieldsFirst = $fields.eq( 0 ),
1356 + validator = $fieldsFirst.data( "valid_skip" ) ? $fieldsFirst.data( "valid_skip" ) : $.extend( {}, this ),
1357 + numberFilled = $fields.filter( function() {
1358 + return validator.elementValue( this );
1359 + } ).length,
1360 + isValid = numberFilled === 0 || numberFilled >= options[ 0 ];
1361 +
1362 + // Store the cloned validator for future validation
1363 + $fieldsFirst.data( "valid_skip", validator );
1364 +
1365 + // If element isn't being validated, run each skip_or_fill_minimum field's validation rules
1366 + if ( !$( element ).data( "being_validated" ) ) {
1367 + $fields.data( "being_validated", true );
1368 + $fields.each( function() {
1369 + validator.element( this );
1370 + } );
1371 + $fields.data( "being_validated", false );
1372 + }
1373 + return isValid;
1374 +}, $.validator.format( "Please either skip these fields or fill at least {0} of them." ) );
1375 +
1376 +/* Validates US States and/or Territories by @jdforsythe
1377 + * Can be case insensitive or require capitalization - default is case insensitive
1378 + * Can include US Territories or not - default does not
1379 + * Can include US Military postal abbreviations (AA, AE, AP) - default does not
1380 + *
1381 + * Note: "States" always includes DC (District of Colombia)
1382 + *
1383 + * Usage examples:
1384 + *
1385 + * This is the default - case insensitive, no territories, no military zones
1386 + * stateInput: {
1387 + * caseSensitive: false,
1388 + * includeTerritories: false,
1389 + * includeMilitary: false
1390 + * }
1391 + *
1392 + * Only allow capital letters, no territories, no military zones
1393 + * stateInput: {
1394 + * caseSensitive: false
1395 + * }
1396 + *
1397 + * Case insensitive, include territories but not military zones
1398 + * stateInput: {
1399 + * includeTerritories: true
1400 + * }
1401 + *
1402 + * Only allow capital letters, include territories and military zones
1403 + * stateInput: {
1404 + * caseSensitive: true,
1405 + * includeTerritories: true,
1406 + * includeMilitary: true
1407 + * }
1408 + *
1409 + */
1410 +$.validator.addMethod( "stateUS", function( value, element, options ) {
1411 + var isDefault = typeof options === "undefined",
1412 + caseSensitive = ( isDefault || typeof options.caseSensitive === "undefined" ) ? false : options.caseSensitive,
1413 + includeTerritories = ( isDefault || typeof options.includeTerritories === "undefined" ) ? false : options.includeTerritories,
1414 + includeMilitary = ( isDefault || typeof options.includeMilitary === "undefined" ) ? false : options.includeMilitary,
1415 + regex;
1416 +
1417 + if ( !includeTerritories && !includeMilitary ) {
1418 + regex = "^(A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])$";
1419 + } else if ( includeTerritories && includeMilitary ) {
1420 + regex = "^(A[AEKLPRSZ]|C[AOT]|D[CE]|FL|G[AU]|HI|I[ADLN]|K[SY]|LA|M[ADEINOPST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])$";
1421 + } else if ( includeTerritories ) {
1422 + regex = "^(A[KLRSZ]|C[AOT]|D[CE]|FL|G[AU]|HI|I[ADLN]|K[SY]|LA|M[ADEINOPST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])$";
1423 + } else {
1424 + regex = "^(A[AEKLPRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])$";
1425 + }
1426 +
1427 + regex = caseSensitive ? new RegExp( regex ) : new RegExp( regex, "i" );
1428 + return this.optional( element ) || regex.test( value );
1429 +}, "Please specify a valid state." );
1430 +
1431 +// TODO check if value starts with <, otherwise don't try stripping anything
1432 +$.validator.addMethod( "strippedminlength", function( value, element, param ) {
1433 + return $( value ).text().length >= param;
1434 +}, $.validator.format( "Please enter at least {0} characters." ) );
1435 +
1436 +$.validator.addMethod( "time", function( value, element ) {
1437 + return this.optional( element ) || /^([01]\d|2[0-3]|[0-9])(:[0-5]\d){1,2}$/.test( value );
1438 +}, "Please enter a valid time, between 00:00 and 23:59." );
1439 +
1440 +$.validator.addMethod( "time12h", function( value, element ) {
1441 + return this.optional( element ) || /^((0?[1-9]|1[012])(:[0-5]\d){1,2}(\ ?[AP]M))$/i.test( value );
1442 +}, "Please enter a valid time in 12-hour am/pm format." );
1443 +
1444 +// Same as url, but TLD is optional
1445 +$.validator.addMethod( "url2", function( value, element ) {
1446 + return this.optional( element ) || /^(?:(?:(?:https?|ftp):)?\/\/)(?:(?:[^\]\[?\/<~#`!@$^&*()+=}|:";',>{ ]|%[0-9A-Fa-f]{2})+(?::(?:[^\]\[?\/<~#`!@$^&*()+=}|:";',>{ ]|%[0-9A-Fa-f]{2})*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?)|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff])|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62}\.)))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test( value );
1447 +}, $.validator.messages.url );
1448 +
1449 +/**
1450 + * Return true, if the value is a valid vehicle identification number (VIN).
1451 + *
1452 + * Works with all kind of text inputs.
1453 + *
1454 + * @example <input type="text" size="20" name="VehicleID" class="{required:true,vinUS:true}" />
1455 + * @desc Declares a required input element whose value must be a valid vehicle identification number.
1456 + *
1457 + * @name $.validator.methods.vinUS
1458 + * @type Boolean
1459 + * @cat Plugins/Validate/Methods
1460 + */
1461 +$.validator.addMethod( "vinUS", function( v ) {
1462 + if ( v.length !== 17 ) {
1463 + return false;
1464 + }
1465 +
1466 + var LL = [ "A", "B", "C", "D", "E", "F", "G", "H", "J", "K", "L", "M", "N", "P", "R", "S", "T", "U", "V", "W", "X", "Y", "Z" ],
1467 + VL = [ 1, 2, 3, 4, 5, 6, 7, 8, 1, 2, 3, 4, 5, 7, 9, 2, 3, 4, 5, 6, 7, 8, 9 ],
1468 + FL = [ 8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2 ],
1469 + rs = 0,
1470 + i, n, d, f, cd, cdv;
1471 +
1472 + for ( i = 0; i < 17; i++ ) {
1473 + f = FL[ i ];
1474 + d = v.slice( i, i + 1 );
1475 + if ( isNaN( d ) ) {
1476 + d = d.toUpperCase();
1477 + n = VL[ LL.indexOf( d ) ];
1478 + } else {
1479 + n = parseInt( d, 10 );
1480 + }
1481 + if ( i === 8 )
1482 + {
1483 + cdv = n;
1484 + if ( d === "X" ) {
1485 + cdv = 10;
1486 + }
1487 + }
1488 + rs += n * f;
1489 + }
1490 + cd = rs % 11;
1491 + if ( cd === cdv ) {
1492 + return true;
1493 + }
1494 + return false;
1495 +}, "The specified vehicle identification number (VIN) is invalid." );
1496 +
1497 +$.validator.addMethod( "zipcodeUS", function( value, element ) {
1498 + return this.optional( element ) || /^\d{5}(-\d{4})?$/.test( value );
1499 +}, "The specified US ZIP Code is invalid." );
1500 +
1501 +$.validator.addMethod( "ziprange", function( value, element ) {
1502 + return this.optional( element ) || /^90[2-5]\d\{2\}-\d{4}$/.test( value );
1503 +}, "Your ZIP-code must be in the range 902xx-xxxx to 905xx-xxxx." );
1504 +return $;
1505 +}));
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/dist/additional-methods.min.js +4 −0
@@ -0,0 +1,4 @@
1 +/*! jQuery Validation Plugin - v1.21.0 - 7/17/2024
2 + * https://jqueryvalidation.org/
3 + * Copyright (c) 2024 Jörn Zaefferer; Licensed MIT */
4 +!function(a){"function"==typeof define&&define.amd?define(["jquery","./jquery.validate.min"],a):"object"==typeof module&&module.exports?module.exports=a(require("jquery")):a(jQuery)}(function(a){return function(){function b(a){return a.replace(/<.[^<>]*?>/g," ").replace(/&nbsp;|&#160;/gi," ").replace(/[.(),;:!?%#$'\"_+=\/\-“”’]*/g,"")}a.validator.addMethod("maxWords",function(a,c,d){return this.optional(c)||b(a).match(/\b\w+\b/g).length<=d},a.validator.format("Please enter {0} words or less.")),a.validator.addMethod("minWords",function(a,c,d){return this.optional(c)||b(a).match(/\b\w+\b/g).length>=d},a.validator.format("Please enter at least {0} words.")),a.validator.addMethod("rangeWords",function(a,c,d){var e=b(a),f=/\b\w+\b/g;return this.optional(c)||e.match(f).length>=d[0]&&e.match(f).length<=d[1]},a.validator.format("Please enter between {0} and {1} words."))}(),a.validator.addMethod("abaRoutingNumber",function(a){var b=0,c=a.split(""),d=c.length;if(9!==d)return!1;for(var e=0;e<d;e+=3)b+=3*parseInt(c[e],10)+7*parseInt(c[e+1],10)+parseInt(c[e+2],10);return 0!==b&&b%10===0},"Please enter a valid routing number."),a.validator.addMethod("accept",function(b,c,d){var e,f,g,h="string"==typeof d?d.replace(/\s/g,""):"image/*",i=this.optional(c);if(i)return i;if("file"===a(c).attr("type")&&(h=h.replace(/[\-\[\]\/\{\}\(\)\+\?\.\\\^\$\|]/g,"\\$&").replace(/,/g,"|").replace(/\/\*/g,"/.*"),c.files&&c.files.length))for(g=new RegExp(".?("+h+")$","i"),e=0;e<c.files.length;e++)if(f=c.files[e],!f.type.match(g))return!1;return!0},a.validator.format("Please enter a value with a valid mimetype.")),a.validator.addMethod("alphanumeric",function(a,b){return this.optional(b)||/^\w+$/i.test(a)},"Letters, numbers, and underscores only please."),a.validator.addMethod("bankaccountNL",function(a,b){if(this.optional(b))return!0;if(!/^[0-9]{9}|([0-9]{2} ){3}[0-9]{3}$/.test(a))return!1;var c,d,e,f=a.replace(/ /g,""),g=0,h=f.length;for(c=0;c<h;c++)d=h-c,e=f.substring(c,c+1),g+=d*e;return g%11===0},"Please specify a valid bank account number."),a.validator.addMethod("bankorgiroaccountNL",function(b,c){return this.optional(c)||a.validator.methods.bankaccountNL.call(this,b,c)||a.validator.methods.giroaccountNL.call(this,b,c)},"Please specify a valid bank or giro account number."),a.validator.addMethod("bic",function(a,b){return this.optional(b)||/^([A-Z]{6}[A-Z2-9][A-NP-Z1-9])(X{3}|[A-WY-Z0-9][A-Z0-9]{2})?$/.test(a.toUpperCase())},"Please specify a valid BIC code."),a.validator.addMethod("cifES",function(a,b){"use strict";function c(a){return a%2===0}if(this.optional(b))return!0;var d,e,f,g,h=new RegExp(/^([ABCDEFGHJKLMNPQRSUVW])(\d{7})([0-9A-J])$/gi),i=a.substring(0,1),j=a.substring(1,8),k=a.substring(8,9),l=0,m=0,n=0;if(9!==a.length||!h.test(a))return!1;for(d=0;d<j.length;d++)e=parseInt(j[d],10),c(d)?(e*=2,n+=e<10?e:e-9):m+=e;return l=m+n,f=(10-l.toString().substr(-1)).toString(),f=parseInt(f,10)>9?"0":f,g="JABCDEFGHI".substr(f,1).toString(),i.match(/[ABEH]/)?k===f:i.match(/[KPQS]/)?k===g:k===f||k===g},"Please specify a valid CIF number."),a.validator.addMethod("cnhBR",function(a){if(a=a.replace(/([~!@#$%^&*()_+=`{}\[\]\-|\\:;'<>,.\/? ])+/g,""),11!==a.length)return!1;var b,c,d,e,f,g,h=0,i=0;if(b=a.charAt(0),new Array(12).join(b)===a)return!1;for(e=0,f=9,g=0;e<9;++e,--f)h+=+(a.charAt(e)*f);for(c=h%11,c>=10&&(c=0,i=2),h=0,e=0,f=1,g=0;e<9;++e,++f)h+=+(a.charAt(e)*f);return d=h%11,d>=10?d=0:d-=i,String(c).concat(d)===a.substr(-2)},"Please specify a valid CNH number."),a.validator.addMethod("cnpjBR",function(a,b){"use strict";if(this.optional(b))return!0;if(a=a.replace(/[^\d]+/g,""),14!==a.length)return!1;if("00000000000000"===a||"11111111111111"===a||"22222222222222"===a||"33333333333333"===a||"44444444444444"===a||"55555555555555"===a||"66666666666666"===a||"77777777777777"===a||"88888888888888"===a||"99999999999999"===a)return!1;for(var c=a.length-2,d=a.substring(0,c),e=a.substring(c),f=0,g=c-7,h=c;h>=1;h--)f+=d.charAt(c-h)*g--,g<2&&(g=9);var i=f%11<2?0:11-f%11;if(i!==parseInt(e.charAt(0),10))return!1;c+=1,d=a.substring(0,c),f=0,g=c-7;for(var j=c;j>=1;j--)f+=d.charAt(c-j)*g--,g<2&&(g=9);return i=f%11<2?0:11-f%11,i===parseInt(e.charAt(1),10)},"Please specify a CNPJ value number."),a.validator.addMethod("cpfBR",function(a,b){"use strict";if(this.optional(b))return!0;if(a=a.replace(/([~!@#$%^&*()_+=`{}\[\]\-|\\:;'<>,.\/? ])+/g,""),11!==a.length)return!1;var c,d,e,f,g=0;if(c=parseInt(a.substring(9,10),10),d=parseInt(a.substring(10,11),10),e=function(a,b){var c=10*a%11;return 10!==c&&11!==c||(c=0),c===b},""===a||"00000000000"===a||"11111111111"===a||"22222222222"===a||"33333333333"===a||"44444444444"===a||"55555555555"===a||"66666666666"===a||"77777777777"===a||"88888888888"===a||"99999999999"===a)return!1;for(f=1;f<=9;f++)g+=parseInt(a.substring(f-1,f),10)*(11-f);if(e(g,c)){for(g=0,f=1;f<=10;f++)g+=parseInt(a.substring(f-1,f),10)*(12-f);return e(g,d)}return!1},"Please specify a valid CPF number."),a.validator.addMethod("creditcard",function(a,b){if(this.optional(b))return"dependency-mismatch";if(/[^0-9 \-]+/.test(a))return!1;var c,d,e=0,f=0,g=!1;if(a=a.replace(/\D/g,""),a.length<13||a.length>19)return!1;for(c=a.length-1;c>=0;c--)d=a.charAt(c),f=parseInt(d,10),g&&(f*=2)>9&&(f-=9),e+=f,g=!g;return e%10===0},"Please enter a valid credit card number."),a.validator.addMethod("creditcardtypes",function(a,b,c){if(/[^0-9\-]+/.test(a))return!1;a=a.replace(/\D/g,"");var d=0;return c.mastercard&&(d|=1),c.visa&&(d|=2),c.amex&&(d|=4),c.dinersclub&&(d|=8),c.enroute&&(d|=16),c.discover&&(d|=32),c.jcb&&(d|=64),c.unknown&&(d|=128),c.all&&(d=255),1&d&&(/^(5[12345])/.test(a)||/^(2[234567])/.test(a))?16===a.length:2&d&&/^(4)/.test(a)?16===a.length:4&d&&/^(3[47])/.test(a)?15===a.length:8&d&&/^(3(0[012345]|[68]))/.test(a)?14===a.length:16&d&&/^(2(014|149))/.test(a)?15===a.length:32&d&&/^(6011)/.test(a)?16===a.length:64&d&&/^(3)/.test(a)?16===a.length:64&d&&/^(2131|1800)/.test(a)?15===a.length:!!(128&d)},"Please enter a valid credit card number."),a.validator.addMethod("currency",function(a,b,c){var d,e="string"==typeof c,f=e?c:c[0],g=!!e||c[1];return f=f.replace(/,/g,""),f=g?f+"]":f+"]?",d="^["+f+"([1-9]{1}[0-9]{0,2}(\\,[0-9]{3})*(\\.[0-9]{0,2})?|[1-9]{1}[0-9]{0,}(\\.[0-9]{0,2})?|0(\\.[0-9]{0,2})?|(\\.[0-9]{1,2})?)$",d=new RegExp(d),this.optional(b)||d.test(a)},"Please specify a valid currency."),a.validator.addMethod("dateFA",function(a,b){return this.optional(b)||/^[1-4]\d{3}\/((0?[1-6]\/((3[0-1])|([1-2][0-9])|(0?[1-9])))|((1[0-2]|(0?[7-9]))\/(30|([1-2][0-9])|(0?[1-9]))))$/.test(a)},a.validator.messages.date),a.validator.addMethod("dateITA",function(a,b){var c,d,e,f,g,h=!1,i=/^\d{1,2}\/\d{1,2}\/\d{4}$/;return i.test(a)?(c=a.split("/"),d=parseInt(c[0],10),e=parseInt(c[1],10),f=parseInt(c[2],10),g=new Date(Date.UTC(f,e-1,d,12,0,0,0)),h=g.getUTCFullYear()===f&&g.getUTCMonth()===e-1&&g.getUTCDate()===d):h=!1,this.optional(b)||h},a.validator.messages.date),a.validator.addMethod("dateNL",function(a,b){return this.optional(b)||/^(0?[1-9]|[12]\d|3[01])[\.\/\-](0?[1-9]|1[012])[\.\/\-]([12]\d)?(\d\d)$/.test(a)},a.validator.messages.date),a.validator.addMethod("extension",function(a,b,c){return c="string"==typeof c?c.replace(/,/g,"|"):"png|jpe?g|gif",this.optional(b)||a.match(new RegExp("\\.("+c+")$","i"))},a.validator.format("Please enter a value with a valid extension.")),a.validator.addMethod("giroaccountNL",function(a,b){return this.optional(b)||/^[0-9]{1,7}$/.test(a)},"Please specify a valid giro account number."),a.validator.addMethod("greaterThan",function(b,c,d){var e=a(d);return this.settings.onfocusout&&e.not(".validate-greaterThan-blur").length&&e.addClass("validate-greaterThan-blur").on("blur.validate-greaterThan",function(){a(c).valid()}),b>e.val()},"Please enter a greater value."),a.validator.addMethod("greaterThanEqual",function(b,c,d){var e=a(d);return this.settings.onfocusout&&e.not(".validate-greaterThanEqual-blur").length&&e.addClass("validate-greaterThanEqual-blur").on("blur.validate-greaterThanEqual",function(){a(c).valid()}),b>=e.val()},"Please enter a greater value."),a.validator.addMethod("iban",function(a,b){if(this.optional(b))return!0;var c,d,e,f,g,h,i,j,k,l=a.replace(/ /g,"").toUpperCase(),m="",n=!0,o="",p="",q=5;if(l.length<q)return!1;if(c=l.substring(0,2),h={AL:"\\d{8}[\\dA-Z]{16}",AD:"\\d{8}[\\dA-Z]{12}",AT:"\\d{16}",AZ:"[\\dA-Z]{4}\\d{20}",BE:"\\d{12}",BH:"[A-Z]{4}[\\dA-Z]{14}",BA:"\\d{16}",BR:"\\d{23}[A-Z][\\dA-Z]",BG:"[A-Z]{4}\\d{6}[\\dA-Z]{8}",CR:"\\d{17}",HR:"\\d{17}",CY:"\\d{8}[\\dA-Z]{16}",CZ:"\\d{20}",DK:"\\d{14}",DO:"[A-Z]{4}\\d{20}",EE:"\\d{16}",FO:"\\d{14}",FI:"\\d{14}",FR:"\\d{10}[\\dA-Z]{11}\\d{2}",GE:"[\\dA-Z]{2}\\d{16}",DE:"\\d{18}",GI:"[A-Z]{4}[\\dA-Z]{15}",GR:"\\d{7}[\\dA-Z]{16}",GL:"\\d{14}",GT:"[\\dA-Z]{4}[\\dA-Z]{20}",HU:"\\d{24}",IS:"\\d{22}",IE:"[\\dA-Z]{4}\\d{14}",IL:"\\d{19}",IT:"[A-Z]\\d{10}[\\dA-Z]{12}",KZ:"\\d{3}[\\dA-Z]{13}",KW:"[A-Z]{4}[\\dA-Z]{22}",LV:"[A-Z]{4}[\\dA-Z]{13}",LB:"\\d{4}[\\dA-Z]{20}",LI:"\\d{5}[\\dA-Z]{12}",LT:"\\d{16}",LU:"\\d{3}[\\dA-Z]{13}",MK:"\\d{3}[\\dA-Z]{10}\\d{2}",MT:"[A-Z]{4}\\d{5}[\\dA-Z]{18}",MR:"\\d{23}",MU:"[A-Z]{4}\\d{19}[A-Z]{3}",MC:"\\d{10}[\\dA-Z]{11}\\d{2}",MD:"[\\dA-Z]{2}\\d{18}",ME:"\\d{18}",NL:"[A-Z]{4}\\d{10}",NO:"\\d{11}",PK:"[\\dA-Z]{4}\\d{16}",PS:"[\\dA-Z]{4}\\d{21}",PL:"\\d{24}",PT:"\\d{21}",RO:"[A-Z]{4}[\\dA-Z]{16}",SM:"[A-Z]\\d{10}[\\dA-Z]{12}",SA:"\\d{2}[\\dA-Z]{18}",RS:"\\d{18}",SK:"\\d{20}",SI:"\\d{15}",ES:"\\d{20}",SE:"\\d{20}",CH:"\\d{5}[\\dA-Z]{12}",TN:"\\d{20}",TR:"\\d{5}[\\dA-Z]{17}",AE:"\\d{3}\\d{16}",GB:"[A-Z]{4}\\d{14}",VG:"[\\dA-Z]{4}\\d{16}"},g=h[c],"undefined"!=typeof g&&(i=new RegExp("^[A-Z]{2}\\d{2}"+g+"$",""),!i.test(l)))return!1;for(d=l.substring(4,l.length)+l.substring(0,4),j=0;j<d.length;j++)e=d.charAt(j),"0"!==e&&(n=!1),n||(m+="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".indexOf(e));for(k=0;k<m.length;k++)f=m.charAt(k),p=""+o+f,o=p%97;return 1===o},"Please specify a valid IBAN."),a.validator.addMethod("integer",function(a,b){return this.optional(b)||/^-?\d+$/.test(a)},"A positive or negative non-decimal number please."),a.validator.addMethod("ipv4",function(a,b){return this.optional(b)||/^(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)$/i.test(a)},"Please enter a valid IP v4 address."),a.validator.addMethod("ipv6",function(a,b){return this.optional(b)||/^((([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){6}:[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){5}:([0-9A-Fa-f]{1,4}:)?[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){4}:([0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){3}:([0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){2}:([0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){6}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|(([0-9A-Fa-f]{1,4}:){0,5}:((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|(::([0-9A-Fa-f]{1,4}:){0,5}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|([0-9A-Fa-f]{1,4}::([0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})|(::([0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){1,7}:))$/i.test(a)},"Please enter a valid IP v6 address."),a.validator.addMethod("lessThan",function(b,c,d){var e=a(d);return this.settings.onfocusout&&e.not(".validate-lessThan-blur").length&&e.addClass("validate-lessThan-blur").on("blur.validate-lessThan",function(){a(c).valid()}),b<e.val()},"Please enter a lesser value."),a.validator.addMethod("lessThanEqual",function(b,c,d){var e=a(d);return this.settings.onfocusout&&e.not(".validate-lessThanEqual-blur").length&&e.addClass("validate-lessThanEqual-blur").on("blur.validate-lessThanEqual",function(){a(c).valid()}),b<=e.val()},"Please enter a lesser value."),a.validator.addMethod("lettersonly",function(a,b){return this.optional(b)||/^[a-z]+$/i.test(a)},"Letters only please."),a.validator.addMethod("letterswithbasicpunc",function(a,b){return this.optional(b)||/^[a-z\-.,()'"\s]+$/i.test(a)},"Letters or punctuation only please."),a.validator.addMethod("maxfiles",function(b,c,d){return!!this.optional(c)||!("file"===a(c).attr("type")&&c.files&&c.files.length>d)},a.validator.format("Please select no more than {0} files.")),a.validator.addMethod("maxsize",function(b,c,d){if(this.optional(c))return!0;if("file"===a(c).attr("type")&&c.files&&c.files.length)for(var e=0;e<c.files.length;e++)if(c.files[e].size>d)return!1;return!0},a.validator.format("File size must not exceed {0} bytes each.")),a.validator.addMethod("maxsizetotal",function(b,c,d){if(this.optional(c))return!0;if("file"===a(c).attr("type")&&c.files&&c.files.length)for(var e=0,f=0;f<c.files.length;f++)if(e+=c.files[f].size,e>d)return!1;return!0},a.validator.format("Total size of all files must not exceed {0} bytes.")),a.validator.addMethod("mobileNL",function(a,b){return this.optional(b)||/^((\+|00(\s|\s?\-\s?)?)31(\s|\s?\-\s?)?(\(0\)[\-\s]?)?|0)6((\s|\s?\-\s?)?[0-9]){8}$/.test(a)},"Please specify a valid mobile number."),a.validator.addMethod("mobileRU",function(a,b){var c=a.replace(/\(|\)|\s+|-/g,"");return this.optional(b)||c.length>9&&/^((\+7|7|8)+([0-9]){10})$/.test(c)},"Please specify a valid mobile number."),a.validator.addMethod("mobileUK",function(a,b){return a=a.replace(/\(|\)|\s+|-/g,""),this.optional(b)||a.length>9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?|0)7(?:[1345789]\d{2}|624)\s?\d{3}\s?\d{3})$/)},"Please specify a valid mobile number."),a.validator.addMethod("netmask",function(a,b){return this.optional(b)||/^(254|252|248|240|224|192|128)\.0\.0\.0|255\.(254|252|248|240|224|192|128|0)\.0\.0|255\.255\.(254|252|248|240|224|192|128|0)\.0|255\.255\.255\.(254|252|248|240|224|192|128|0)/i.test(a)},"Please enter a valid netmask."),a.validator.addMethod("nieES",function(a,b){"use strict";if(this.optional(b))return!0;var c,d=new RegExp(/^[MXYZ]{1}[0-9]{7,8}[TRWAGMYFPDXBNJZSQVHLCKET]{1}$/gi),e="TRWAGMYFPDXBNJZSQVHLCKET",f=a.substr(a.length-1).toUpperCase();return a=a.toString().toUpperCase(),!(a.length>10||a.length<9||!d.test(a))&&(a=a.replace(/^[X]/,"0").replace(/^[Y]/,"1").replace(/^[Z]/,"2"),c=9===a.length?a.substr(0,8):a.substr(0,9),e.charAt(parseInt(c,10)%23)===f)},"Please specify a valid NIE number."),a.validator.addMethod("nifES",function(a,b){"use strict";return!!this.optional(b)||(a=a.toUpperCase(),!!a.match("((^[A-Z]{1}[0-9]{7}[A-Z0-9]{1}$|^[T]{1}[A-Z0-9]{8}$)|^[0-9]{8}[A-Z]{1}$)")&&(/^[0-9]{8}[A-Z]{1}$/.test(a)?"TRWAGMYFPDXBNJZSQVHLCKE".charAt(a.substring(8,0)%23)===a.charAt(8):!!/^[KLM]{1}/.test(a)&&a[8]==="TRWAGMYFPDXBNJZSQVHLCKE".charAt(a.substring(8,1)%23)))},"Please specify a valid NIF number."),a.validator.addMethod("nipPL",function(a){"use strict";if(a=a.replace(/[^0-9]/g,""),10!==a.length)return!1;for(var b=[6,5,7,2,3,4,5,6,7],c=0,d=0;d<9;d++)c+=b[d]*a[d];var e=c%11,f=10===e?0:e;return f===parseInt(a[9],10)},"Please specify a valid NIP number."),a.validator.addMethod("nisBR",function(a){var b,c,d,e,f,g=0;if(a=a.replace(/([~!@#$%^&*()_+=`{}\[\]\-|\\:;'<>,.\/? ])+/g,""),11!==a.length)return!1;for(c=parseInt(a.substring(10,11),10),b=parseInt(a.substring(0,10),10),e=2;e<12;e++)f=e,10===e&&(f=2),11===e&&(f=3),g+=b%10*f,b=parseInt(b/10,10);return d=g%11,d=d>1?11-d:0,c===d},"Please specify a valid NIS/PIS number."),a.validator.addMethod("notEqualTo",function(b,c,d){return this.optional(c)||!a.validator.methods.equalTo.call(this,b,c,d)},"Please enter a different value, values must not be the same."),a.validator.addMethod("nowhitespace",function(a,b){return this.optional(b)||/^\S+$/i.test(a)},"No white space please."),a.validator.addMethod("pattern",function(a,b,c){return!!this.optional(b)||("string"==typeof c&&(c=new RegExp("^(?:"+c+")$")),c.test(a))},"Invalid format."),a.validator.addMethod("phoneNL",function(a,b){return this.optional(b)||/^((\+|00(\s|\s?\-\s?)?)31(\s|\s?\-\s?)?(\(0\)[\-\s]?)?|0)[1-9]((\s|\s?\-\s?)?[0-9]){8}$/.test(a)},"Please specify a valid phone number."),a.validator.addMethod("phonePL",function(a,b){a=a.replace(/\s+/g,"");var c=/^(?:(?:(?:\+|00)?48)|(?:\(\+?48\)))?(?:1[2-8]|2[2-69]|3[2-49]|4[1-68]|5[0-9]|6[0-35-9]|[7-8][1-9]|9[145])\d{7}$/;return this.optional(b)||c.test(a)},"Please specify a valid phone number."),a.validator.addMethod("phonesUK",function(a,b){return a=a.replace(/\(|\)|\s+|-/g,""),this.optional(b)||a.length>9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?|0)(?:1\d{8,9}|[23]\d{9}|7(?:[1345789]\d{8}|624\d{6})))$/)},"Please specify a valid uk phone number."),a.validator.addMethod("phoneUK",function(a,b){return a=a.replace(/\(|\)|\s+|-/g,""),this.optional(b)||a.length>9&&a.match(/^(?:(?:(?:00\s?|\+)44\s?)|(?:\(?0))(?:\d{2}\)?\s?\d{4}\s?\d{4}|\d{3}\)?\s?\d{3}\s?\d{3,4}|\d{4}\)?\s?(?:\d{5}|\d{3}\s?\d{3})|\d{5}\)?\s?\d{4,5})$/)},"Please specify a valid phone number."),a.validator.addMethod("phoneUS",function(a,b){return a=a.replace(/\s+/g,""),this.optional(b)||a.length>9&&a.match(/^(\+?1-?)?(\([2-9]([02-9]\d|1[02-9])\)|[2-9]([02-9]\d|1[02-9]))-?[2-9]\d{2}-?\d{4}$/)},"Please specify a valid phone number."),a.validator.addMethod("postalcodeBR",function(a,b){return this.optional(b)||/^\d{2}.\d{3}-\d{3}?$|^\d{5}-?\d{3}?$/.test(a)},"Informe um CEP válido."),a.validator.addMethod("postalCodeCA",function(a,b){return this.optional(b)||/^[ABCEGHJKLMNPRSTVXY]\d[ABCEGHJKLMNPRSTVWXYZ] *\d[ABCEGHJKLMNPRSTVWXYZ]\d$/i.test(a)},"Please specify a valid postal code."),a.validator.addMethod("postalcodeIT",function(a,b){return this.optional(b)||/^\d{5}$/.test(a)},"Please specify a valid postal code."),a.validator.addMethod("postalcodeNL",function(a,b){return this.optional(b)||/^[1-9][0-9]{3}\s?[a-zA-Z]{2}$/.test(a)},"Please specify a valid postal code."),a.validator.addMethod("postcodeUK",function(a,b){return this.optional(b)||/^((([A-PR-UWYZ][0-9])|([A-PR-UWYZ][0-9][0-9])|([A-PR-UWYZ][A-HK-Y][0-9])|([A-PR-UWYZ][A-HK-Y][0-9][0-9])|([A-PR-UWYZ][0-9][A-HJKSTUW])|([A-PR-UWYZ][A-HK-Y][0-9][ABEHMNPRVWXY]))\s?([0-9][ABD-HJLNP-UW-Z]{2})|(GIR)\s?(0AA))$/i.test(a)},"Please specify a valid UK postcode."),a.validator.addMethod("require_from_group",function(b,c,d){var e=a(d[1],c.form),f=e.eq(0),g=f.data("valid_req_grp")?f.data("valid_req_grp"):a.extend({},this),h=e.filter(function(){return g.elementValue(this)}).length>=d[0];return f.data("valid_req_grp",g),a(c).data("being_validated")||(e.data("being_validated",!0),e.each(function(){g.element(this)}),e.data("being_validated",!1)),h},a.validator.format("Please fill at least {0} of these fields.")),a.validator.addMethod("skip_or_fill_minimum",function(b,c,d){var e=a(d[1],c.form),f=e.eq(0),g=f.data("valid_skip")?f.data("valid_skip"):a.extend({},this),h=e.filter(function(){return g.elementValue(this)}).length,i=0===h||h>=d[0];return f.data("valid_skip",g),a(c).data("being_validated")||(e.data("being_validated",!0),e.each(function(){g.element(this)}),e.data("being_validated",!1)),i},a.validator.format("Please either skip these fields or fill at least {0} of them.")),a.validator.addMethod("stateUS",function(a,b,c){var d,e="undefined"==typeof c,f=!e&&"undefined"!=typeof c.caseSensitive&&c.caseSensitive,g=!e&&"undefined"!=typeof c.includeTerritories&&c.includeTerritories,h=!e&&"undefined"!=typeof c.includeMilitary&&c.includeMilitary;return d=g||h?g&&h?"^(A[AEKLPRSZ]|C[AOT]|D[CE]|FL|G[AU]|HI|I[ADLN]|K[SY]|LA|M[ADEINOPST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])$":g?"^(A[KLRSZ]|C[AOT]|D[CE]|FL|G[AU]|HI|I[ADLN]|K[SY]|LA|M[ADEINOPST]|N[CDEHJMVY]|O[HKR]|P[AR]|RI|S[CD]|T[NX]|UT|V[AIT]|W[AIVY])$":"^(A[AEKLPRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])$":"^(A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY])$",d=f?new RegExp(d):new RegExp(d,"i"),this.optional(b)||d.test(a)},"Please specify a valid state."),a.validator.addMethod("strippedminlength",function(b,c,d){return a(b).text().length>=d},a.validator.format("Please enter at least {0} characters.")),a.validator.addMethod("time",function(a,b){return this.optional(b)||/^([01]\d|2[0-3]|[0-9])(:[0-5]\d){1,2}$/.test(a)},"Please enter a valid time, between 00:00 and 23:59."),a.validator.addMethod("time12h",function(a,b){return this.optional(b)||/^((0?[1-9]|1[012])(:[0-5]\d){1,2}(\ ?[AP]M))$/i.test(a)},"Please enter a valid time in 12-hour am/pm format."),a.validator.addMethod("url2",function(a,b){return this.optional(b)||/^(?:(?:(?:https?|ftp):)?\/\/)(?:(?:[^\]\[?\/<~#`!@$^&*()+=}|:";',>{ ]|%[0-9A-Fa-f]{2})+(?::(?:[^\]\[?\/<~#`!@$^&*()+=}|:";',>{ ]|%[0-9A-Fa-f]{2})*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?)|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff])|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62}\.)))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(a)},a.validator.messages.url),a.validator.addMethod("vinUS",function(a){if(17!==a.length)return!1;var b,c,d,e,f,g,h=["A","B","C","D","E","F","G","H","J","K","L","M","N","P","R","S","T","U","V","W","X","Y","Z"],i=[1,2,3,4,5,6,7,8,1,2,3,4,5,7,9,2,3,4,5,6,7,8,9],j=[8,7,6,5,4,3,2,10,0,9,8,7,6,5,4,3,2],k=0;for(b=0;b<17;b++)e=j[b],d=a.slice(b,b+1),isNaN(d)?(d=d.toUpperCase(),c=i[h.indexOf(d)]):c=parseInt(d,10),8===b&&(g=c,"X"===d&&(g=10)),k+=c*e;return f=k%11,f===g},"The specified vehicle identification number (VIN) is invalid."),a.validator.addMethod("zipcodeUS",function(a,b){return this.optional(b)||/^\d{5}(-\d{4})?$/.test(a)},"The specified US ZIP Code is invalid."),a.validator.addMethod("ziprange",function(a,b){return this.optional(b)||/^90[2-5]\d\{2\}-\d{4}$/.test(a)},"Your ZIP-code must be in the range 902xx-xxxx to 905xx-xxxx."),a});
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/dist/jquery.validate.js +1703 −0
@@ -0,0 +1,1703 @@
1 +/*!
2 + * jQuery Validation Plugin v1.21.0
3 + *
4 + * https://jqueryvalidation.org/
5 + *
6 + * Copyright (c) 2024 Jörn Zaefferer
7 + * Released under the MIT license
8 + */
9 +(function( factory ) {
10 + if ( typeof define === "function" && define.amd ) {
11 + define( ["jquery"], factory );
12 + } else if (typeof module === "object" && module.exports) {
13 + module.exports = factory( require( "jquery" ) );
14 + } else {
15 + factory( jQuery );
16 + }
17 +}(function( $ ) {
18 +
19 +$.extend( $.fn, {
20 +
21 + // https://jqueryvalidation.org/validate/
22 + validate: function( options ) {
23 +
24 + // If nothing is selected, return nothing; can't chain anyway
25 + if ( !this.length ) {
26 + if ( options && options.debug && window.console ) {
27 + console.warn( "Nothing selected, can't validate, returning nothing." );
28 + }
29 + return;
30 + }
31 +
32 + // Check if a validator for this form was already created
33 + var validator = $.data( this[ 0 ], "validator" );
34 + if ( validator ) {
35 + return validator;
36 + }
37 +
38 + // Add novalidate tag if HTML5.
39 + this.attr( "novalidate", "novalidate" );
40 +
41 + validator = new $.validator( options, this[ 0 ] );
42 + $.data( this[ 0 ], "validator", validator );
43 +
44 + if ( validator.settings.onsubmit ) {
45 +
46 + this.on( "click.validate", ":submit", function( event ) {
47 +
48 + // Track the used submit button to properly handle scripted
49 + // submits later.
50 + validator.submitButton = event.currentTarget;
51 +
52 + // Allow suppressing validation by adding a cancel class to the submit button
53 + if ( $( this ).hasClass( "cancel" ) ) {
54 + validator.cancelSubmit = true;
55 + }
56 +
57 + // Allow suppressing validation by adding the html5 formnovalidate attribute to the submit button
58 + if ( $( this ).attr( "formnovalidate" ) !== undefined ) {
59 + validator.cancelSubmit = true;
60 + }
61 + } );
62 +
63 + // Validate the form on submit
64 + this.on( "submit.validate", function( event ) {
65 + if ( validator.settings.debug ) {
66 +
67 + // Prevent form submit to be able to see console output
68 + event.preventDefault();
69 + }
70 +
71 + function handle() {
72 + var hidden, result;
73 +
74 + // Insert a hidden input as a replacement for the missing submit button
75 + // The hidden input is inserted in two cases:
76 + // - A user defined a `submitHandler`
77 + // - There was a pending request due to `remote` method and `stopRequest()`
78 + // was called to submit the form in case it's valid
79 + if ( validator.submitButton && ( validator.settings.submitHandler || validator.formSubmitted ) ) {
80 + hidden = $( "<input type='hidden'/>" )
81 + .attr( "name", validator.submitButton.name )
82 + .val( $( validator.submitButton ).val() )
83 + .appendTo( validator.currentForm );
84 + }
85 +
86 + if ( validator.settings.submitHandler && !validator.settings.debug ) {
87 + result = validator.settings.submitHandler.call( validator, validator.currentForm, event );
88 + if ( hidden ) {
89 +
90 + // And clean up afterwards; thanks to no-block-scope, hidden can be referenced
91 + hidden.remove();
92 + }
93 + if ( result !== undefined ) {
94 + return result;
95 + }
96 + return false;
97 + }
98 + return true;
99 + }
100 +
101 + // Prevent submit for invalid forms or custom submit handlers
102 + if ( validator.cancelSubmit ) {
103 + validator.cancelSubmit = false;
104 + return handle();
105 + }
106 + if ( validator.form() ) {
107 + if ( validator.pendingRequest ) {
108 + validator.formSubmitted = true;
109 + return false;
110 + }
111 + return handle();
112 + } else {
113 + validator.focusInvalid();
114 + return false;
115 + }
116 + } );
117 + }
118 +
119 + return validator;
120 + },
121 +
122 + // https://jqueryvalidation.org/valid/
123 + valid: function() {
124 + var valid, validator, errorList;
125 +
126 + if ( $( this[ 0 ] ).is( "form" ) ) {
127 + valid = this.validate().form();
128 + } else {
129 + errorList = [];
130 + valid = true;
131 + validator = $( this[ 0 ].form ).validate();
132 + this.each( function() {
133 + valid = validator.element( this ) && valid;
134 + if ( !valid ) {
135 + errorList = errorList.concat( validator.errorList );
136 + }
137 + } );
138 + validator.errorList = errorList;
139 + }
140 + return valid;
141 + },
142 +
143 + // https://jqueryvalidation.org/rules/
144 + rules: function( command, argument ) {
145 + var element = this[ 0 ],
146 + isContentEditable = typeof this.attr( "contenteditable" ) !== "undefined" && this.attr( "contenteditable" ) !== "false",
147 + settings, staticRules, existingRules, data, param, filtered;
148 +
149 + // If nothing is selected, return empty object; can't chain anyway
150 + if ( element == null ) {
151 + return;
152 + }
153 +
154 + if ( !element.form && isContentEditable ) {
155 + element.form = this.closest( "form" )[ 0 ];
156 + element.name = this.attr( "name" );
157 + }
158 +
159 + if ( element.form == null ) {
160 + return;
161 + }
162 +
163 + if ( command ) {
164 + settings = $.data( element.form, "validator" ).settings;
165 + staticRules = settings.rules;
166 + existingRules = $.validator.staticRules( element );
167 + switch ( command ) {
168 + case "add":
169 + $.extend( existingRules, $.validator.normalizeRule( argument ) );
170 +
171 + // Remove messages from rules, but allow them to be set separately
172 + delete existingRules.messages;
173 + staticRules[ element.name ] = existingRules;
174 + if ( argument.messages ) {
175 + settings.messages[ element.name ] = $.extend( settings.messages[ element.name ], argument.messages );
176 + }
177 + break;
178 + case "remove":
179 + if ( !argument ) {
180 + delete staticRules[ element.name ];
181 + return existingRules;
182 + }
183 + filtered = {};
184 + $.each( argument.split( /\s/ ), function( index, method ) {
185 + filtered[ method ] = existingRules[ method ];
186 + delete existingRules[ method ];
187 + } );
188 + return filtered;
189 + }
190 + }
191 +
192 + data = $.validator.normalizeRules(
193 + $.extend(
194 + {},
195 + $.validator.classRules( element ),
196 + $.validator.attributeRules( element ),
197 + $.validator.dataRules( element ),
198 + $.validator.staticRules( element )
199 + ), element );
200 +
201 + // Make sure required is at front
202 + if ( data.required ) {
203 + param = data.required;
204 + delete data.required;
205 + data = $.extend( { required: param }, data );
206 + }
207 +
208 + // Make sure remote is at back
209 + if ( data.remote ) {
210 + param = data.remote;
211 + delete data.remote;
212 + data = $.extend( data, { remote: param } );
213 + }
214 +
215 + return data;
216 + }
217 +} );
218 +
219 +// JQuery trim is deprecated, provide a trim method based on String.prototype.trim
220 +var trim = function( str ) {
221 +
222 + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/trim#Polyfill
223 + return str.replace( /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, "" );
224 +};
225 +
226 +// Custom selectors
227 +$.extend( $.expr.pseudos || $.expr[ ":" ], { // '|| $.expr[ ":" ]' here enables backwards compatibility to jQuery 1.7. Can be removed when dropping jQ 1.7.x support
228 +
229 + // https://jqueryvalidation.org/blank-selector/
230 + blank: function( a ) {
231 + return !trim( "" + $( a ).val() );
232 + },
233 +
234 + // https://jqueryvalidation.org/filled-selector/
235 + filled: function( a ) {
236 + var val = $( a ).val();
237 + return val !== null && !!trim( "" + val );
238 + },
239 +
240 + // https://jqueryvalidation.org/unchecked-selector/
241 + unchecked: function( a ) {
242 + return !$( a ).prop( "checked" );
243 + }
244 +} );
245 +
246 +// Constructor for validator
247 +$.validator = function( options, form ) {
248 + this.settings = $.extend( true, {}, $.validator.defaults, options );
249 + this.currentForm = form;
250 + this.init();
251 +};
252 +
253 +// https://jqueryvalidation.org/jQuery.validator.format/
254 +$.validator.format = function( source, params ) {
255 + if ( arguments.length === 1 ) {
256 + return function() {
257 + var args = $.makeArray( arguments );
258 + args.unshift( source );
259 + return $.validator.format.apply( this, args );
260 + };
261 + }
262 + if ( params === undefined ) {
263 + return source;
264 + }
265 + if ( arguments.length > 2 && params.constructor !== Array ) {
266 + params = $.makeArray( arguments ).slice( 1 );
267 + }
268 + if ( params.constructor !== Array ) {
269 + params = [ params ];
270 + }
271 + $.each( params, function( i, n ) {
272 + source = source.replace( new RegExp( "\\{" + i + "\\}", "g" ), function() {
273 + return n;
274 + } );
275 + } );
276 + return source;
277 +};
278 +
279 +$.extend( $.validator, {
280 +
281 + defaults: {
282 + messages: {},
283 + groups: {},
284 + rules: {},
285 + errorClass: "error",
286 + pendingClass: "pending",
287 + validClass: "valid",
288 + errorElement: "label",
289 + focusCleanup: false,
290 + focusInvalid: true,
291 + errorContainer: $( [] ),
292 + errorLabelContainer: $( [] ),
293 + onsubmit: true,
294 + ignore: ":hidden",
295 + ignoreTitle: false,
296 + customElements: [],
297 + onfocusin: function( element ) {
298 + this.lastActive = element;
299 +
300 + // Hide error label and remove error class on focus if enabled
301 + if ( this.settings.focusCleanup ) {
302 + if ( this.settings.unhighlight ) {
303 + this.settings.unhighlight.call( this, element, this.settings.errorClass, this.settings.validClass );
304 + }
305 + this.hideThese( this.errorsFor( element ) );
306 + }
307 + },
308 + onfocusout: function( element ) {
309 + if ( !this.checkable( element ) && ( element.name in this.submitted || !this.optional( element ) ) ) {
310 + this.element( element );
311 + }
312 + },
313 + onkeyup: function( element, event ) {
314 +
315 + // Avoid revalidate the field when pressing one of the following keys
316 + // Shift => 16
317 + // Ctrl => 17
318 + // Alt => 18
319 + // Caps lock => 20
320 + // End => 35
321 + // Home => 36
322 + // Left arrow => 37
323 + // Up arrow => 38
324 + // Right arrow => 39
325 + // Down arrow => 40
326 + // Insert => 45
327 + // Num lock => 144
328 + // AltGr key => 225
329 + var excludedKeys = [
330 + 16, 17, 18, 20, 35, 36, 37,
331 + 38, 39, 40, 45, 144, 225
332 + ];
333 +
334 + if ( event.which === 9 && this.elementValue( element ) === "" || $.inArray( event.keyCode, excludedKeys ) !== -1 ) {
335 + return;
336 + } else if ( element.name in this.submitted || element.name in this.invalid ) {
337 + this.element( element );
338 + }
339 + },
340 + onclick: function( element ) {
341 +
342 + // Click on selects, radiobuttons and checkboxes
343 + if ( element.name in this.submitted ) {
344 + this.element( element );
345 +
346 + // Or option elements, check parent select in that case
347 + } else if ( element.parentNode.name in this.submitted ) {
348 + this.element( element.parentNode );
349 + }
350 + },
351 + highlight: function( element, errorClass, validClass ) {
352 + if ( element.type === "radio" ) {
353 + this.findByName( element.name ).addClass( errorClass ).removeClass( validClass );
354 + } else {
355 + $( element ).addClass( errorClass ).removeClass( validClass );
356 + }
357 + },
358 + unhighlight: function( element, errorClass, validClass ) {
359 + if ( element.type === "radio" ) {
360 + this.findByName( element.name ).removeClass( errorClass ).addClass( validClass );
361 + } else {
362 + $( element ).removeClass( errorClass ).addClass( validClass );
363 + }
364 + }
365 + },
366 +
367 + // https://jqueryvalidation.org/jQuery.validator.setDefaults/
368 + setDefaults: function( settings ) {
369 + $.extend( $.validator.defaults, settings );
370 + },
371 +
372 + messages: {
373 + required: "This field is required.",
374 + remote: "Please fix this field.",
375 + email: "Please enter a valid email address.",
376 + url: "Please enter a valid URL.",
377 + date: "Please enter a valid date.",
378 + dateISO: "Please enter a valid date (ISO).",
379 + number: "Please enter a valid number.",
380 + digits: "Please enter only digits.",
381 + equalTo: "Please enter the same value again.",
382 + maxlength: $.validator.format( "Please enter no more than {0} characters." ),
383 + minlength: $.validator.format( "Please enter at least {0} characters." ),
384 + rangelength: $.validator.format( "Please enter a value between {0} and {1} characters long." ),
385 + range: $.validator.format( "Please enter a value between {0} and {1}." ),
386 + max: $.validator.format( "Please enter a value less than or equal to {0}." ),
387 + min: $.validator.format( "Please enter a value greater than or equal to {0}." ),
388 + step: $.validator.format( "Please enter a multiple of {0}." )
389 + },
390 +
391 + autoCreateRanges: false,
392 +
393 + prototype: {
394 +
395 + init: function() {
396 + this.labelContainer = $( this.settings.errorLabelContainer );
397 + this.errorContext = this.labelContainer.length && this.labelContainer || $( this.currentForm );
398 + this.containers = $( this.settings.errorContainer ).add( this.settings.errorLabelContainer );
399 + this.submitted = {};
400 + this.valueCache = {};
401 + this.pendingRequest = 0;
402 + this.pending = {};
403 + this.invalid = {};
404 + this.reset();
405 +
406 + var currentForm = this.currentForm,
407 + groups = ( this.groups = {} ),
408 + rules;
409 + $.each( this.settings.groups, function( key, value ) {
410 + if ( typeof value === "string" ) {
411 + value = value.split( /\s/ );
412 + }
413 + $.each( value, function( index, name ) {
414 + groups[ name ] = key;
415 + } );
416 + } );
417 + rules = this.settings.rules;
418 + $.each( rules, function( key, value ) {
419 + rules[ key ] = $.validator.normalizeRule( value );
420 + } );
421 +
422 + function delegate( event ) {
423 + var isContentEditable = typeof $( this ).attr( "contenteditable" ) !== "undefined" && $( this ).attr( "contenteditable" ) !== "false";
424 +
425 + // Set form expando on contenteditable
426 + if ( !this.form && isContentEditable ) {
427 + this.form = $( this ).closest( "form" )[ 0 ];
428 + this.name = $( this ).attr( "name" );
429 + }
430 +
431 + // Ignore the element if it belongs to another form. This will happen mainly
432 + // when setting the `form` attribute of an input to the id of another form.
433 + if ( currentForm !== this.form ) {
434 + return;
435 + }
436 +
437 + var validator = $.data( this.form, "validator" ),
438 + eventType = "on" + event.type.replace( /^validate/, "" ),
439 + settings = validator.settings;
440 + if ( settings[ eventType ] && !$( this ).is( settings.ignore ) ) {
441 + settings[ eventType ].call( validator, this, event );
442 + }
443 + }
444 + var focusListeners = [ ":text", "[type='password']", "[type='file']", "select", "textarea", "[type='number']", "[type='search']",
445 + "[type='tel']", "[type='url']", "[type='email']", "[type='datetime']", "[type='date']", "[type='month']",
446 + "[type='week']", "[type='time']", "[type='datetime-local']", "[type='range']", "[type='color']",
447 + "[type='radio']", "[type='checkbox']", "[contenteditable]", "[type='button']" ];
448 + var clickListeners = [ "select", "option", "[type='radio']", "[type='checkbox']" ];
449 + $( this.currentForm )
450 + .on( "focusin.validate focusout.validate keyup.validate", focusListeners.concat( this.settings.customElements ).join( ", " ), delegate )
451 +
452 + // Support: Chrome, oldIE
453 + // "select" is provided as event.target when clicking a option
454 + .on( "click.validate", clickListeners.concat( this.settings.customElements ).join( ", " ), delegate );
455 +
456 + if ( this.settings.invalidHandler ) {
457 + $( this.currentForm ).on( "invalid-form.validate", this.settings.invalidHandler );
458 + }
459 + },
460 +
461 + // https://jqueryvalidation.org/Validator.form/
462 + form: function() {
463 + this.checkForm();
464 + $.extend( this.submitted, this.errorMap );
465 + this.invalid = $.extend( {}, this.errorMap );
466 + if ( !this.valid() ) {
467 + $( this.currentForm ).triggerHandler( "invalid-form", [ this ] );
468 + }
469 + this.showErrors();
470 + return this.valid();
471 + },
472 +
473 + checkForm: function() {
474 + this.prepareForm();
475 + for ( var i = 0, elements = ( this.currentElements = this.elements() ); elements[ i ]; i++ ) {
476 + this.check( elements[ i ] );
477 + }
478 + return this.valid();
479 + },
480 +
481 + // https://jqueryvalidation.org/Validator.element/
482 + element: function( element ) {
483 + var cleanElement = this.clean( element ),
484 + checkElement = this.validationTargetFor( cleanElement ),
485 + v = this,
486 + result = true,
487 + rs, group;
488 +
489 + if ( checkElement === undefined ) {
490 + delete this.invalid[ cleanElement.name ];
491 + } else {
492 + this.prepareElement( checkElement );
493 + this.currentElements = $( checkElement );
494 +
495 + // If this element is grouped, then validate all group elements already
496 + // containing a value
497 + group = this.groups[ checkElement.name ];
498 + if ( group ) {
499 + $.each( this.groups, function( name, testgroup ) {
500 + if ( testgroup === group && name !== checkElement.name ) {
501 + cleanElement = v.validationTargetFor( v.clean( v.findByName( name ) ) );
502 + if ( cleanElement && cleanElement.name in v.invalid ) {
503 + v.currentElements.push( cleanElement );
504 + result = v.check( cleanElement ) && result;
505 + }
506 + }
507 + } );
508 + }
509 +
510 + rs = this.check( checkElement ) !== false;
511 + result = result && rs;
512 + if ( rs ) {
513 + this.invalid[ checkElement.name ] = false;
514 + } else {
515 + this.invalid[ checkElement.name ] = true;
516 + }
517 +
518 + if ( !this.numberOfInvalids() ) {
519 +
520 + // Hide error containers on last error
521 + this.toHide = this.toHide.add( this.containers );
522 + }
523 + this.showErrors();
524 +
525 + // Add aria-invalid status for screen readers
526 + $( element ).attr( "aria-invalid", !rs );
527 + }
528 +
529 + return result;
530 + },
531 +
532 + // https://jqueryvalidation.org/Validator.showErrors/
533 + showErrors: function( errors ) {
534 + if ( errors ) {
535 + var validator = this;
536 +
537 + // Add items to error list and map
538 + $.extend( this.errorMap, errors );
539 + this.errorList = $.map( this.errorMap, function( message, name ) {
540 + return {
541 + message: message,
542 + element: validator.findByName( name )[ 0 ]
543 + };
544 + } );
545 +
546 + // Remove items from success list
547 + this.successList = $.grep( this.successList, function( element ) {
548 + return !( element.name in errors );
549 + } );
550 + }
551 + if ( this.settings.showErrors ) {
552 + this.settings.showErrors.call( this, this.errorMap, this.errorList );
553 + } else {
554 + this.defaultShowErrors();
555 + }
556 + },
557 +
558 + // https://jqueryvalidation.org/Validator.resetForm/
559 + resetForm: function() {
560 + if ( $.fn.resetForm ) {
561 + $( this.currentForm ).resetForm();
562 + }
563 + this.invalid = {};
564 + this.submitted = {};
565 + this.prepareForm();
566 + this.hideErrors();
567 + var elements = this.elements()
568 + .removeData( "previousValue" )
569 + .removeAttr( "aria-invalid" );
570 +
571 + this.resetElements( elements );
572 + },
573 +
574 + resetElements: function( elements ) {
575 + var i;
576 +
577 + if ( this.settings.unhighlight ) {
578 + for ( i = 0; elements[ i ]; i++ ) {
579 + this.settings.unhighlight.call( this, elements[ i ],
580 + this.settings.errorClass, "" );
581 + this.findByName( elements[ i ].name ).removeClass( this.settings.validClass );
582 + }
583 + } else {
584 + elements
585 + .removeClass( this.settings.errorClass )
586 + .removeClass( this.settings.validClass );
587 + }
588 + },
589 +
590 + numberOfInvalids: function() {
591 + return this.objectLength( this.invalid );
592 + },
593 +
594 + objectLength: function( obj ) {
595 + /* jshint unused: false */
596 + var count = 0,
597 + i;
598 + for ( i in obj ) {
599 +
600 + // This check allows counting elements with empty error
601 + // message as invalid elements
602 + if ( obj[ i ] !== undefined && obj[ i ] !== null && obj[ i ] !== false ) {
603 + count++;
604 + }
605 + }
606 + return count;
607 + },
608 +
609 + hideErrors: function() {
610 + this.hideThese( this.toHide );
611 + },
612 +
613 + hideThese: function( errors ) {
614 + errors.not( this.containers ).text( "" );
615 + this.addWrapper( errors ).hide();
616 + },
617 +
618 + valid: function() {
619 + return this.size() === 0;
620 + },
621 +
622 + size: function() {
623 + return this.errorList.length;
624 + },
625 +
626 + focusInvalid: function() {
627 + if ( this.settings.focusInvalid ) {
628 + try {
629 + $( this.findLastActive() || this.errorList.length && this.errorList[ 0 ].element || [] )
630 + .filter( ":visible" )
631 + .trigger( "focus" )
632 +
633 + // Manually trigger focusin event; without it, focusin handler isn't called, findLastActive won't have anything to find
634 + .trigger( "focusin" );
635 + } catch ( e ) {
636 +
637 + // Ignore IE throwing errors when focusing hidden elements
638 + }
639 + }
640 + },
641 +
642 + findLastActive: function() {
643 + var lastActive = this.lastActive;
644 + return lastActive && $.grep( this.errorList, function( n ) {
645 + return n.element.name === lastActive.name;
646 + } ).length === 1 && lastActive;
647 + },
648 +
649 + elements: function() {
650 + var validator = this,
651 + rulesCache = {},
652 + selectors = [ "input", "select", "textarea", "[contenteditable]" ];
653 +
654 + // Select all valid inputs inside the form (no submit or reset buttons)
655 + return $( this.currentForm )
656 + .find( selectors.concat( this.settings.customElements ).join( ", " ) )
657 + .not( ":submit, :reset, :image, :disabled" )
658 + .not( this.settings.ignore )
659 + .filter( function() {
660 + var name = this.name || $( this ).attr( "name" ); // For contenteditable
661 + var isContentEditable = typeof $( this ).attr( "contenteditable" ) !== "undefined" && $( this ).attr( "contenteditable" ) !== "false";
662 +
663 + if ( !name && validator.settings.debug && window.console ) {
664 + console.error( "%o has no name assigned", this );
665 + }
666 +
667 + // Set form expando on contenteditable
668 + if ( isContentEditable ) {
669 + this.form = $( this ).closest( "form" )[ 0 ];
670 + this.name = name;
671 + }
672 +
673 + // Ignore elements that belong to other/nested forms
674 + if ( this.form !== validator.currentForm ) {
675 + return false;
676 + }
677 +
678 + // Select only the first element for each name, and only those with rules specified
679 + if ( name in rulesCache || !validator.objectLength( $( this ).rules() ) ) {
680 + return false;
681 + }
682 +
683 + rulesCache[ name ] = true;
684 + return true;
685 + } );
686 + },
687 +
688 + clean: function( selector ) {
689 + return $( selector )[ 0 ];
690 + },
691 +
692 + errors: function() {
693 + var errorClass = this.settings.errorClass.split( " " ).join( "." );
694 + return $( this.settings.errorElement + "." + errorClass, this.errorContext );
695 + },
696 +
697 + resetInternals: function() {
698 + this.successList = [];
699 + this.errorList = [];
700 + this.errorMap = {};
701 + this.toShow = $( [] );
702 + this.toHide = $( [] );
703 + },
704 +
705 + reset: function() {
706 + this.resetInternals();
707 + this.currentElements = $( [] );
708 + },
709 +
710 + prepareForm: function() {
711 + this.reset();
712 + this.toHide = this.errors().add( this.containers );
713 + },
714 +
715 + prepareElement: function( element ) {
716 + this.reset();
717 + this.toHide = this.errorsFor( element );
718 + },
719 +
720 + elementValue: function( element ) {
721 + var $element = $( element ),
722 + type = element.type,
723 + isContentEditable = typeof $element.attr( "contenteditable" ) !== "undefined" && $element.attr( "contenteditable" ) !== "false",
724 + val, idx;
725 +
726 + if ( type === "radio" || type === "checkbox" ) {
727 + return this.findByName( element.name ).filter( ":checked" ).val();
728 + } else if ( type === "number" && typeof element.validity !== "undefined" ) {
729 + return element.validity.badInput ? "NaN" : $element.val();
730 + }
731 +
732 + if ( isContentEditable ) {
733 + val = $element.text();
734 + } else {
735 + val = $element.val();
736 + }
737 +
738 + if ( type === "file" ) {
739 +
740 + // Modern browser (chrome & safari)
741 + if ( val.substr( 0, 12 ) === "C:\\fakepath\\" ) {
742 + return val.substr( 12 );
743 + }
744 +
745 + // Legacy browsers
746 + // Unix-based path
747 + idx = val.lastIndexOf( "/" );
748 + if ( idx >= 0 ) {
749 + return val.substr( idx + 1 );
750 + }
751 +
752 + // Windows-based path
753 + idx = val.lastIndexOf( "\\" );
754 + if ( idx >= 0 ) {
755 + return val.substr( idx + 1 );
756 + }
757 +
758 + // Just the file name
759 + return val;
760 + }
761 +
762 + if ( typeof val === "string" ) {
763 + return val.replace( /\r/g, "" );
764 + }
765 + return val;
766 + },
767 +
768 + check: function( element ) {
769 + element = this.validationTargetFor( this.clean( element ) );
770 +
771 + var rules = $( element ).rules(),
772 + rulesCount = $.map( rules, function( n, i ) {
773 + return i;
774 + } ).length,
775 + dependencyMismatch = false,
776 + val = this.elementValue( element ),
777 + result, method, rule, normalizer;
778 +
779 + // Abort any pending Ajax request from a previous call to this method.
780 + this.abortRequest( element );
781 +
782 + // Prioritize the local normalizer defined for this element over the global one
783 + // if the former exists, otherwise user the global one in case it exists.
784 + if ( typeof rules.normalizer === "function" ) {
785 + normalizer = rules.normalizer;
786 + } else if ( typeof this.settings.normalizer === "function" ) {
787 + normalizer = this.settings.normalizer;
788 + }
789 +
790 + // If normalizer is defined, then call it to retreive the changed value instead
791 + // of using the real one.
792 + // Note that `this` in the normalizer is `element`.
793 + if ( normalizer ) {
794 + val = normalizer.call( element, val );
795 +
796 + // Delete the normalizer from rules to avoid treating it as a pre-defined method.
797 + delete rules.normalizer;
798 + }
799 +
800 + for ( method in rules ) {
801 + rule = { method: method, parameters: rules[ method ] };
802 + try {
803 + result = $.validator.methods[ method ].call( this, val, element, rule.parameters );
804 +
805 + // If a method indicates that the field is optional and therefore valid,
806 + // don't mark it as valid when there are no other rules
807 + if ( result === "dependency-mismatch" && rulesCount === 1 ) {
808 + dependencyMismatch = true;
809 + continue;
810 + }
811 + dependencyMismatch = false;
812 +
813 + if ( result === "pending" ) {
814 + this.toHide = this.toHide.not( this.errorsFor( element ) );
815 + return;
816 + }
817 +
818 + if ( !result ) {
819 + this.formatAndAdd( element, rule );
820 + return false;
821 + }
822 + } catch ( e ) {
823 + if ( this.settings.debug && window.console ) {
824 + console.log( "Exception occurred when checking element " + element.id + ", check the '" + rule.method + "' method.", e );
825 + }
826 + if ( e instanceof TypeError ) {
827 + e.message += ". Exception occurred when checking element " + element.id + ", check the '" + rule.method + "' method.";
828 + }
829 +
830 + throw e;
831 + }
832 + }
833 + if ( dependencyMismatch ) {
834 + return;
835 + }
836 + if ( this.objectLength( rules ) ) {
837 + this.successList.push( element );
838 + }
839 + return true;
840 + },
841 +
842 + // Return the custom message for the given element and validation method
843 + // specified in the element's HTML5 data attribute
844 + // return the generic message if present and no method specific message is present
845 + customDataMessage: function( element, method ) {
846 + return $( element ).data( "msg" + method.charAt( 0 ).toUpperCase() +
847 + method.substring( 1 ).toLowerCase() ) || $( element ).data( "msg" );
848 + },
849 +
850 + // Return the custom message for the given element name and validation method
851 + customMessage: function( name, method ) {
852 + var m = this.settings.messages[ name ];
853 + return m && ( m.constructor === String ? m : m[ method ] );
854 + },
855 +
856 + // Return the first defined argument, allowing empty strings
857 + findDefined: function() {
858 + for ( var i = 0; i < arguments.length; i++ ) {
859 + if ( arguments[ i ] !== undefined ) {
860 + return arguments[ i ];
861 + }
862 + }
863 + return undefined;
864 + },
865 +
866 + // The second parameter 'rule' used to be a string, and extended to an object literal
867 + // of the following form:
868 + // rule = {
869 + // method: "method name",
870 + // parameters: "the given method parameters"
871 + // }
872 + //
873 + // The old behavior still supported, kept to maintain backward compatibility with
874 + // old code, and will be removed in the next major release.
875 + defaultMessage: function( element, rule ) {
876 + if ( typeof rule === "string" ) {
877 + rule = { method: rule };
878 + }
879 +
880 + var message = this.findDefined(
881 + this.customMessage( element.name, rule.method ),
882 + this.customDataMessage( element, rule.method ),
883 +
884 + // 'title' is never undefined, so handle empty string as undefined
885 + !this.settings.ignoreTitle && element.title || undefined,
886 + $.validator.messages[ rule.method ],
887 + "<strong>Warning: No message defined for " + element.name + "</strong>"
888 + ),
889 + theregex = /\$?\{(\d+)\}/g;
890 + if ( typeof message === "function" ) {
891 + message = message.call( this, rule.parameters, element );
892 + } else if ( theregex.test( message ) ) {
893 + message = $.validator.format( message.replace( theregex, "{$1}" ), rule.parameters );
894 + }
895 +
896 + return message;
897 + },
898 +
899 + formatAndAdd: function( element, rule ) {
900 + var message = this.defaultMessage( element, rule );
901 +
902 + this.errorList.push( {
903 + message: message,
904 + element: element,
905 + method: rule.method
906 + } );
907 +
908 + this.errorMap[ element.name ] = message;
909 + this.submitted[ element.name ] = message;
910 + },
911 +
912 + addWrapper: function( toToggle ) {
913 + if ( this.settings.wrapper ) {
914 + toToggle = toToggle.add( toToggle.parent( this.settings.wrapper ) );
915 + }
916 + return toToggle;
917 + },
918 +
919 + defaultShowErrors: function() {
920 + var i, elements, error;
921 + for ( i = 0; this.errorList[ i ]; i++ ) {
922 + error = this.errorList[ i ];
923 + if ( this.settings.highlight ) {
924 + this.settings.highlight.call( this, error.element, this.settings.errorClass, this.settings.validClass );
925 + }
926 + this.showLabel( error.element, error.message );
927 + }
928 + if ( this.errorList.length ) {
929 + this.toShow = this.toShow.add( this.containers );
930 + }
931 + if ( this.settings.success ) {
932 + for ( i = 0; this.successList[ i ]; i++ ) {
933 + this.showLabel( this.successList[ i ] );
934 + }
935 + }
936 + if ( this.settings.unhighlight ) {
937 + for ( i = 0, elements = this.validElements(); elements[ i ]; i++ ) {
938 + this.settings.unhighlight.call( this, elements[ i ], this.settings.errorClass, this.settings.validClass );
939 + }
940 + }
941 + this.toHide = this.toHide.not( this.toShow );
942 + this.hideErrors();
943 + this.addWrapper( this.toShow ).show();
944 + },
945 +
946 + validElements: function() {
947 + return this.currentElements.not( this.invalidElements() );
948 + },
949 +
950 + invalidElements: function() {
951 + return $( this.errorList ).map( function() {
952 + return this.element;
953 + } );
954 + },
955 +
956 + showLabel: function( element, message ) {
957 + var place, group, errorID, v,
958 + error = this.errorsFor( element ),
959 + elementID = this.idOrName( element ),
960 + describedBy = $( element ).attr( "aria-describedby" );
961 +
962 + if ( error.length ) {
963 +
964 + // Refresh error/success class
965 + error.removeClass( this.settings.validClass ).addClass( this.settings.errorClass );
966 +
967 + // Replace message on existing label
968 + if ( this.settings && this.settings.escapeHtml ) {
969 + error.text( message || "" );
970 + } else {
971 + error.html( message || "" );
972 + }
973 + } else {
974 +
975 + // Create error element
976 + error = $( "<" + this.settings.errorElement + ">" )
977 + .attr( "id", elementID + "-error" )
978 + .addClass( this.settings.errorClass );
979 +
980 + if ( this.settings && this.settings.escapeHtml ) {
981 + error.text( message || "" );
982 + } else {
983 + error.html( message || "" );
984 + }
985 +
986 + // Maintain reference to the element to be placed into the DOM
987 + place = error;
988 + if ( this.settings.wrapper ) {
989 +
990 + // Make sure the element is visible, even in IE
991 + // actually showing the wrapped element is handled elsewhere
992 + place = error.hide().show().wrap( "<" + this.settings.wrapper + "/>" ).parent();
993 + }
994 + if ( this.labelContainer.length ) {
995 + this.labelContainer.append( place );
996 + } else if ( this.settings.errorPlacement ) {
997 + this.settings.errorPlacement.call( this, place, $( element ) );
998 + } else {
999 + place.insertAfter( element );
1000 + }
1001 +
1002 + // Link error back to the element
1003 + if ( error.is( "label" ) ) {
1004 +
1005 + // If the error is a label, then associate using 'for'
1006 + error.attr( "for", elementID );
1007 +
1008 + // If the element is not a child of an associated label, then it's necessary
1009 + // to explicitly apply aria-describedby
1010 + } else if ( error.parents( "label[for='" + this.escapeCssMeta( elementID ) + "']" ).length === 0 ) {
1011 + errorID = error.attr( "id" );
1012 +
1013 + // Respect existing non-error aria-describedby
1014 + if ( !describedBy ) {
1015 + describedBy = errorID;
1016 + } else if ( !describedBy.match( new RegExp( "\\b" + this.escapeCssMeta( errorID ) + "\\b" ) ) ) {
1017 +
1018 + // Add to end of list if not already present
1019 + describedBy += " " + errorID;
1020 + }
1021 + $( element ).attr( "aria-describedby", describedBy );
1022 +
1023 + // If this element is grouped, then assign to all elements in the same group
1024 + group = this.groups[ element.name ];
1025 + if ( group ) {
1026 + v = this;
1027 + $.each( v.groups, function( name, testgroup ) {
1028 + if ( testgroup === group ) {
1029 + $( "[name='" + v.escapeCssMeta( name ) + "']", v.currentForm )
1030 + .attr( "aria-describedby", error.attr( "id" ) );
1031 + }
1032 + } );
1033 + }
1034 + }
1035 + }
1036 + if ( !message && this.settings.success ) {
1037 + error.text( "" );
1038 + if ( typeof this.settings.success === "string" ) {
1039 + error.addClass( this.settings.success );
1040 + } else {
1041 + this.settings.success( error, element );
1042 + }
1043 + }
1044 + this.toShow = this.toShow.add( error );
1045 + },
1046 +
1047 + errorsFor: function( element ) {
1048 + var name = this.escapeCssMeta( this.idOrName( element ) ),
1049 + describer = $( element ).attr( "aria-describedby" ),
1050 + selector = "label[for='" + name + "'], label[for='" + name + "'] *";
1051 +
1052 + // 'aria-describedby' should directly reference the error element
1053 + if ( describer ) {
1054 + selector = selector + ", #" + this.escapeCssMeta( describer )
1055 + .replace( /\s+/g, ", #" );
1056 + }
1057 +
1058 + return this
1059 + .errors()
1060 + .filter( selector );
1061 + },
1062 +
1063 + // See https://api.jquery.com/category/selectors/, for CSS
1064 + // meta-characters that should be escaped in order to be used with JQuery
1065 + // as a literal part of a name/id or any selector.
1066 + escapeCssMeta: function( string ) {
1067 + if ( string === undefined ) {
1068 + return "";
1069 + }
1070 +
1071 + return string.replace( /([\\!"#$%&'()*+,./:;<=>?@\[\]^`{|}~])/g, "\\$1" );
1072 + },
1073 +
1074 + idOrName: function( element ) {
1075 + return this.groups[ element.name ] || ( this.checkable( element ) ? element.name : element.id || element.name );
1076 + },
1077 +
1078 + validationTargetFor: function( element ) {
1079 +
1080 + // If radio/checkbox, validate first element in group instead
1081 + if ( this.checkable( element ) ) {
1082 + element = this.findByName( element.name );
1083 + }
1084 +
1085 + // Always apply ignore filter
1086 + return $( element ).not( this.settings.ignore )[ 0 ];
1087 + },
1088 +
1089 + checkable: function( element ) {
1090 + return ( /radio|checkbox/i ).test( element.type );
1091 + },
1092 +
1093 + findByName: function( name ) {
1094 + return $( this.currentForm ).find( "[name='" + this.escapeCssMeta( name ) + "']" );
1095 + },
1096 +
1097 + getLength: function( value, element ) {
1098 + switch ( element.nodeName.toLowerCase() ) {
1099 + case "select":
1100 + return $( "option:selected", element ).length;
1101 + case "input":
1102 + if ( this.checkable( element ) ) {
1103 + return this.findByName( element.name ).filter( ":checked" ).length;
1104 + }
1105 + }
1106 + return value.length;
1107 + },
1108 +
1109 + depend: function( param, element ) {
1110 + return this.dependTypes[ typeof param ] ? this.dependTypes[ typeof param ]( param, element ) : true;
1111 + },
1112 +
1113 + dependTypes: {
1114 + "boolean": function( param ) {
1115 + return param;
1116 + },
1117 + "string": function( param, element ) {
1118 + return !!$( param, element.form ).length;
1119 + },
1120 + "function": function( param, element ) {
1121 + return param( element );
1122 + }
1123 + },
1124 +
1125 + optional: function( element ) {
1126 + var val = this.elementValue( element );
1127 + return !$.validator.methods.required.call( this, val, element ) && "dependency-mismatch";
1128 + },
1129 +
1130 + elementAjaxPort: function( element ) {
1131 + return "validate" + element.name;
1132 + },
1133 +
1134 + startRequest: function( element ) {
1135 + if ( !this.pending[ element.name ] ) {
1136 + this.pendingRequest++;
1137 + $( element ).addClass( this.settings.pendingClass );
1138 + this.pending[ element.name ] = true;
1139 + }
1140 + },
1141 +
1142 + stopRequest: function( element, valid ) {
1143 + this.pendingRequest--;
1144 +
1145 + // Sometimes synchronization fails, make sure pendingRequest is never < 0
1146 + if ( this.pendingRequest < 0 ) {
1147 + this.pendingRequest = 0;
1148 + }
1149 + delete this.pending[ element.name ];
1150 + $( element ).removeClass( this.settings.pendingClass );
1151 + if ( valid && this.pendingRequest === 0 && this.formSubmitted && this.form() && this.pendingRequest === 0 ) {
1152 + $( this.currentForm ).trigger( "submit" );
1153 +
1154 + // Remove the hidden input that was used as a replacement for the
1155 + // missing submit button. The hidden input is added by `handle()`
1156 + // to ensure that the value of the used submit button is passed on
1157 + // for scripted submits triggered by this method
1158 + if ( this.submitButton ) {
1159 + $( "input:hidden[name='" + this.submitButton.name + "']", this.currentForm ).remove();
1160 + }
1161 +
1162 + this.formSubmitted = false;
1163 + } else if ( !valid && this.pendingRequest === 0 && this.formSubmitted ) {
1164 + $( this.currentForm ).triggerHandler( "invalid-form", [ this ] );
1165 + this.formSubmitted = false;
1166 + }
1167 + },
1168 +
1169 + abortRequest: function( element ) {
1170 + var port;
1171 +
1172 + if ( this.pending[ element.name ] ) {
1173 + port = this.elementAjaxPort( element );
1174 + $.ajaxAbort( port );
1175 +
1176 + this.pendingRequest--;
1177 +
1178 + // Sometimes synchronization fails, make sure pendingRequest is never < 0
1179 + if ( this.pendingRequest < 0 ) {
1180 + this.pendingRequest = 0;
1181 + }
1182 +
1183 + delete this.pending[ element.name ];
1184 + $( element ).removeClass( this.settings.pendingClass );
1185 + }
1186 + },
1187 +
1188 + previousValue: function( element, method ) {
1189 + method = typeof method === "string" && method || "remote";
1190 +
1191 + return $.data( element, "previousValue" ) || $.data( element, "previousValue", {
1192 + old: null,
1193 + valid: true,
1194 + message: this.defaultMessage( element, { method: method } )
1195 + } );
1196 + },
1197 +
1198 + // Cleans up all forms and elements, removes validator-specific events
1199 + destroy: function() {
1200 + this.resetForm();
1201 +
1202 + $( this.currentForm )
1203 + .off( ".validate" )
1204 + .removeData( "validator" )
1205 + .find( ".validate-equalTo-blur" )
1206 + .off( ".validate-equalTo" )
1207 + .removeClass( "validate-equalTo-blur" )
1208 + .find( ".validate-lessThan-blur" )
1209 + .off( ".validate-lessThan" )
1210 + .removeClass( "validate-lessThan-blur" )
1211 + .find( ".validate-lessThanEqual-blur" )
1212 + .off( ".validate-lessThanEqual" )
1213 + .removeClass( "validate-lessThanEqual-blur" )
1214 + .find( ".validate-greaterThanEqual-blur" )
1215 + .off( ".validate-greaterThanEqual" )
1216 + .removeClass( "validate-greaterThanEqual-blur" )
1217 + .find( ".validate-greaterThan-blur" )
1218 + .off( ".validate-greaterThan" )
1219 + .removeClass( "validate-greaterThan-blur" );
1220 + }
1221 +
1222 + },
1223 +
1224 + classRuleSettings: {
1225 + required: { required: true },
1226 + email: { email: true },
1227 + url: { url: true },
1228 + date: { date: true },
1229 + dateISO: { dateISO: true },
1230 + number: { number: true },
1231 + digits: { digits: true },
1232 + creditcard: { creditcard: true }
1233 + },
1234 +
1235 + addClassRules: function( className, rules ) {
1236 + if ( className.constructor === String ) {
1237 + this.classRuleSettings[ className ] = rules;
1238 + } else {
1239 + $.extend( this.classRuleSettings, className );
1240 + }
1241 + },
1242 +
1243 + classRules: function( element ) {
1244 + var rules = {},
1245 + classes = $( element ).attr( "class" );
1246 +
1247 + if ( classes ) {
1248 + $.each( classes.split( " " ), function() {
1249 + if ( this in $.validator.classRuleSettings ) {
1250 + $.extend( rules, $.validator.classRuleSettings[ this ] );
1251 + }
1252 + } );
1253 + }
1254 + return rules;
1255 + },
1256 +
1257 + normalizeAttributeRule: function( rules, type, method, value ) {
1258 +
1259 + // Convert the value to a number for number inputs, and for text for backwards compability
1260 + // allows type="date" and others to be compared as strings
1261 + if ( /min|max|step/.test( method ) && ( type === null || /number|range|text/.test( type ) ) ) {
1262 + value = Number( value );
1263 +
1264 + // Support Opera Mini, which returns NaN for undefined minlength
1265 + if ( isNaN( value ) ) {
1266 + value = undefined;
1267 + }
1268 + }
1269 +
1270 + if ( value || value === 0 ) {
1271 + rules[ method ] = value;
1272 + } else if ( type === method && type !== "range" ) {
1273 +
1274 + // Exception: the jquery validate 'range' method
1275 + // does not test for the html5 'range' type
1276 + rules[ type === "date" ? "dateISO" : method ] = true;
1277 + }
1278 + },
1279 +
1280 + attributeRules: function( element ) {
1281 + var rules = {},
1282 + $element = $( element ),
1283 + type = element.getAttribute( "type" ),
1284 + method, value;
1285 +
1286 + for ( method in $.validator.methods ) {
1287 +
1288 + // Support for <input required> in both html5 and older browsers
1289 + if ( method === "required" ) {
1290 + value = element.getAttribute( method );
1291 +
1292 + // Some browsers return an empty string for the required attribute
1293 + // and non-HTML5 browsers might have required="" markup
1294 + if ( value === "" ) {
1295 + value = true;
1296 + }
1297 +
1298 + // Force non-HTML5 browsers to return bool
1299 + value = !!value;
1300 + } else {
1301 + value = $element.attr( method );
1302 + }
1303 +
1304 + this.normalizeAttributeRule( rules, type, method, value );
1305 + }
1306 +
1307 + // 'maxlength' may be returned as -1, 2147483647 ( IE ) and 524288 ( safari ) for text inputs
1308 + if ( rules.maxlength && /-1|2147483647|524288/.test( rules.maxlength ) ) {
1309 + delete rules.maxlength;
1310 + }
1311 +
1312 + return rules;
1313 + },
1314 +
1315 + dataRules: function( element ) {
1316 + var rules = {},
1317 + $element = $( element ),
1318 + type = element.getAttribute( "type" ),
1319 + method, value;
1320 +
1321 + for ( method in $.validator.methods ) {
1322 + value = $element.data( "rule" + method.charAt( 0 ).toUpperCase() + method.substring( 1 ).toLowerCase() );
1323 +
1324 + // Cast empty attributes like `data-rule-required` to `true`
1325 + if ( value === "" ) {
1326 + value = true;
1327 + }
1328 +
1329 + this.normalizeAttributeRule( rules, type, method, value );
1330 + }
1331 + return rules;
1332 + },
1333 +
1334 + staticRules: function( element ) {
1335 + var rules = {},
1336 + validator = $.data( element.form, "validator" );
1337 +
1338 + if ( validator.settings.rules ) {
1339 + rules = $.validator.normalizeRule( validator.settings.rules[ element.name ] ) || {};
1340 + }
1341 + return rules;
1342 + },
1343 +
1344 + normalizeRules: function( rules, element ) {
1345 +
1346 + // Handle dependency check
1347 + $.each( rules, function( prop, val ) {
1348 +
1349 + // Ignore rule when param is explicitly false, eg. required:false
1350 + if ( val === false ) {
1351 + delete rules[ prop ];
1352 + return;
1353 + }
1354 + if ( val.param || val.depends ) {
1355 + var keepRule = true;
1356 + switch ( typeof val.depends ) {
1357 + case "string":
1358 + keepRule = !!$( val.depends, element.form ).length;
1359 + break;
1360 + case "function":
1361 + keepRule = val.depends.call( element, element );
1362 + break;
1363 + }
1364 + if ( keepRule ) {
1365 + rules[ prop ] = val.param !== undefined ? val.param : true;
1366 + } else {
1367 + $.data( element.form, "validator" ).resetElements( $( element ) );
1368 + delete rules[ prop ];
1369 + }
1370 + }
1371 + } );
1372 +
1373 + // Evaluate parameters
1374 + $.each( rules, function( rule, parameter ) {
1375 + rules[ rule ] = typeof parameter === "function" && rule !== "normalizer" ? parameter( element ) : parameter;
1376 + } );
1377 +
1378 + // Clean number parameters
1379 + $.each( [ "minlength", "maxlength" ], function() {
1380 + if ( rules[ this ] ) {
1381 + rules[ this ] = Number( rules[ this ] );
1382 + }
1383 + } );
1384 + $.each( [ "rangelength", "range" ], function() {
1385 + var parts;
1386 + if ( rules[ this ] ) {
1387 + if ( Array.isArray( rules[ this ] ) ) {
1388 + rules[ this ] = [ Number( rules[ this ][ 0 ] ), Number( rules[ this ][ 1 ] ) ];
1389 + } else if ( typeof rules[ this ] === "string" ) {
1390 + parts = rules[ this ].replace( /[\[\]]/g, "" ).split( /[\s,]+/ );
1391 + rules[ this ] = [ Number( parts[ 0 ] ), Number( parts[ 1 ] ) ];
1392 + }
1393 + }
1394 + } );
1395 +
1396 + if ( $.validator.autoCreateRanges ) {
1397 +
1398 + // Auto-create ranges
1399 + if ( rules.min != null && rules.max != null ) {
1400 + rules.range = [ rules.min, rules.max ];
1401 + delete rules.min;
1402 + delete rules.max;
1403 + }
1404 + if ( rules.minlength != null && rules.maxlength != null ) {
1405 + rules.rangelength = [ rules.minlength, rules.maxlength ];
1406 + delete rules.minlength;
1407 + delete rules.maxlength;
1408 + }
1409 + }
1410 +
1411 + return rules;
1412 + },
1413 +
1414 + // Converts a simple string to a {string: true} rule, e.g., "required" to {required:true}
1415 + normalizeRule: function( data ) {
1416 + if ( typeof data === "string" ) {
1417 + var transformed = {};
1418 + $.each( data.split( /\s/ ), function() {
1419 + transformed[ this ] = true;
1420 + } );
1421 + data = transformed;
1422 + }
1423 + return data;
1424 + },
1425 +
1426 + // https://jqueryvalidation.org/jQuery.validator.addMethod/
1427 + addMethod: function( name, method, message ) {
1428 + $.validator.methods[ name ] = method;
1429 + $.validator.messages[ name ] = message !== undefined ? message : $.validator.messages[ name ];
1430 + if ( method.length < 3 ) {
1431 + $.validator.addClassRules( name, $.validator.normalizeRule( name ) );
1432 + }
1433 + },
1434 +
1435 + // https://jqueryvalidation.org/jQuery.validator.methods/
1436 + methods: {
1437 +
1438 + // https://jqueryvalidation.org/required-method/
1439 + required: function( value, element, param ) {
1440 +
1441 + // Check if dependency is met
1442 + if ( !this.depend( param, element ) ) {
1443 + return "dependency-mismatch";
1444 + }
1445 + if ( element.nodeName.toLowerCase() === "select" ) {
1446 +
1447 + // Could be an array for select-multiple or a string, both are fine this way
1448 + var val = $( element ).val();
1449 + return val && val.length > 0;
1450 + }
1451 + if ( this.checkable( element ) ) {
1452 + return this.getLength( value, element ) > 0;
1453 + }
1454 + return value !== undefined && value !== null && value.length > 0;
1455 + },
1456 +
1457 + // https://jqueryvalidation.org/email-method/
1458 + email: function( value, element ) {
1459 +
1460 + // From https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address
1461 + // Retrieved 2014-01-14
1462 + // If you have a problem with this implementation, report a bug against the above spec
1463 + // Or use custom methods to implement your own email validation
1464 + return this.optional( element ) || /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( value );
1465 + },
1466 +
1467 + // https://jqueryvalidation.org/url-method/
1468 + url: function( value, element ) {
1469 +
1470 + // Copyright (c) 2010-2013 Diego Perini, MIT licensed
1471 + // https://gist.github.com/dperini/729294
1472 + // see also https://mathiasbynens.be/demo/url-regex
1473 + // modified to allow protocol-relative URLs
1474 + return this.optional( element ) || /^(?:(?:(?:https?|ftp):)?\/\/)(?:(?:[^\]\[?\/<~#`!@$^&*()+=}|:";',>{ ]|%[0-9A-Fa-f]{2})+(?::(?:[^\]\[?\/<~#`!@$^&*()+=}|:";',>{ ]|%[0-9A-Fa-f]{2})*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test( value );
1475 + },
1476 +
1477 + // https://jqueryvalidation.org/date-method/
1478 + date: ( function() {
1479 + var called = false;
1480 +
1481 + return function( value, element ) {
1482 + if ( !called ) {
1483 + called = true;
1484 + if ( this.settings.debug && window.console ) {
1485 + console.warn(
1486 + "The `date` method is deprecated and will be removed in version '2.0.0'.\n" +
1487 + "Please don't use it, since it relies on the Date constructor, which\n" +
1488 + "behaves very differently across browsers and locales. Use `dateISO`\n" +
1489 + "instead or one of the locale specific methods in `localizations/`\n" +
1490 + "and `additional-methods.js`."
1491 + );
1492 + }
1493 + }
1494 +
1495 + return this.optional( element ) || !/Invalid|NaN/.test( new Date( value ).toString() );
1496 + };
1497 + }() ),
1498 +
1499 + // https://jqueryvalidation.org/dateISO-method/
1500 + dateISO: function( value, element ) {
1501 + return this.optional( element ) || /^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test( value );
1502 + },
1503 +
1504 + // https://jqueryvalidation.org/number-method/
1505 + number: function( value, element ) {
1506 + return this.optional( element ) || /^(?:-?\d+|-?\d{1,3}(?:,\d{3})+)?(?:-?\.\d+)?$/.test( value );
1507 + },
1508 +
1509 + // https://jqueryvalidation.org/digits-method/
1510 + digits: function( value, element ) {
1511 + return this.optional( element ) || /^\d+$/.test( value );
1512 + },
1513 +
1514 + // https://jqueryvalidation.org/minlength-method/
1515 + minlength: function( value, element, param ) {
1516 + var length = Array.isArray( value ) ? value.length : this.getLength( value, element );
1517 + return this.optional( element ) || length >= param;
1518 + },
1519 +
1520 + // https://jqueryvalidation.org/maxlength-method/
1521 + maxlength: function( value, element, param ) {
1522 + var length = Array.isArray( value ) ? value.length : this.getLength( value, element );
1523 + return this.optional( element ) || length <= param;
1524 + },
1525 +
1526 + // https://jqueryvalidation.org/rangelength-method/
1527 + rangelength: function( value, element, param ) {
1528 + var length = Array.isArray( value ) ? value.length : this.getLength( value, element );
1529 + return this.optional( element ) || ( length >= param[ 0 ] && length <= param[ 1 ] );
1530 + },
1531 +
1532 + // https://jqueryvalidation.org/min-method/
1533 + min: function( value, element, param ) {
1534 + return this.optional( element ) || value >= param;
1535 + },
1536 +
1537 + // https://jqueryvalidation.org/max-method/
1538 + max: function( value, element, param ) {
1539 + return this.optional( element ) || value <= param;
1540 + },
1541 +
1542 + // https://jqueryvalidation.org/range-method/
1543 + range: function( value, element, param ) {
1544 + return this.optional( element ) || ( value >= param[ 0 ] && value <= param[ 1 ] );
1545 + },
1546 +
1547 + // https://jqueryvalidation.org/step-method/
1548 + step: function( value, element, param ) {
1549 + var type = $( element ).attr( "type" ),
1550 + errorMessage = "Step attribute on input type " + type + " is not supported.",
1551 + supportedTypes = [ "text", "number", "range" ],
1552 + re = new RegExp( "\\b" + type + "\\b" ),
1553 + notSupported = type && !re.test( supportedTypes.join() ),
1554 + decimalPlaces = function( num ) {
1555 + var match = ( "" + num ).match( /(?:\.(\d+))?$/ );
1556 + if ( !match ) {
1557 + return 0;
1558 + }
1559 +
1560 + // Number of digits right of decimal point.
1561 + return match[ 1 ] ? match[ 1 ].length : 0;
1562 + },
1563 + toInt = function( num ) {
1564 + return Math.round( num * Math.pow( 10, decimals ) );
1565 + },
1566 + valid = true,
1567 + decimals;
1568 +
1569 + // Works only for text, number and range input types
1570 + // TODO find a way to support input types date, datetime, datetime-local, month, time and week
1571 + if ( notSupported ) {
1572 + throw new Error( errorMessage );
1573 + }
1574 +
1575 + decimals = decimalPlaces( param );
1576 +
1577 + // Value can't have too many decimals
1578 + if ( decimalPlaces( value ) > decimals || toInt( value ) % toInt( param ) !== 0 ) {
1579 + valid = false;
1580 + }
1581 +
1582 + return this.optional( element ) || valid;
1583 + },
1584 +
1585 + // https://jqueryvalidation.org/equalTo-method/
1586 + equalTo: function( value, element, param ) {
1587 +
1588 + // Bind to the blur event of the target in order to revalidate whenever the target field is updated
1589 + var target = $( param );
1590 + if ( this.settings.onfocusout && target.not( ".validate-equalTo-blur" ).length ) {
1591 + target.addClass( "validate-equalTo-blur" ).on( "blur.validate-equalTo", function() {
1592 + $( element ).valid();
1593 + } );
1594 + }
1595 + return value === target.val();
1596 + },
1597 +
1598 + // https://jqueryvalidation.org/remote-method/
1599 + remote: function( value, element, param, method ) {
1600 + if ( this.optional( element ) ) {
1601 + return "dependency-mismatch";
1602 + }
1603 +
1604 + method = typeof method === "string" && method || "remote";
1605 +
1606 + var previous = this.previousValue( element, method ),
1607 + validator, data, optionDataString;
1608 +
1609 + if ( !this.settings.messages[ element.name ] ) {
1610 + this.settings.messages[ element.name ] = {};
1611 + }
1612 + previous.originalMessage = previous.originalMessage || this.settings.messages[ element.name ][ method ];
1613 + this.settings.messages[ element.name ][ method ] = previous.message;
1614 +
1615 + param = typeof param === "string" && { url: param } || param;
1616 + optionDataString = $.param( $.extend( { data: value }, param.data ) );
1617 + if ( previous.valid !== null && previous.old === optionDataString ) {
1618 + return previous.valid;
1619 + }
1620 +
1621 + previous.old = optionDataString;
1622 + previous.valid = null;
1623 + validator = this;
1624 + this.startRequest( element );
1625 + data = {};
1626 + data[ element.name ] = value;
1627 + $.ajax( $.extend( true, {
1628 + mode: "abort",
1629 + port: this.elementAjaxPort( element ),
1630 + dataType: "json",
1631 + data: data,
1632 + context: validator.currentForm,
1633 + success: function( response ) {
1634 + var valid = response === true || response === "true",
1635 + errors, message, submitted;
1636 +
1637 + validator.settings.messages[ element.name ][ method ] = previous.originalMessage;
1638 + if ( valid ) {
1639 + submitted = validator.formSubmitted;
1640 + validator.toHide = validator.errorsFor( element );
1641 + validator.formSubmitted = submitted;
1642 + validator.successList.push( element );
1643 + validator.invalid[ element.name ] = false;
1644 + validator.showErrors();
1645 + } else {
1646 + errors = {};
1647 + message = response || validator.defaultMessage( element, { method: method, parameters: value } );
1648 + errors[ element.name ] = previous.message = message;
1649 + validator.invalid[ element.name ] = true;
1650 + validator.showErrors( errors );
1651 + }
1652 + previous.valid = valid;
1653 + validator.stopRequest( element, valid );
1654 + }
1655 + }, param ) );
1656 + return "pending";
1657 + }
1658 + }
1659 +
1660 +} );
1661 +
1662 +// Ajax mode: abort
1663 +// usage: $.ajax({ mode: "abort"[, port: "uniqueport"]});
1664 +// $.ajaxAbort( port );
1665 +// if mode:"abort" is used, the previous request on that port (port can be undefined) is aborted via XMLHttpRequest.abort()
1666 +
1667 +var pendingRequests = {},
1668 + ajax;
1669 +
1670 +// Use a prefilter if available (1.5+)
1671 +if ( $.ajaxPrefilter ) {
1672 + $.ajaxPrefilter( function( settings, _, xhr ) {
1673 + var port = settings.port;
1674 + if ( settings.mode === "abort" ) {
1675 + $.ajaxAbort( port );
1676 + pendingRequests[ port ] = xhr;
1677 + }
1678 + } );
1679 +} else {
1680 +
1681 + // Proxy ajax
1682 + ajax = $.ajax;
1683 + $.ajax = function( settings ) {
1684 + var mode = ( "mode" in settings ? settings : $.ajaxSettings ).mode,
1685 + port = ( "port" in settings ? settings : $.ajaxSettings ).port;
1686 + if ( mode === "abort" ) {
1687 + $.ajaxAbort( port );
1688 + pendingRequests[ port ] = ajax.apply( this, arguments );
1689 + return pendingRequests[ port ];
1690 + }
1691 + return ajax.apply( this, arguments );
1692 + };
1693 +}
1694 +
1695 +// Abort the previous request without sending a new one
1696 +$.ajaxAbort = function( port ) {
1697 + if ( pendingRequests[ port ] ) {
1698 + pendingRequests[ port ].abort();
1699 + delete pendingRequests[ port ];
1700 + }
1701 +};
1702 +return $;
1703 +}));
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/dist/jquery.validate.min.js +4 −0
@@ -0,0 +1,4 @@
1 +/*! jQuery Validation Plugin - v1.21.0 - 7/17/2024
2 + * https://jqueryvalidation.org/
3 + * Copyright (c) 2024 Jörn Zaefferer; Licensed MIT */
4 +!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof module&&module.exports?module.exports=a(require("jquery")):a(jQuery)}(function(a){a.extend(a.fn,{validate:function(b){if(!this.length)return void(b&&b.debug&&window.console&&console.warn("Nothing selected, can't validate, returning nothing."));var c=a.data(this[0],"validator");return c?c:(this.attr("novalidate","novalidate"),c=new a.validator(b,this[0]),a.data(this[0],"validator",c),c.settings.onsubmit&&(this.on("click.validate",":submit",function(b){c.submitButton=b.currentTarget,a(this).hasClass("cancel")&&(c.cancelSubmit=!0),void 0!==a(this).attr("formnovalidate")&&(c.cancelSubmit=!0)}),this.on("submit.validate",function(b){function d(){var d,e;return c.submitButton&&(c.settings.submitHandler||c.formSubmitted)&&(d=a("<input type='hidden'/>").attr("name",c.submitButton.name).val(a(c.submitButton).val()).appendTo(c.currentForm)),!(c.settings.submitHandler&&!c.settings.debug)||(e=c.settings.submitHandler.call(c,c.currentForm,b),d&&d.remove(),void 0!==e&&e)}return c.settings.debug&&b.preventDefault(),c.cancelSubmit?(c.cancelSubmit=!1,d()):c.form()?c.pendingRequest?(c.formSubmitted=!0,!1):d():(c.focusInvalid(),!1)})),c)},valid:function(){var b,c,d;return a(this[0]).is("form")?b=this.validate().form():(d=[],b=!0,c=a(this[0].form).validate(),this.each(function(){b=c.element(this)&&b,b||(d=d.concat(c.errorList))}),c.errorList=d),b},rules:function(b,c){var d,e,f,g,h,i,j=this[0],k="undefined"!=typeof this.attr("contenteditable")&&"false"!==this.attr("contenteditable");if(null!=j&&(!j.form&&k&&(j.form=this.closest("form")[0],j.name=this.attr("name")),null!=j.form)){if(b)switch(d=a.data(j.form,"validator").settings,e=d.rules,f=a.validator.staticRules(j),b){case"add":a.extend(f,a.validator.normalizeRule(c)),delete f.messages,e[j.name]=f,c.messages&&(d.messages[j.name]=a.extend(d.messages[j.name],c.messages));break;case"remove":return c?(i={},a.each(c.split(/\s/),function(a,b){i[b]=f[b],delete f[b]}),i):(delete e[j.name],f)}return g=a.validator.normalizeRules(a.extend({},a.validator.classRules(j),a.validator.attributeRules(j),a.validator.dataRules(j),a.validator.staticRules(j)),j),g.required&&(h=g.required,delete g.required,g=a.extend({required:h},g)),g.remote&&(h=g.remote,delete g.remote,g=a.extend(g,{remote:h})),g}}});var b=function(a){return a.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")};a.extend(a.expr.pseudos||a.expr[":"],{blank:function(c){return!b(""+a(c).val())},filled:function(c){var d=a(c).val();return null!==d&&!!b(""+d)},unchecked:function(b){return!a(b).prop("checked")}}),a.validator=function(b,c){this.settings=a.extend(!0,{},a.validator.defaults,b),this.currentForm=c,this.init()},a.validator.format=function(b,c){return 1===arguments.length?function(){var c=a.makeArray(arguments);return c.unshift(b),a.validator.format.apply(this,c)}:void 0===c?b:(arguments.length>2&&c.constructor!==Array&&(c=a.makeArray(arguments).slice(1)),c.constructor!==Array&&(c=[c]),a.each(c,function(a,c){b=b.replace(new RegExp("\\{"+a+"\\}","g"),function(){return c})}),b)},a.extend(a.validator,{defaults:{messages:{},groups:{},rules:{},errorClass:"error",pendingClass:"pending",validClass:"valid",errorElement:"label",focusCleanup:!1,focusInvalid:!0,errorContainer:a([]),errorLabelContainer:a([]),onsubmit:!0,ignore:":hidden",ignoreTitle:!1,customElements:[],onfocusin:function(a){this.lastActive=a,this.settings.focusCleanup&&(this.settings.unhighlight&&this.settings.unhighlight.call(this,a,this.settings.errorClass,this.settings.validClass),this.hideThese(this.errorsFor(a)))},onfocusout:function(a){this.checkable(a)||!(a.name in this.submitted)&&this.optional(a)||this.element(a)},onkeyup:function(b,c){var d=[16,17,18,20,35,36,37,38,39,40,45,144,225];9===c.which&&""===this.elementValue(b)||a.inArray(c.keyCode,d)!==-1||(b.name in this.submitted||b.name in this.invalid)&&this.element(b)},onclick:function(a){a.name in this.submitted?this.element(a):a.parentNode.name in this.submitted&&this.element(a.parentNode)},highlight:function(b,c,d){"radio"===b.type?this.findByName(b.name).addClass(c).removeClass(d):a(b).addClass(c).removeClass(d)},unhighlight:function(b,c,d){"radio"===b.type?this.findByName(b.name).removeClass(c).addClass(d):a(b).removeClass(c).addClass(d)}},setDefaults:function(b){a.extend(a.validator.defaults,b)},messages:{required:"This field is required.",remote:"Please fix this field.",email:"Please enter a valid email address.",url:"Please enter a valid URL.",date:"Please enter a valid date.",dateISO:"Please enter a valid date (ISO).",number:"Please enter a valid number.",digits:"Please enter only digits.",equalTo:"Please enter the same value again.",maxlength:a.validator.format("Please enter no more than {0} characters."),minlength:a.validator.format("Please enter at least {0} characters."),rangelength:a.validator.format("Please enter a value between {0} and {1} characters long."),range:a.validator.format("Please enter a value between {0} and {1}."),max:a.validator.format("Please enter a value less than or equal to {0}."),min:a.validator.format("Please enter a value greater than or equal to {0}."),step:a.validator.format("Please enter a multiple of {0}.")},autoCreateRanges:!1,prototype:{init:function(){function b(b){var c="undefined"!=typeof a(this).attr("contenteditable")&&"false"!==a(this).attr("contenteditable");if(!this.form&&c&&(this.form=a(this).closest("form")[0],this.name=a(this).attr("name")),d===this.form){var e=a.data(this.form,"validator"),f="on"+b.type.replace(/^validate/,""),g=e.settings;g[f]&&!a(this).is(g.ignore)&&g[f].call(e,this,b)}}this.labelContainer=a(this.settings.errorLabelContainer),this.errorContext=this.labelContainer.length&&this.labelContainer||a(this.currentForm),this.containers=a(this.settings.errorContainer).add(this.settings.errorLabelContainer),this.submitted={},this.valueCache={},this.pendingRequest=0,this.pending={},this.invalid={},this.reset();var c,d=this.currentForm,e=this.groups={};a.each(this.settings.groups,function(b,c){"string"==typeof c&&(c=c.split(/\s/)),a.each(c,function(a,c){e[c]=b})}),c=this.settings.rules,a.each(c,function(b,d){c[b]=a.validator.normalizeRule(d)});var f=[":text","[type='password']","[type='file']","select","textarea","[type='number']","[type='search']","[type='tel']","[type='url']","[type='email']","[type='datetime']","[type='date']","[type='month']","[type='week']","[type='time']","[type='datetime-local']","[type='range']","[type='color']","[type='radio']","[type='checkbox']","[contenteditable]","[type='button']"],g=["select","option","[type='radio']","[type='checkbox']"];a(this.currentForm).on("focusin.validate focusout.validate keyup.validate",f.concat(this.settings.customElements).join(", "),b).on("click.validate",g.concat(this.settings.customElements).join(", "),b),this.settings.invalidHandler&&a(this.currentForm).on("invalid-form.validate",this.settings.invalidHandler)},form:function(){return this.checkForm(),a.extend(this.submitted,this.errorMap),this.invalid=a.extend({},this.errorMap),this.valid()||a(this.currentForm).triggerHandler("invalid-form",[this]),this.showErrors(),this.valid()},checkForm:function(){this.prepareForm();for(var a=0,b=this.currentElements=this.elements();b[a];a++)this.check(b[a]);return this.valid()},element:function(b){var c,d,e=this.clean(b),f=this.validationTargetFor(e),g=this,h=!0;return void 0===f?delete this.invalid[e.name]:(this.prepareElement(f),this.currentElements=a(f),d=this.groups[f.name],d&&a.each(this.groups,function(a,b){b===d&&a!==f.name&&(e=g.validationTargetFor(g.clean(g.findByName(a))),e&&e.name in g.invalid&&(g.currentElements.push(e),h=g.check(e)&&h))}),c=this.check(f)!==!1,h=h&&c,c?this.invalid[f.name]=!1:this.invalid[f.name]=!0,this.numberOfInvalids()||(this.toHide=this.toHide.add(this.containers)),this.showErrors(),a(b).attr("aria-invalid",!c)),h},showErrors:function(b){if(b){var c=this;a.extend(this.errorMap,b),this.errorList=a.map(this.errorMap,function(a,b){return{message:a,element:c.findByName(b)[0]}}),this.successList=a.grep(this.successList,function(a){return!(a.name in b)})}this.settings.showErrors?this.settings.showErrors.call(this,this.errorMap,this.errorList):this.defaultShowErrors()},resetForm:function(){a.fn.resetForm&&a(this.currentForm).resetForm(),this.invalid={},this.submitted={},this.prepareForm(),this.hideErrors();var b=this.elements().removeData("previousValue").removeAttr("aria-invalid");this.resetElements(b)},resetElements:function(a){var b;if(this.settings.unhighlight)for(b=0;a[b];b++)this.settings.unhighlight.call(this,a[b],this.settings.errorClass,""),this.findByName(a[b].name).removeClass(this.settings.validClass);else a.removeClass(this.settings.errorClass).removeClass(this.settings.validClass)},numberOfInvalids:function(){return this.objectLength(this.invalid)},objectLength:function(a){var b,c=0;for(b in a)void 0!==a[b]&&null!==a[b]&&a[b]!==!1&&c++;return c},hideErrors:function(){this.hideThese(this.toHide)},hideThese:function(a){a.not(this.containers).text(""),this.addWrapper(a).hide()},valid:function(){return 0===this.size()},size:function(){return this.errorList.length},focusInvalid:function(){if(this.settings.focusInvalid)try{a(this.findLastActive()||this.errorList.length&&this.errorList[0].element||[]).filter(":visible").trigger("focus").trigger("focusin")}catch(b){}},findLastActive:function(){var b=this.lastActive;return b&&1===a.grep(this.errorList,function(a){return a.element.name===b.name}).length&&b},elements:function(){var b=this,c={},d=["input","select","textarea","[contenteditable]"];return a(this.currentForm).find(d.concat(this.settings.customElements).join(", ")).not(":submit, :reset, :image, :disabled").not(this.settings.ignore).filter(function(){var d=this.name||a(this).attr("name"),e="undefined"!=typeof a(this).attr("contenteditable")&&"false"!==a(this).attr("contenteditable");return!d&&b.settings.debug&&window.console&&console.error("%o has no name assigned",this),e&&(this.form=a(this).closest("form")[0],this.name=d),this.form===b.currentForm&&(!(d in c||!b.objectLength(a(this).rules()))&&(c[d]=!0,!0))})},clean:function(b){return a(b)[0]},errors:function(){var b=this.settings.errorClass.split(" ").join(".");return a(this.settings.errorElement+"."+b,this.errorContext)},resetInternals:function(){this.successList=[],this.errorList=[],this.errorMap={},this.toShow=a([]),this.toHide=a([])},reset:function(){this.resetInternals(),this.currentElements=a([])},prepareForm:function(){this.reset(),this.toHide=this.errors().add(this.containers)},prepareElement:function(a){this.reset(),this.toHide=this.errorsFor(a)},elementValue:function(b){var c,d,e=a(b),f=b.type,g="undefined"!=typeof e.attr("contenteditable")&&"false"!==e.attr("contenteditable");return"radio"===f||"checkbox"===f?this.findByName(b.name).filter(":checked").val():"number"===f&&"undefined"!=typeof b.validity?b.validity.badInput?"NaN":e.val():(c=g?e.text():e.val(),"file"===f?"C:\\fakepath\\"===c.substr(0,12)?c.substr(12):(d=c.lastIndexOf("/"),d>=0?c.substr(d+1):(d=c.lastIndexOf("\\"),d>=0?c.substr(d+1):c)):"string"==typeof c?c.replace(/\r/g,""):c)},check:function(b){b=this.validationTargetFor(this.clean(b));var c,d,e,f,g=a(b).rules(),h=a.map(g,function(a,b){return b}).length,i=!1,j=this.elementValue(b);this.abortRequest(b),"function"==typeof g.normalizer?f=g.normalizer:"function"==typeof this.settings.normalizer&&(f=this.settings.normalizer),f&&(j=f.call(b,j),delete g.normalizer);for(d in g){e={method:d,parameters:g[d]};try{if(c=a.validator.methods[d].call(this,j,b,e.parameters),"dependency-mismatch"===c&&1===h){i=!0;continue}if(i=!1,"pending"===c)return void(this.toHide=this.toHide.not(this.errorsFor(b)));if(!c)return this.formatAndAdd(b,e),!1}catch(k){throw this.settings.debug&&window.console&&console.log("Exception occurred when checking element "+b.id+", check the '"+e.method+"' method.",k),k instanceof TypeError&&(k.message+=". Exception occurred when checking element "+b.id+", check the '"+e.method+"' method."),k}}if(!i)return this.objectLength(g)&&this.successList.push(b),!0},customDataMessage:function(b,c){return a(b).data("msg"+c.charAt(0).toUpperCase()+c.substring(1).toLowerCase())||a(b).data("msg")},customMessage:function(a,b){var c=this.settings.messages[a];return c&&(c.constructor===String?c:c[b])},findDefined:function(){for(var a=0;a<arguments.length;a++)if(void 0!==arguments[a])return arguments[a]},defaultMessage:function(b,c){"string"==typeof c&&(c={method:c});var d=this.findDefined(this.customMessage(b.name,c.method),this.customDataMessage(b,c.method),!this.settings.ignoreTitle&&b.title||void 0,a.validator.messages[c.method],"<strong>Warning: No message defined for "+b.name+"</strong>"),e=/\$?\{(\d+)\}/g;return"function"==typeof d?d=d.call(this,c.parameters,b):e.test(d)&&(d=a.validator.format(d.replace(e,"{$1}"),c.parameters)),d},formatAndAdd:function(a,b){var c=this.defaultMessage(a,b);this.errorList.push({message:c,element:a,method:b.method}),this.errorMap[a.name]=c,this.submitted[a.name]=c},addWrapper:function(a){return this.settings.wrapper&&(a=a.add(a.parent(this.settings.wrapper))),a},defaultShowErrors:function(){var a,b,c;for(a=0;this.errorList[a];a++)c=this.errorList[a],this.settings.highlight&&this.settings.highlight.call(this,c.element,this.settings.errorClass,this.settings.validClass),this.showLabel(c.element,c.message);if(this.errorList.length&&(this.toShow=this.toShow.add(this.containers)),this.settings.success)for(a=0;this.successList[a];a++)this.showLabel(this.successList[a]);if(this.settings.unhighlight)for(a=0,b=this.validElements();b[a];a++)this.settings.unhighlight.call(this,b[a],this.settings.errorClass,this.settings.validClass);this.toHide=this.toHide.not(this.toShow),this.hideErrors(),this.addWrapper(this.toShow).show()},validElements:function(){return this.currentElements.not(this.invalidElements())},invalidElements:function(){return a(this.errorList).map(function(){return this.element})},showLabel:function(b,c){var d,e,f,g,h=this.errorsFor(b),i=this.idOrName(b),j=a(b).attr("aria-describedby");h.length?(h.removeClass(this.settings.validClass).addClass(this.settings.errorClass),this.settings&&this.settings.escapeHtml?h.text(c||""):h.html(c||"")):(h=a("<"+this.settings.errorElement+">").attr("id",i+"-error").addClass(this.settings.errorClass),this.settings&&this.settings.escapeHtml?h.text(c||""):h.html(c||""),d=h,this.settings.wrapper&&(d=h.hide().show().wrap("<"+this.settings.wrapper+"/>").parent()),this.labelContainer.length?this.labelContainer.append(d):this.settings.errorPlacement?this.settings.errorPlacement.call(this,d,a(b)):d.insertAfter(b),h.is("label")?h.attr("for",i):0===h.parents("label[for='"+this.escapeCssMeta(i)+"']").length&&(f=h.attr("id"),j?j.match(new RegExp("\\b"+this.escapeCssMeta(f)+"\\b"))||(j+=" "+f):j=f,a(b).attr("aria-describedby",j),e=this.groups[b.name],e&&(g=this,a.each(g.groups,function(b,c){c===e&&a("[name='"+g.escapeCssMeta(b)+"']",g.currentForm).attr("aria-describedby",h.attr("id"))})))),!c&&this.settings.success&&(h.text(""),"string"==typeof this.settings.success?h.addClass(this.settings.success):this.settings.success(h,b)),this.toShow=this.toShow.add(h)},errorsFor:function(b){var c=this.escapeCssMeta(this.idOrName(b)),d=a(b).attr("aria-describedby"),e="label[for='"+c+"'], label[for='"+c+"'] *";return d&&(e=e+", #"+this.escapeCssMeta(d).replace(/\s+/g,", #")),this.errors().filter(e)},escapeCssMeta:function(a){return void 0===a?"":a.replace(/([\\!"#$%&'()*+,./:;<=>?@\[\]^`{|}~])/g,"\\$1")},idOrName:function(a){return this.groups[a.name]||(this.checkable(a)?a.name:a.id||a.name)},validationTargetFor:function(b){return this.checkable(b)&&(b=this.findByName(b.name)),a(b).not(this.settings.ignore)[0]},checkable:function(a){return/radio|checkbox/i.test(a.type)},findByName:function(b){return a(this.currentForm).find("[name='"+this.escapeCssMeta(b)+"']")},getLength:function(b,c){switch(c.nodeName.toLowerCase()){case"select":return a("option:selected",c).length;case"input":if(this.checkable(c))return this.findByName(c.name).filter(":checked").length}return b.length},depend:function(a,b){return!this.dependTypes[typeof a]||this.dependTypes[typeof a](a,b)},dependTypes:{"boolean":function(a){return a},string:function(b,c){return!!a(b,c.form).length},"function":function(a,b){return a(b)}},optional:function(b){var c=this.elementValue(b);return!a.validator.methods.required.call(this,c,b)&&"dependency-mismatch"},elementAjaxPort:function(a){return"validate"+a.name},startRequest:function(b){this.pending[b.name]||(this.pendingRequest++,a(b).addClass(this.settings.pendingClass),this.pending[b.name]=!0)},stopRequest:function(b,c){this.pendingRequest--,this.pendingRequest<0&&(this.pendingRequest=0),delete this.pending[b.name],a(b).removeClass(this.settings.pendingClass),c&&0===this.pendingRequest&&this.formSubmitted&&this.form()&&0===this.pendingRequest?(a(this.currentForm).trigger("submit"),this.submitButton&&a("input:hidden[name='"+this.submitButton.name+"']",this.currentForm).remove(),this.formSubmitted=!1):!c&&0===this.pendingRequest&&this.formSubmitted&&(a(this.currentForm).triggerHandler("invalid-form",[this]),this.formSubmitted=!1)},abortRequest:function(b){var c;this.pending[b.name]&&(c=this.elementAjaxPort(b),a.ajaxAbort(c),this.pendingRequest--,this.pendingRequest<0&&(this.pendingRequest=0),delete this.pending[b.name],a(b).removeClass(this.settings.pendingClass))},previousValue:function(b,c){return c="string"==typeof c&&c||"remote",a.data(b,"previousValue")||a.data(b,"previousValue",{old:null,valid:!0,message:this.defaultMessage(b,{method:c})})},destroy:function(){this.resetForm(),a(this.currentForm).off(".validate").removeData("validator").find(".validate-equalTo-blur").off(".validate-equalTo").removeClass("validate-equalTo-blur").find(".validate-lessThan-blur").off(".validate-lessThan").removeClass("validate-lessThan-blur").find(".validate-lessThanEqual-blur").off(".validate-lessThanEqual").removeClass("validate-lessThanEqual-blur").find(".validate-greaterThanEqual-blur").off(".validate-greaterThanEqual").removeClass("validate-greaterThanEqual-blur").find(".validate-greaterThan-blur").off(".validate-greaterThan").removeClass("validate-greaterThan-blur")}},classRuleSettings:{required:{required:!0},email:{email:!0},url:{url:!0},date:{date:!0},dateISO:{dateISO:!0},number:{number:!0},digits:{digits:!0},creditcard:{creditcard:!0}},addClassRules:function(b,c){b.constructor===String?this.classRuleSettings[b]=c:a.extend(this.classRuleSettings,b)},classRules:function(b){var c={},d=a(b).attr("class");return d&&a.each(d.split(" "),function(){this in a.validator.classRuleSettings&&a.extend(c,a.validator.classRuleSettings[this])}),c},normalizeAttributeRule:function(a,b,c,d){/min|max|step/.test(c)&&(null===b||/number|range|text/.test(b))&&(d=Number(d),isNaN(d)&&(d=void 0)),d||0===d?a[c]=d:b===c&&"range"!==b&&(a["date"===b?"dateISO":c]=!0)},attributeRules:function(b){var c,d,e={},f=a(b),g=b.getAttribute("type");for(c in a.validator.methods)"required"===c?(d=b.getAttribute(c),""===d&&(d=!0),d=!!d):d=f.attr(c),this.normalizeAttributeRule(e,g,c,d);return e.maxlength&&/-1|2147483647|524288/.test(e.maxlength)&&delete e.maxlength,e},dataRules:function(b){var c,d,e={},f=a(b),g=b.getAttribute("type");for(c in a.validator.methods)d=f.data("rule"+c.charAt(0).toUpperCase()+c.substring(1).toLowerCase()),""===d&&(d=!0),this.normalizeAttributeRule(e,g,c,d);return e},staticRules:function(b){var c={},d=a.data(b.form,"validator");return d.settings.rules&&(c=a.validator.normalizeRule(d.settings.rules[b.name])||{}),c},normalizeRules:function(b,c){return a.each(b,function(d,e){if(e===!1)return void delete b[d];if(e.param||e.depends){var f=!0;switch(typeof e.depends){case"string":f=!!a(e.depends,c.form).length;break;case"function":f=e.depends.call(c,c)}f?b[d]=void 0===e.param||e.param:(a.data(c.form,"validator").resetElements(a(c)),delete b[d])}}),a.each(b,function(a,d){b[a]="function"==typeof d&&"normalizer"!==a?d(c):d}),a.each(["minlength","maxlength"],function(){b[this]&&(b[this]=Number(b[this]))}),a.each(["rangelength","range"],function(){var a;b[this]&&(Array.isArray(b[this])?b[this]=[Number(b[this][0]),Number(b[this][1])]:"string"==typeof b[this]&&(a=b[this].replace(/[\[\]]/g,"").split(/[\s,]+/),b[this]=[Number(a[0]),Number(a[1])]))}),a.validator.autoCreateRanges&&(null!=b.min&&null!=b.max&&(b.range=[b.min,b.max],delete b.min,delete b.max),null!=b.minlength&&null!=b.maxlength&&(b.rangelength=[b.minlength,b.maxlength],delete b.minlength,delete b.maxlength)),b},normalizeRule:function(b){if("string"==typeof b){var c={};a.each(b.split(/\s/),function(){c[this]=!0}),b=c}return b},addMethod:function(b,c,d){a.validator.methods[b]=c,a.validator.messages[b]=void 0!==d?d:a.validator.messages[b],c.length<3&&a.validator.addClassRules(b,a.validator.normalizeRule(b))},methods:{required:function(b,c,d){if(!this.depend(d,c))return"dependency-mismatch";if("select"===c.nodeName.toLowerCase()){var e=a(c).val();return e&&e.length>0}return this.checkable(c)?this.getLength(b,c)>0:void 0!==b&&null!==b&&b.length>0},email:function(a,b){return this.optional(b)||/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(a)},url:function(a,b){return this.optional(b)||/^(?:(?:(?:https?|ftp):)?\/\/)(?:(?:[^\]\[?\/<~#`!@$^&*()+=}|:";',>{ ]|%[0-9A-Fa-f]{2})+(?::(?:[^\]\[?\/<~#`!@$^&*()+=}|:";',>{ ]|%[0-9A-Fa-f]{2})*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z0-9\u00a1-\uffff][a-z0-9\u00a1-\uffff_-]{0,62})?[a-z0-9\u00a1-\uffff]\.)+(?:[a-z\u00a1-\uffff]{2,}\.?))(?::\d{2,5})?(?:[/?#]\S*)?$/i.test(a)},date:function(){var a=!1;return function(b,c){return a||(a=!0,this.settings.debug&&window.console&&console.warn("The `date` method is deprecated and will be removed in version '2.0.0'.\nPlease don't use it, since it relies on the Date constructor, which\nbehaves very differently across browsers and locales. Use `dateISO`\ninstead or one of the locale specific methods in `localizations/`\nand `additional-methods.js`.")),this.optional(c)||!/Invalid|NaN/.test(new Date(b).toString())}}(),dateISO:function(a,b){return this.optional(b)||/^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test(a)},number:function(a,b){return this.optional(b)||/^(?:-?\d+|-?\d{1,3}(?:,\d{3})+)?(?:-?\.\d+)?$/.test(a)},digits:function(a,b){return this.optional(b)||/^\d+$/.test(a)},minlength:function(a,b,c){var d=Array.isArray(a)?a.length:this.getLength(a,b);return this.optional(b)||d>=c},maxlength:function(a,b,c){var d=Array.isArray(a)?a.length:this.getLength(a,b);return this.optional(b)||d<=c},rangelength:function(a,b,c){var d=Array.isArray(a)?a.length:this.getLength(a,b);return this.optional(b)||d>=c[0]&&d<=c[1]},min:function(a,b,c){return this.optional(b)||a>=c},max:function(a,b,c){return this.optional(b)||a<=c},range:function(a,b,c){return this.optional(b)||a>=c[0]&&a<=c[1]},step:function(b,c,d){var e,f=a(c).attr("type"),g="Step attribute on input type "+f+" is not supported.",h=["text","number","range"],i=new RegExp("\\b"+f+"\\b"),j=f&&!i.test(h.join()),k=function(a){var b=(""+a).match(/(?:\.(\d+))?$/);return b&&b[1]?b[1].length:0},l=function(a){return Math.round(a*Math.pow(10,e))},m=!0;if(j)throw new Error(g);return e=k(d),(k(b)>e||l(b)%l(d)!==0)&&(m=!1),this.optional(c)||m},equalTo:function(b,c,d){var e=a(d);return this.settings.onfocusout&&e.not(".validate-equalTo-blur").length&&e.addClass("validate-equalTo-blur").on("blur.validate-equalTo",function(){a(c).valid()}),b===e.val()},remote:function(b,c,d,e){if(this.optional(c))return"dependency-mismatch";e="string"==typeof e&&e||"remote";var f,g,h,i=this.previousValue(c,e);return this.settings.messages[c.name]||(this.settings.messages[c.name]={}),i.originalMessage=i.originalMessage||this.settings.messages[c.name][e],this.settings.messages[c.name][e]=i.message,d="string"==typeof d&&{url:d}||d,h=a.param(a.extend({data:b},d.data)),null!==i.valid&&i.old===h?i.valid:(i.old=h,i.valid=null,f=this,this.startRequest(c),g={},g[c.name]=b,a.ajax(a.extend(!0,{mode:"abort",port:this.elementAjaxPort(c),dataType:"json",data:g,context:f.currentForm,success:function(a){var d,g,h,j=a===!0||"true"===a;f.settings.messages[c.name][e]=i.originalMessage,j?(h=f.formSubmitted,f.toHide=f.errorsFor(c),f.formSubmitted=h,f.successList.push(c),f.invalid[c.name]=!1,f.showErrors()):(d={},g=a||f.defaultMessage(c,{method:e,parameters:b}),d[c.name]=i.message=g,f.invalid[c.name]=!0,f.showErrors(d)),i.valid=j,f.stopRequest(c,j)}},d)),"pending")}}});var c,d={};return a.ajaxPrefilter?a.ajaxPrefilter(function(b,c,e){var f=b.port;"abort"===b.mode&&(a.ajaxAbort(f),d[f]=e)}):(c=a.ajax,a.ajax=function(b){var e=("mode"in b?b:a.ajaxSettings).mode,f=("port"in b?b:a.ajaxSettings).port;return"abort"===e?(a.ajaxAbort(f),d[f]=c.apply(this,arguments),d[f]):c.apply(this,arguments)}),a.ajaxAbort=function(a){d[a]&&(d[a].abort(),delete d[a])},a});
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/LICENSE.txt +21 −0
@@ -0,0 +1,21 @@
1 +
2 +Copyright OpenJS Foundation and other contributors, https://openjsf.org/
3 +
4 +Permission is hereby granted, free of charge, to any person obtaining
5 +a copy of this software and associated documentation files (the
6 +"Software"), to deal in the Software without restriction, including
7 +without limitation the rights to use, copy, modify, merge, publish,
8 +distribute, sublicense, and/or sell copies of the Software, and to
9 +permit persons to whom the Software is furnished to do so, subject to
10 +the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be
13 +included in all copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18 +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
19 +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
20 +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21 +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
No newline at end of file
added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.js +0 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.min.js +2 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.min.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.slim.js +8617 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.slim.min.js +2 −0

Line changes are not available for this file.

added SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/dist/jquery.slim.min.map +1 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/CurrencyConverterTests.cs +61 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/SplitApp.Modules.Expenses.Tests.csproj +25 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/LangStrTests.cs +86 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/SplitApp.Modules.Trips.Tests.csproj +25 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/IdentityHelpersTests.cs +105 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/SplitApp.Modules.Users.Tests.csproj +25 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.Shared.Messaging.Tests/IntegrationContractTests.cs +103 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.Shared.Messaging.Tests/SplitApp.Shared.Messaging.Tests.csproj +23 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/CrossModuleNavigationTests.cs +90 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/DbContextSchemaIsolationTests.cs +70 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs +87 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs +59 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostFeatureTests.cs +87 −0

Line changes are not available for this file.

added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/SplitApp.WebApp.IntegrationTests.csproj +29 −0

Line changes are not available for this file.

added architecture.md +263 −0

Line changes are not available for this file.

added docker-compose.yml +93 −0
@@ -0,0 +1,93 @@
1 +services:
2 + rabbitmq:
3 + image: rabbitmq:3-management
4 + container_name: phase4-rabbitmq
5 + restart: unless-stopped
6 + ports:
7 + - "5672:5672"
8 + - "15672:15672"
9 + healthcheck:
10 + test: ["CMD", "rabbitmq-diagnostics", "ping"]
11 + interval: 10s
12 + retries: 10
13 +
14 + db-monolith:
15 + image: postgres:16
16 + container_name: phase4-db-monolith
17 + restart: unless-stopped
18 + environment:
19 + POSTGRES_DB: splitapp
20 + POSTGRES_USER: postgres
21 + POSTGRES_PASSWORD: postgres
22 + volumes:
23 + - phase4-monolith-pgdata:/var/lib/postgresql/data
24 +
25 + db-users:
26 + image: postgres:16
27 + container_name: phase4-db-users
28 + restart: unless-stopped
29 + environment:
30 + POSTGRES_DB: splitapp_users
31 + POSTGRES_USER: postgres
32 + POSTGRES_PASSWORD: postgres
33 + volumes:
34 + - phase4-users-pgdata:/var/lib/postgresql/data
35 +
36 + users-service:
37 + build:
38 + context: .
39 + dockerfile: Dockerfile.usersservice
40 + container_name: phase4-users-service
41 + restart: unless-stopped
42 + ports:
43 + - "98:8080"
44 + environment:
45 + - ConnectionStrings__DefaultConnection=Host=db-users;Port=5432;Database=splitapp_users;Username=postgres;Password=postgres
46 + - JWT__Key=dev-only-signing-key-override-in-production-0123456789
47 + - JWT__Issuer=splitapp
48 + - JWT__Audience=splitapp
49 + - JWT__ExpiresInSeconds=1800
50 + - Messaging__RabbitMq__HostName=rabbitmq
51 + - ASPNETCORE_URLS=http://+:8080
52 + healthcheck:
53 + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
54 + interval: 5s
55 + retries: 30
56 + start_period: 30s
57 + depends_on:
58 + rabbitmq:
59 + condition: service_healthy
60 + db-users:
61 + condition: service_started
62 +
63 + webapp:
64 + build:
65 + context: .
66 + dockerfile: Dockerfile
67 + container_name: phase4-webapp
68 + restart: unless-stopped
69 + ports:
70 + - "97:8080"
71 + environment:
72 + - ConnectionStrings__DefaultConnection=Host=db-monolith;Port=5432;Database=splitapp;Username=postgres;Password=postgres
73 + - JWT__Key=dev-only-signing-key-override-in-production-0123456789
74 + - JWT__Issuer=splitapp
75 + - JWT__Audience=splitapp
76 + - JWT__ExpiresInSeconds=1800
77 + - Messaging__RabbitMq__HostName=rabbitmq
78 + - UsersService__BaseUrl=http://users-service:8080
79 + - ASPNETCORE_URLS=http://+:8080
80 + volumes:
81 + - phase4-webapp-keys:/app/keys
82 + depends_on:
83 + rabbitmq:
84 + condition: service_healthy
85 + users-service:
86 + condition: service_healthy
87 + db-monolith:
88 + condition: service_started
89 +
90 +volumes:
91 + phase4-monolith-pgdata:
92 + phase4-users-pgdata:
93 + phase4-webapp-keys: