Commit
Initial commit: SplitApp REST API, Modular Monolith architecture
commit
5549f85
418 changed files with +32138 and −0
Jump to a changed file
- .dockerignore +20 −0
- .gitignore +87 −0
- .gitlab-ci.yml +11 −0
- Dockerfile +40 −0
- LICENSE +21 −0
- README.md +215 −0
- SplitApp.Modular/Directory.Build.props +9 −0
- SplitApp.Modular/README.md +96 −0
- SplitApp.Modular/SplitApp.sln +326 −0
- SplitApp.Modular/docs/ARCHITECTURE.md +191 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor +3 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Component1.razor.css +6 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/CurrenciesController.cs +39 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/ExpensesController.cs +278 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SettlementsController.cs +290 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Controllers/SplitPresetsController.cs +208 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/CurrencyDto.cs +9 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/ExpenseDto.cs +49 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SettlementDto.cs +37 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/Dto/v1/SplitPresetDto.cs +35 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/ExampleJsInterop.cs +31 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/SplitApp.Modules.Expenses.Api.csproj +26 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/_Imports.razor +1 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/background.png +0 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/wwwroot/exampleJsInterop.js +6 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/Contracts/IExpensesUnitOfWork.cs +15 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/CurrencyConverter.cs +22 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/ExpensesModuleMarker.cs +4 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/SplitApp.Modules.Expenses.Application.csproj +19 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Currency.cs +16 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/Expense.cs +39 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/ExpenseSplit.cs +19 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPayment.cs +30 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SettlementPlan.cs +27 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPreset.cs +27 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/SplitPresetMember.cs +19 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/EPaymentStatus.cs +8 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESettlementStatus.cs +8 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/Enums/ESplitMethod.cs +9 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/SplitApp.Modules.Expenses.Domain.csproj +14 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/ExpensesModuleExtensions.cs +64 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/CalculateSettlementHandler.cs +113 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetBudgetCategorySpentHandler.cs +30 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetCurrenciesByIdsHandler.cs +28 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/GetTripExpenseTotalsHandler.cs +34 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/RemoveSettlementPlanHandler.cs +37 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/TripDeletedHandler.cs +55 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Handlers/UserDeletedHandler.cs +27 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesDbContext.cs +78 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/ExpensesUnitOfWork.cs +33 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.Designer.cs +330 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/20260430135020_Init.cs +235 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Migrations/ExpensesDbContextModelSnapshot.cs +327 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/Repositories/ExpensesBaseRepository.cs +29 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
- SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/SplitApp.Modules.Expenses.Infrastructure.csproj +30 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor +3 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Component1.razor.css +6 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/BudgetCategoriesController.cs +146 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/InvitationsController.cs +198 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/PollsController.cs +255 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/TripsController.cs +328 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Controllers/WishlistController.cs +242 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/BudgetCategoryDto.cs +21 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/InvitationDto.cs +17 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/PollDto.cs +36 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/TripDto.cs +53 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/Dto/v1/WishlistItemDto.cs +32 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/ExampleJsInterop.cs +31 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/SplitApp.Modules.Trips.Api.csproj +26 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/_Imports.razor +1 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/background.png +0 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/wwwroot/exampleJsInterop.js +6 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Contracts/ITripsUnitOfWork.cs +18 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/Handlers/GetTripByIdHandler.cs +23 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/SplitApp.Modules.Trips.Application.csproj +19 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/TripsModuleMarker.cs +4 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/BudgetCategory.cs +25 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/Trip.cs +56 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripInvitation.cs +24 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripParticipant.cs +27 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPoll.cs +26 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollOption.cs +17 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripPollVote.cs +14 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistItem.cs +40 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/TripWishlistVote.cs +16 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EInvitationStatus.cs +10 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EParticipantRole.cs +7 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/ETripStatus.cs +9 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistCategory.cs +9 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/Enums/EWishlistPriority.cs +8 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/SplitApp.Modules.Trips.Domain.csproj +15 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetBudgetCategoryNamesByIdsHandler.cs +30 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/GetTripParticipantsHandler.cs +27 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/IsTripParticipantHandler.cs +25 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/SettlementPlanCompletedHandler.cs +32 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Handlers/UserDeletedHandler.cs +35 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.Designer.cs +495 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/20260430134536_Init.cs +350 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Migrations/TripsDbContextModelSnapshot.cs +492 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/Repositories/TripsBaseRepository.cs +29 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsDbContext.cs +96 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/TripsUnitOfWork.cs +38 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/SplitApp.Modules.Trips.Infrastructure.csproj +30 −0
- SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/TripsModuleExtensions.cs +40 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor +3 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Component1.razor.css +6 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Controllers/AccountController.cs +145 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/JWTResponse.cs +9 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LoginInfo.cs +7 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/LogoutInfo.cs +6 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RegisterInfo.cs +9 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/RestApiErrorResponse.cs +9 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/Dto/v1/TokenRefreshInfo.cs +7 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/ExampleJsInterop.cs +31 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/SplitApp.Modules.Users.Api.csproj +27 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/_Imports.razor +1 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/background.png +0 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/wwwroot/exampleJsInterop.js +6 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IRefreshTokenRepository.cs +12 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUserRepository.cs +12 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Contracts/IUsersUnitOfWork.cs +9 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Handlers/GetUserByIdHandler.cs +26 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Handlers/GetUsersByIdsHandler.cs +25 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IIdentityService.cs +9 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityService.cs +271 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Services/IdentityServiceModels.cs +63 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/SplitApp.Modules.Users.Application.csproj +23 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/UsersModuleMarker.cs +4 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRefreshToken.cs +20 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppRole.cs +8 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/Entities/AppUser.cs +16 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Domain/SplitApp.Modules.Users.Domain.csproj +17 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.Designer.cs +359 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/20260430134045_Init.cs +310 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Migrations/UsersDbContextModelSnapshot.cs +356 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/BaseRepository.cs +29 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/RefreshTokenRepository.cs +42 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/Repositories/UserRepository.cs +40 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersDbContext.cs +66 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UsersUnitOfWork.cs +23 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/Persistence/UtcDateTimeConverter.cs +14 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/SplitApp.Modules.Users.Infrastructure.csproj +34 −0
- SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/UsersModuleExtensions.cs +141 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/CalculateSettlementCommand.cs +11 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Commands/RemoveSettlementPlanCommand.cs +8 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/CurrencyDto.cs +7 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/ExpenseSettledEvent.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Events/SettlementPlanCompletedEvent.cs +10 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetBudgetCategorySpentQuery.cs +6 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetCurrenciesByIdsQuery.cs +6 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/Queries/GetTripExpenseTotalsQuery.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Expenses/TripExpenseTotalsDto.cs +7 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/SplitApp.Shared.Contracts.csproj +17 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/BudgetCategoryNameDto.cs +6 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Events/TripDeletedEvent.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetBudgetCategoryNamesByIdsQuery.cs +6 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripByIdQuery.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/GetTripParticipantsQuery.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/Queries/IsTripParticipantQuery.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripParticipantDto.cs +11 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Trips/TripSummaryDto.cs +8 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Events/UserDeletedEvent.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Queries/GetUserByIdQuery.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Queries/GetUsersByIdsQuery.cs +5 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/UserDto.cs +7 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Auth/IdentityHelpers.cs +59 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/BaseEntity.cs +8 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Domain/IBaseEntity.cs +6 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Localization/LangStr.cs +73 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IBaseRepository.cs +13 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/Persistence/IUnitOfWork.cs +6 −0
- SplitApp.Modular/src/Shared/SplitApp.Shared.Kernel/SplitApp.Shared.Kernel.csproj +14 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Contracts/IAppUnitOfWork.cs +100 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/AppUserBllDto.cs +11 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BalanceBllDto.cs +10 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/BudgetCategoryBllDto.cs +22 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/CurrencyBllDto.cs +15 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseBllDto.cs +38 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/ExpenseSplitBllDto.cs +19 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPaymentBllDto.cs +31 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPlanBllDto.cs +30 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SplitPresetBllDto.cs +42 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripBllDto.cs +39 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripInvitationBllDto.cs +30 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripParticipantBllDto.cs +30 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollBllDto.cs +26 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripPollOptionBllDto.cs +18 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripWishlistItemBllDto.cs +38 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Helpers/CurrencyConverter.cs +30 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/AppUserBllDtoFactory.cs +15 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/BudgetCategoryBllDtoFactory.cs +42 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/CurrencyBllDtoFactory.cs +32 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/ExpenseBllDtoFactory.cs +71 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/InvitationBllDtoFactory.cs +36 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/PollBllDtoFactory.cs +63 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SettlementBllDtoFactory.cs +73 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/SplitPresetBllDtoFactory.cs +61 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/TripBllDtoFactory.cs +86 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Mappers/WishlistBllDtoFactory.cs +54 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs +653 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs +179 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminDashboardData.cs +66 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/AdminStatsService.cs +203 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/BudgetCategoryAdminService.cs +81 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/CurrencyAdminService.cs +73 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ExpenseAdminService.cs +99 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IAdminStatsService.cs +6 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IBudgetCategoryAdminService.cs +14 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ICurrencyAdminService.cs +13 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IExpenseAdminService.cs +18 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IInvitationAdminService.cs +15 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IPollAdminService.cs +16 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPaymentAdminService.cs +15 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISettlementPlanAdminService.cs +16 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ISplitPresetAdminService.cs +14 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripAdminService.cs +14 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ITripParticipantAdminService.cs +16 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/IWishlistAdminService.cs +16 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/InvitationAdminService.cs +77 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/PollAdminService.cs +77 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPaymentAdminService.cs +78 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPlanAdminService.cs +76 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SplitPresetAdminService.cs +61 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripAdminService.cs +71 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripParticipantAdminService.cs +86 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/WishlistAdminService.cs +83 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/BudgetCategoryService.cs +86 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ExpenseService.cs +348 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IBudgetCategoryService.cs +14 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IExpenseService.cs +44 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IInvitationService.cs +30 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IPollService.cs +27 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISettlementService.cs +46 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISplitPresetService.cs +20 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ITripService.cs +38 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IWishlistService.cs +16 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IIdentityService.cs +9 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IdentityService.cs +266 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IdentityServiceResult.cs +63 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/InvitationService.cs +193 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/PollService.cs +207 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SettlementService.cs +340 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SplitPresetService.cs +124 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/TripService.cs +240 −0
- SplitApp.Modular/src/SplitApp.WebApp/Application/Services/WishlistService.cs +149 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +147 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/CurrenciesController.cs +134 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/DashboardController.cs +88 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/ExpensesController.cs +149 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/InvitationsController.cs +158 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/PollsController.cs +129 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +153 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPlansController.cs +187 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SplitPresetsController.cs +115 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +188 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripsController.cs +140 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/UsersController.cs +197 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/WishlistController.cs +129 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Models/AdminViewModels.cs +287 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Create.cshtml +55 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Delete.cshtml +25 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Details.cshtml +28 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Edit.cshtml +56 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/BudgetCategories/Index.cshtml +76 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Create.cshtml +45 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Delete.cshtml +25 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Details.cshtml +22 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Edit.cshtml +46 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Currencies/Index.cshtml +65 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Dashboard/Index.cshtml +373 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Create.cshtml +71 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Delete.cshtml +31 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Details.cshtml +37 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Edit.cshtml +72 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Expenses/Index.cshtml +80 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Create.cshtml +58 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Delete.cshtml +39 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Details.cshtml +40 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Edit.cshtml +59 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Invitations/Index.cshtml +65 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Create.cshtml +48 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Delete.cshtml +13 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Details.cshtml +37 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Edit.cshtml +45 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Polls/Index.cshtml +73 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Create.cshtml +55 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Delete.cshtml +39 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Details.cshtml +43 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Edit.cshtml +56 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPayments/Index.cshtml +67 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Create.cshtml +52 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Delete.cshtml +28 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Details.cshtml +28 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Edit.cshtml +53 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SettlementPlans/Index.cshtml +73 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Shared/_Layout.cshtml +126 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Create.cshtml +52 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Delete.cshtml +28 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Details.cshtml +50 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/SplitPresets/Index.cshtml +64 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Create.cshtml +53 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Delete.cshtml +28 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Details.cshtml +34 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Edit.cshtml +54 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/TripParticipants/Index.cshtml +78 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Create.cshtml +62 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Delete.cshtml +28 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Details.cshtml +37 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Edit.cshtml +64 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Trips/Index.cshtml +69 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Delete.cshtml +44 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Details.cshtml +44 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Edit.cshtml +44 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/EditRoles.cshtml +29 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Users/Index.cshtml +46 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Create.cshtml +64 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Delete.cshtml +13 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Details.cshtml +35 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Edit.cshtml +57 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/Wishlist/Index.cshtml +71 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewImports.cshtml +16 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Views/_ViewStart.cshtml +3 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/Account/Register.cshtml +59 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/Account/Register.cshtml.cs +93 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/_ViewImports.cshtml +5 −0
- SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/_ViewStart.cshtml +3 −0
- SplitApp.Modular/src/SplitApp.WebApp/Controllers/BudgetController.cs +213 −0
- SplitApp.Modular/src/SplitApp.WebApp/Controllers/ExpensesController.cs +270 −0
- SplitApp.Modular/src/SplitApp.WebApp/Controllers/HomeController.cs +35 −0
- SplitApp.Modular/src/SplitApp.WebApp/Controllers/MembersController.cs +181 −0
- SplitApp.Modular/src/SplitApp.WebApp/Controllers/PollsClientController.cs +148 −0
- SplitApp.Modular/src/SplitApp.WebApp/Controllers/SettlementController.cs +194 −0
- SplitApp.Modular/src/SplitApp.WebApp/Controllers/TripsController.cs +264 −0
- SplitApp.Modular/src/SplitApp.WebApp/Controllers/WishlistClientController.cs +281 −0
- SplitApp.Modular/src/SplitApp.WebApp/Hosting/AppDataInit.cs +363 −0
- SplitApp.Modular/src/SplitApp.WebApp/Hosting/ConfigureSwaggerOptions.cs +61 −0
- SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/CurrencyConverter.cs +30 −0
- SplitApp.Modular/src/SplitApp.WebApp/Hosting/Helpers/EnumHelper.cs +18 −0
- SplitApp.Modular/src/SplitApp.WebApp/Hosting/InvariantDecimalModelBinderProvider.cs +55 −0
- SplitApp.Modular/src/SplitApp.WebApp/Hosting/PassthroughStringLocalizer.cs +28 −0
- SplitApp.Modular/src/SplitApp.WebApp/Models/ErrorViewModel.cs +8 −0
- SplitApp.Modular/src/SplitApp.WebApp/Program.cs +191 −0
- SplitApp.Modular/src/SplitApp.WebApp/Properties/launchSettings.json +23 −0
- SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.cs +8 −0
- SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.et.resx +70 −0
- SplitApp.Modular/src/SplitApp.WebApp/Resources/Domain/Enums.resx +70 −0
- SplitApp.Modular/src/SplitApp.WebApp/Resources/Shared.cs +7 −0
- SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.et.resx +513 −0
- SplitApp.Modular/src/SplitApp.WebApp/Resources/Views/Shared.resx +514 −0
- SplitApp.Modular/src/SplitApp.WebApp/SplitApp.WebApp.csproj +45 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/CreateCategory.cshtml +71 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/DeleteCategory.cshtml +51 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/EditCategory.cshtml +72 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Budget/Index.cshtml +155 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Create.cshtml +365 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Delete.cshtml +51 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Edit.cshtml +84 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Expenses/Index.cshtml +111 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Index.cshtml +116 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Home/Privacy.cshtml +6 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Members/AcceptInvitation.cshtml +60 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Index.cshtml +125 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InvitationInvalid.cshtml +24 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Members/Invite.cshtml +34 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Members/InviteGenerated.cshtml +40 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Create.cshtml +83 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Details.cshtml +126 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/PollsClient/Index.cshtml +99 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Settlement/Index.cshtml +232 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/Error.cshtml +29 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LanguageSelection.cshtml +24 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml +116 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_Layout.cshtml.css +48 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_LoginPartial.cshtml +50 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Shared/_ValidationScriptsPartial.cshtml +2 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Create.cshtml +82 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Delete.cshtml +56 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Details.cshtml +291 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Edit.cshtml +87 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/Trips/Index.cshtml +104 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Create.cshtml +85 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Delete.cshtml +45 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Edit.cshtml +77 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/WishlistClient/Index.cshtml +147 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewImports.cshtml +15 −0
- SplitApp.Modular/src/SplitApp.WebApp/Views/_ViewStart.cshtml +3 −0
- SplitApp.Modular/src/SplitApp.WebApp/appsettings.json +27 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/admin.css +439 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/site.css +31 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/css/splitapp-design.css +1605 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/favicon.ico +0 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/site.js +4 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/js/splitapp.js +323 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/bootstrap/LICENSE +22 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt +23 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery-validation/LICENSE.md +22 −0
- SplitApp.Modular/src/SplitApp.WebApp/wwwroot/lib/jquery/LICENSE.txt +21 −0
- SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/CurrencyConverterTests.cs +61 −0
- SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/SplitApp.Modules.Expenses.Tests.csproj +25 −0
- SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/LangStrTests.cs +86 −0
- SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/SplitApp.Modules.Trips.Tests.csproj +25 −0
- SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/IdentityHelpersTests.cs +105 −0
- SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/SplitApp.Modules.Users.Tests.csproj +25 −0
- SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/CrossModuleNavigationTests.cs +90 −0
- SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/DbContextSchemaIsolationTests.cs +70 −0
- SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs +87 −0
- SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs +59 −0
- SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostFeatureTests.cs +85 −0
- SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/SplitApp.WebApp.IntegrationTests.csproj +29 −0
- architecture.md +221 −0
- arhitektuur.md +230 −0
- docker-compose.yml +32 −0
- docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0
- docs/grouptravel.png +0 −0
- explanation.md +313 −0
- modularmonolith.md +328 −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 .gitlab-ci.yml +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +stages: | |
| 2 | + - deploy | |
| 3 | + | |
| 4 | +deploy: | |
| 5 | + stage: deploy | |
| 6 | + only: | |
| 7 | + - main | |
| 8 | + tags: | |
| 9 | + - shared | |
| 10 | + script: | |
| 11 | + - docker compose -p splitapp-phase3 up --build --remove-orphans --detach |
added Dockerfile +40 −0
| @@ -0,0 +1,40 @@ | ||
| 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/Modules/Users/SplitApp.Modules.Users.Domain/*.csproj src/Modules/Users/SplitApp.Modules.Users.Domain/ | |
| 13 | +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/*.csproj src/Modules/Users/SplitApp.Modules.Users.Application/ | |
| 14 | +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Infrastructure/*.csproj src/Modules/Users/SplitApp.Modules.Users.Infrastructure/ | |
| 15 | +COPY SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Api/*.csproj src/Modules/Users/SplitApp.Modules.Users.Api/ | |
| 16 | +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Domain/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Domain/ | |
| 17 | +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Application/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Application/ | |
| 18 | +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Infrastructure/ | |
| 19 | +COPY SplitApp.Modular/src/Modules/Trips/SplitApp.Modules.Trips.Api/*.csproj src/Modules/Trips/SplitApp.Modules.Trips.Api/ | |
| 20 | +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Domain/ | |
| 21 | +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Application/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Application/ | |
| 22 | +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Infrastructure/ | |
| 23 | +COPY SplitApp.Modular/src/Modules/Expenses/SplitApp.Modules.Expenses.Api/*.csproj src/Modules/Expenses/SplitApp.Modules.Expenses.Api/ | |
| 24 | +COPY SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/*.csproj tests/SplitApp.Modules.Users.Tests/ | |
| 25 | +COPY SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/*.csproj tests/SplitApp.Modules.Trips.Tests/ | |
| 26 | +COPY SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/*.csproj tests/SplitApp.Modules.Expenses.Tests/ | |
| 27 | +COPY SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/*.csproj tests/SplitApp.WebApp.IntegrationTests/ | |
| 28 | + | |
| 29 | +RUN dotnet restore SplitApp.sln | |
| 30 | + | |
| 31 | +# Copy everything else and publish the host | |
| 32 | +COPY SplitApp.Modular/ . | |
| 33 | +RUN dotnet publish src/SplitApp.WebApp/SplitApp.WebApp.csproj -c Release -o /app/publish | |
| 34 | + | |
| 35 | +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime | |
| 36 | +WORKDIR /app | |
| 37 | +COPY --from=build /app/publish . | |
| 38 | +ENV ASPNETCORE_URLS=http://+:8080 | |
| 39 | +EXPOSE 8080 | |
| 40 | +ENTRYPOINT ["dotnet", "SplitApp.WebApp.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 +215 −0
| @@ -0,0 +1,215 @@ | ||
| 1 | +# SplitApp — Trip Expense Management (Phase 3 — Modular Monolith) | |
| 2 | + | |
| 3 | +URL: https://travel.rasmusj.com/ | |
| 4 | + | |
| 5 | +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. | |
| 6 | + | |
| 7 | +This repo is the **Phase 3 — Modular Monolith** refactor of the project. The whole product lives under [`SplitApp.Modular/`](SplitApp.Modular/): one deployable, three internally isolated modules (**Users**, **Trips**, **Expenses**), MediatR for cross-module communication, schema-per-module Postgres isolation. | |
| 8 | + | |
| 9 | +Built for the TalTech "Web Applications with C#" course **Personal Project — Phase 3**. | |
| 10 | + | |
| 11 | +--- | |
| 12 | + | |
| 13 | +## Run | |
| 14 | + | |
| 15 | +```bash | |
| 16 | +docker compose up --build | |
| 17 | +``` | |
| 18 | + | |
| 19 | +Brings up two containers: | |
| 20 | + | |
| 21 | +| Service | Container | Port | Notes | | |
| 22 | +|---|---|---|---| | |
| 23 | +| `phase3` | `phase3` | http://localhost:90 | The web app | | |
| 24 | +| `db` | `phase3-db` | (internal only) | PostgreSQL 16, schemas `users` / `trips` / `expenses` — not exposed to host | | |
| 25 | + | |
| 26 | +Module migrations run automatically on host startup. Sample data is seeded if `DataInitialization:SeedData=true` (set in `appsettings.json`). | |
| 27 | + | |
| 28 | +Test login (after seed): | |
| 29 | +- `admin@taltech.ee` / `Foo.Bar.1` — `admin` role, full Admin area access | |
| 30 | +- `alice@taltech.ee`, `bob@taltech.ee`, `charlie@taltech.ee`, `diana@taltech.ee` / `Foo.Bar.1` — regular users with sample trips | |
| 31 | + | |
| 32 | +Stop: | |
| 33 | + | |
| 34 | +```bash | |
| 35 | +docker compose down | |
| 36 | +``` | |
| 37 | + | |
| 38 | +--- | |
| 39 | + | |
| 40 | +## Architecture at a glance | |
| 41 | + | |
| 42 | +``` | |
| 43 | + ┌────────────────────────────────────────────────┐ | |
| 44 | + │ SplitApp.WebApp (host) │ | |
| 45 | + │ Program.cs · Controllers · Areas/Admin │ | |
| 46 | + │ Application/{Services, DTO, Mappers, │ | |
| 47 | + │ Persistence} │ | |
| 48 | + └────────────────────────────────────────────────┘ | |
| 49 | + │ │ │ | |
| 50 | + ▼ ▼ ▼ | |
| 51 | + ┌──────────┐ ┌──────────┐ ┌──────────┐ | |
| 52 | + │ Users │ │ Trips │ │ Expenses │ | |
| 53 | + │ Domain │ │ Domain │ │ Domain │ | |
| 54 | + │ App │ │ App │ │ App │ | |
| 55 | + │ Infra │ │ Infra │ │ Infra │ | |
| 56 | + │ Api │ │ Api │ │ Api │ | |
| 57 | + │ schema: │ │ schema: │ │ schema: │ | |
| 58 | + │ users │ │ trips │ │ expenses │ | |
| 59 | + └──────────┘ └──────────┘ └──────────┘ | |
| 60 | + ▲ ▲ ▲ | |
| 61 | + └─MediatR───┴─MediatR───┘ | |
| 62 | + ┌──────────────────────┐ ┌──────────────────────┐ | |
| 63 | + │ Shared.Contracts │ │ Shared.Kernel │ | |
| 64 | + │ IRequest / INotification│ │ BaseEntity, LangStr │ | |
| 65 | + └──────────────────────┘ └──────────────────────┘ | |
| 66 | +``` | |
| 67 | + | |
| 68 | +**Reference rules** (compiler-enforced + verified by `tests/SplitApp.WebApp.IntegrationTests/Architecture/`): | |
| 69 | + | |
| 70 | +- A module's `Application` / `Infrastructure` / `Api` can reference: same-module projects + `Shared.Kernel` + `Shared.Contracts`. Nothing else. | |
| 71 | +- Inter-module function calls go through **MediatR only**. | |
| 72 | +- `Shared.*` may not reference any module. | |
| 73 | +- `WebApp` is the only project that references all three modules' `Api` and `Infrastructure`. | |
| 74 | + | |
| 75 | +**One caveat at the Domain level only:** to keep view-rendering parity from phase 2 (`Trip.CreatedBy.Email`, `Expense.PaidByUser.FirstName`, etc.), the entity classes still declare cross-module navigation properties — annotated `[NotMapped]` so EF never crosses Postgres schemas. The `CrossModuleNavigationTests` invariant rejects any *mapped* cross-module nav. Application/Infrastructure/Api remain isolated; only entity *types* are shared at the Domain level. | |
| 76 | + | |
| 77 | +See [explanation.md](explanation.md) (Estonian, full walkthrough), [arhitektuur.md](arhitektuur.md) (Estonian, diagrams + reference rules), [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md) (English, deep-dive). | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## Solution layout | |
| 82 | + | |
| 83 | +``` | |
| 84 | +SplitApp.Modular/ | |
| 85 | +├── SplitApp.sln | |
| 86 | +├── Directory.Build.props | |
| 87 | +├── src/ | |
| 88 | +│ ├── SplitApp.WebApp/ ← composition root, host | |
| 89 | +│ │ ├── Program.cs ← AddXxxModule(...) wiring | |
| 90 | +│ │ ├── Application/ ← lifted phase-2 BLL | |
| 91 | +│ │ │ ├── Services/ (+ Admin/, Identity/) | |
| 92 | +│ │ │ ├── DTO/ | |
| 93 | +│ │ │ ├── Mappers/ | |
| 94 | +│ │ │ ├── Persistence/AppUnitOfWork.cs ← aggregates 3 module DbContexts | |
| 95 | +│ │ │ └── Persistence/CrossModuleNavigationLoader.cs | |
| 96 | +│ │ ├── Areas/Admin/ | |
| 97 | +│ │ ├── Areas/Identity/ | |
| 98 | +│ │ ├── Controllers/ | |
| 99 | +│ │ ├── Views/ | |
| 100 | +│ │ └── Resources/ ← i18n .resx (EN + ET) | |
| 101 | +│ ├── Shared/ | |
| 102 | +│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers | |
| 103 | +│ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification | |
| 104 | +│ └── Modules/ | |
| 105 | +│ ├── Users/ ← AppUser, AppRole, AppRefreshToken; JWT | |
| 106 | +│ ├── Trips/ ← Trip, Participant, Invitation, Poll, Wishlist, BudgetCategory | |
| 107 | +│ └── Expenses/ ← Expense, ExpenseSplit, SettlementPlan/Payment, Currency, SplitPreset | |
| 108 | +└── tests/ | |
| 109 | + ├── SplitApp.Modules.Users.Tests/ | |
| 110 | + ├── SplitApp.Modules.Trips.Tests/ | |
| 111 | + ├── SplitApp.Modules.Expenses.Tests/ | |
| 112 | + └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke | |
| 113 | +``` | |
| 114 | + | |
| 115 | +Each module = mini-Clean-Architecture (`Domain` ← `Application` ← `Infrastructure`, `Api` for REST). Each module owns its `DbContext` scoped to its own Postgres schema. | |
| 116 | + | |
| 117 | +--- | |
| 118 | + | |
| 119 | +## URL map | |
| 120 | + | |
| 121 | +| URL | Purpose | | |
| 122 | +|---|---| | |
| 123 | +| `/` | Landing page (MVC) | | |
| 124 | +| `/Trips`, `/Trips/{Create,Details/{id},Edit/{id},Delete/{id}}` | Trip CRUD | | |
| 125 | +| `/Members?tripId={id}` and `/Members/AcceptInvitation/{token}` | Trip participants + invitation flow | | |
| 126 | +| `/Expenses?tripId={id}` (with Create/Edit/Delete) | Trip expenses | | |
| 127 | +| `/Budget?tripId={id}` (CreateCategory/EditCategory/DeleteCategory) | Budget categories | | |
| 128 | +| `/Settlement?tripId={id}` | Balances + settlement plans | | |
| 129 | +| `/PollsClient?tripId={id}` (Create/Details) | Trip polls | | |
| 130 | +| `/WishlistClient?tripId={id}` | Trip wishlist | | |
| 131 | +| `/Identity/Account/Register` | Cookie register | | |
| 132 | +| `/Admin/Dashboard` | Admin home (`admin` role) | | |
| 133 | +| `/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist}` | Admin CRUD per entity | | |
| 134 | +| `/swagger` | Swagger UI listing every module's REST endpoints | | |
| 135 | + | |
| 136 | +### REST API | |
| 137 | + | |
| 138 | +| Module | Endpoints | | |
| 139 | +|---|---| | |
| 140 | +| Users | `/api/v1/identity/account/{register, login, logout, refreshtokendata}` | | |
| 141 | +| Trips | `/api/v1/trips`, `/api/v1/budgetcategories`, `/api/v1/invitations`, `/api/v1/polls`, `/api/v1/wishlist` | | |
| 142 | +| Expenses | `/api/v1/expenses`, `/api/v1/currencies`, `/api/v1/settlements`, `/api/v1/splitpresets` | | |
| 143 | + | |
| 144 | +All API endpoints require JWT bearer auth except `account/register` and `account/login`. | |
| 145 | + | |
| 146 | +--- | |
| 147 | + | |
| 148 | +## Inter-module communication | |
| 149 | + | |
| 150 | +All cross-module calls go through MediatR. Contracts in `Shared.Contracts/<Module>/{Queries|Events|Commands}/`; handlers in the **owning** module. | |
| 151 | + | |
| 152 | +| Contract | Owner | Purpose | | |
| 153 | +|---|---|---| | |
| 154 | +| `GetUserByIdQuery → UserDto?` | Users | Display name lookup | | |
| 155 | +| `GetUsersByIdsQuery → IReadOnlyList<UserDto>` | Users | Batch lookup | | |
| 156 | +| `UserDeletedEvent` | Users | Trips + Expenses subscribe to clean up rows | | |
| 157 | +| `GetTripByIdQuery → TripSummaryDto?` | Trips | Cross-module trip lookup | | |
| 158 | +| `GetTripParticipantsQuery → IReadOnlyList<TripParticipantDto>` | Trips | | | |
| 159 | +| `IsTripParticipantQuery → bool` | Trips | **IDOR guard** in `ExpensesController` | | |
| 160 | +| `TripDeletedEvent` | Trips | Expenses subscribes to delete dependent expenses/settlements | | |
| 161 | +| `GetTripExpenseTotalsQuery → TripExpenseTotalsDto` | Expenses | | | |
| 162 | +| `GetBudgetCategorySpentQuery → IReadOnlyDictionary<Guid, decimal>` | Expenses | Per-budget-category spent totals | | |
| 163 | +| `ExpenseSettledEvent` | Expenses | Reserved | | |
| 164 | +| `SettlementPlanCompletedEvent` | Expenses | Trips advances "Finalizing" trips → "Settled" once every payment is confirmed | | |
| 165 | + | |
| 166 | +--- | |
| 167 | + | |
| 168 | +## Tests | |
| 169 | + | |
| 170 | +```bash | |
| 171 | +cd SplitApp.Modular | |
| 172 | +dotnet test | |
| 173 | +``` | |
| 174 | + | |
| 175 | +**25 tests** across four projects: | |
| 176 | + | |
| 177 | +- Per-module unit tests — `CurrencyConverter` (5), `LangStr` (7), `IdentityHelpers` JWT round-trips (4) | |
| 178 | +- Architecture invariants — `ModuleBoundaryTests`, `DbContextSchemaIsolationTests`, `CrossModuleNavigationTests` | |
| 179 | +- Smoke — `WebApplicationFactory<Program>` boots the host, hits `/`, `/Home/Index`, expects 401 on unauthenticated API | |
| 180 | + | |
| 181 | +A failing architecture test = someone violated the modular monolith invariant. | |
| 182 | + | |
| 183 | +--- | |
| 184 | + | |
| 185 | +## Phase 3 ↔ Phase 2 mapping | |
| 186 | + | |
| 187 | +Phase 3 lifts most of phase 2 unchanged: | |
| 188 | + | |
| 189 | +| Phase 2 | Phase 3 destination | | |
| 190 | +|---|---| | |
| 191 | +| `Base.Domain`, `Base.Contracts` | `Shared.Kernel` | | |
| 192 | +| `Base.Helpers` (IdentityHelpers) | `Shared.Kernel.Auth` | | |
| 193 | +| `App.Domain.Identity.*` | `Modules/Users/Domain/Entities/` | | |
| 194 | +| `App.Domain.{Trip, TripParticipant, ...}` | `Modules/Trips/Domain/Entities/` | | |
| 195 | +| `App.Domain.{Expense, SettlementPlan, ..., Currency}` | `Modules/Expenses/Domain/Entities/` | | |
| 196 | +| `App.DAL.EF.AppDbContext` | split into 3 per-module `DbContext`s | | |
| 197 | +| `App.BLL.Services.Identity.*` | `Modules/Users/Application/Services/` | | |
| 198 | +| `App.BLL.Services.*` (Trip, Expense, Settlement, ...) | `WebApp/Application/Services/` (composition-root facade over 3 module UoWs) | | |
| 199 | +| `App.BLL.{DTO, Mappers}` | `WebApp/Application/{DTO, Mappers}` | | |
| 200 | +| `WebApp.ApiControllers.{Identity, Trips, Expenses, ...}` | split per module → `Modules/X/Api/Controllers/` | | |
| 201 | +| `WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` | preserved structurally; namespace re-rooted to `SplitApp.WebApp.*` | | |
| 202 | + | |
| 203 | +The phase-2 BLL is "lifted" into `WebApp/Application/` rather than rewritten — this keeps the full UX surface (101 Razor views, 21 MVC + Admin controllers, 10 REST controllers, Identity Razor pages) byte-identical to phase 2 while the inter-module boundaries are enforced cleanly via MediatR + per-module DbContexts. | |
| 204 | + | |
| 205 | +--- | |
| 206 | + | |
| 207 | +## Files & docs | |
| 208 | + | |
| 209 | +- [explanation.md](explanation.md) — Phase 3 walkthrough (Estonian) | |
| 210 | +- [arhitektuur.md](arhitektuur.md) — Phase 3 architecture diagrams + rules (Estonian) | |
| 211 | +- [architecture.md](architecture.md) — Phase 3 architecture deep-dive (English) | |
| 212 | +- [modularmonolith.md](modularmonolith.md) — Course material on the modular monolith pattern | |
| 213 | +- [phase3.md](phase3.md) — The original assignment text | |
| 214 | +- [SplitApp.Modular/README.md](SplitApp.Modular/README.md) — Module-level README | |
| 215 | +- [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md) — Per-module architecture details |
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 | +**Production deployment:** https://travel.rasmusj.com/ | |
| 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 +326 −0
| @@ -0,0 +1,326 @@ | ||
| 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 | +Global | |
| 59 | + GlobalSection(SolutionConfigurationPlatforms) = preSolution | |
| 60 | + Debug|Any CPU = Debug|Any CPU | |
| 61 | + Debug|x64 = Debug|x64 | |
| 62 | + Debug|x86 = Debug|x86 | |
| 63 | + Release|Any CPU = Release|Any CPU | |
| 64 | + Release|x64 = Release|x64 | |
| 65 | + Release|x86 = Release|x86 | |
| 66 | + EndGlobalSection | |
| 67 | + GlobalSection(ProjectConfigurationPlatforms) = postSolution | |
| 68 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 69 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 70 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 71 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x64.Build.0 = Debug|Any CPU | |
| 72 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 73 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Debug|x86.Build.0 = Debug|Any CPU | |
| 74 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 75 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 76 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x64.ActiveCfg = Release|Any CPU | |
| 77 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x64.Build.0 = Release|Any CPU | |
| 78 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x86.ActiveCfg = Release|Any CPU | |
| 79 | + {634BAEEE-802C-4568-98D6-50FCD86372BE}.Release|x86.Build.0 = Release|Any CPU | |
| 80 | + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 81 | + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 82 | + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 83 | + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x64.Build.0 = Debug|Any CPU | |
| 84 | + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 85 | + {22C9586A-C6D7-4102-A474-69279D977486}.Debug|x86.Build.0 = Debug|Any CPU | |
| 86 | + {22C9586A-C6D7-4102-A474-69279D977486}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 87 | + {22C9586A-C6D7-4102-A474-69279D977486}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 88 | + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x64.ActiveCfg = Release|Any CPU | |
| 89 | + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x64.Build.0 = Release|Any CPU | |
| 90 | + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x86.ActiveCfg = Release|Any CPU | |
| 91 | + {22C9586A-C6D7-4102-A474-69279D977486}.Release|x86.Build.0 = Release|Any CPU | |
| 92 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 93 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 94 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 95 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x64.Build.0 = Debug|Any CPU | |
| 96 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 97 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Debug|x86.Build.0 = Debug|Any CPU | |
| 98 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 99 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 100 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x64.ActiveCfg = Release|Any CPU | |
| 101 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x64.Build.0 = Release|Any CPU | |
| 102 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x86.ActiveCfg = Release|Any CPU | |
| 103 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6}.Release|x86.Build.0 = Release|Any CPU | |
| 104 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 105 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 106 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 107 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x64.Build.0 = Debug|Any CPU | |
| 108 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 109 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Debug|x86.Build.0 = Debug|Any CPU | |
| 110 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 111 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 112 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x64.ActiveCfg = Release|Any CPU | |
| 113 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x64.Build.0 = Release|Any CPU | |
| 114 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x86.ActiveCfg = Release|Any CPU | |
| 115 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8}.Release|x86.Build.0 = Release|Any CPU | |
| 116 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 117 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 118 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 119 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x64.Build.0 = Debug|Any CPU | |
| 120 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 121 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Debug|x86.Build.0 = Debug|Any CPU | |
| 122 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 123 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 124 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x64.ActiveCfg = Release|Any CPU | |
| 125 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x64.Build.0 = Release|Any CPU | |
| 126 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x86.ActiveCfg = Release|Any CPU | |
| 127 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D}.Release|x86.Build.0 = Release|Any CPU | |
| 128 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 129 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 130 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 131 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x64.Build.0 = Debug|Any CPU | |
| 132 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 133 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Debug|x86.Build.0 = Debug|Any CPU | |
| 134 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 135 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 136 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x64.ActiveCfg = Release|Any CPU | |
| 137 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x64.Build.0 = Release|Any CPU | |
| 138 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x86.ActiveCfg = Release|Any CPU | |
| 139 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251}.Release|x86.Build.0 = Release|Any CPU | |
| 140 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 141 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 142 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 143 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x64.Build.0 = Debug|Any CPU | |
| 144 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 145 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Debug|x86.Build.0 = Debug|Any CPU | |
| 146 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 147 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 148 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x64.ActiveCfg = Release|Any CPU | |
| 149 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x64.Build.0 = Release|Any CPU | |
| 150 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x86.ActiveCfg = Release|Any CPU | |
| 151 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3}.Release|x86.Build.0 = Release|Any CPU | |
| 152 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 153 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 154 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 155 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x64.Build.0 = Debug|Any CPU | |
| 156 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 157 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Debug|x86.Build.0 = Debug|Any CPU | |
| 158 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 159 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 160 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x64.ActiveCfg = Release|Any CPU | |
| 161 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x64.Build.0 = Release|Any CPU | |
| 162 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x86.ActiveCfg = Release|Any CPU | |
| 163 | + {62DDD903-B3D7-4140-B200-D489CE7A567D}.Release|x86.Build.0 = Release|Any CPU | |
| 164 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 165 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 166 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 167 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x64.Build.0 = Debug|Any CPU | |
| 168 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 169 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Debug|x86.Build.0 = Debug|Any CPU | |
| 170 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 171 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 172 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x64.ActiveCfg = Release|Any CPU | |
| 173 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x64.Build.0 = Release|Any CPU | |
| 174 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x86.ActiveCfg = Release|Any CPU | |
| 175 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6}.Release|x86.Build.0 = Release|Any CPU | |
| 176 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 177 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 178 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 179 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x64.Build.0 = Debug|Any CPU | |
| 180 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 181 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Debug|x86.Build.0 = Debug|Any CPU | |
| 182 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 183 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 184 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x64.ActiveCfg = Release|Any CPU | |
| 185 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x64.Build.0 = Release|Any CPU | |
| 186 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x86.ActiveCfg = Release|Any CPU | |
| 187 | + {7E0445E0-A676-4892-89C6-85C1738C1A49}.Release|x86.Build.0 = Release|Any CPU | |
| 188 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 189 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 190 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 191 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x64.Build.0 = Debug|Any CPU | |
| 192 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 193 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Debug|x86.Build.0 = Debug|Any CPU | |
| 194 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 195 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 196 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x64.ActiveCfg = Release|Any CPU | |
| 197 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x64.Build.0 = Release|Any CPU | |
| 198 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x86.ActiveCfg = Release|Any CPU | |
| 199 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD}.Release|x86.Build.0 = Release|Any CPU | |
| 200 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 201 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 202 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 203 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x64.Build.0 = Debug|Any CPU | |
| 204 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 205 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Debug|x86.Build.0 = Debug|Any CPU | |
| 206 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 207 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 208 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x64.ActiveCfg = Release|Any CPU | |
| 209 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x64.Build.0 = Release|Any CPU | |
| 210 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x86.ActiveCfg = Release|Any CPU | |
| 211 | + {E0619779-5956-4D74-B683-BFDECBC89710}.Release|x86.Build.0 = Release|Any CPU | |
| 212 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 213 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 214 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 215 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x64.Build.0 = Debug|Any CPU | |
| 216 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 217 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Debug|x86.Build.0 = Debug|Any CPU | |
| 218 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 219 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 220 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x64.ActiveCfg = Release|Any CPU | |
| 221 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x64.Build.0 = Release|Any CPU | |
| 222 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x86.ActiveCfg = Release|Any CPU | |
| 223 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7}.Release|x86.Build.0 = Release|Any CPU | |
| 224 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 225 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 226 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 227 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x64.Build.0 = Debug|Any CPU | |
| 228 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 229 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Debug|x86.Build.0 = Debug|Any CPU | |
| 230 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 231 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 232 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x64.ActiveCfg = Release|Any CPU | |
| 233 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x64.Build.0 = Release|Any CPU | |
| 234 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x86.ActiveCfg = Release|Any CPU | |
| 235 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C}.Release|x86.Build.0 = Release|Any CPU | |
| 236 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 237 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 238 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 239 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x64.Build.0 = Debug|Any CPU | |
| 240 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 241 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Debug|x86.Build.0 = Debug|Any CPU | |
| 242 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 243 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 244 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x64.ActiveCfg = Release|Any CPU | |
| 245 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x64.Build.0 = Release|Any CPU | |
| 246 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x86.ActiveCfg = Release|Any CPU | |
| 247 | + {0EF6B704-D7B3-4839-B811-753BAD35568F}.Release|x86.Build.0 = Release|Any CPU | |
| 248 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 249 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 250 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 251 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x64.Build.0 = Debug|Any CPU | |
| 252 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 253 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Debug|x86.Build.0 = Debug|Any CPU | |
| 254 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 255 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 256 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x64.ActiveCfg = Release|Any CPU | |
| 257 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x64.Build.0 = Release|Any CPU | |
| 258 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x86.ActiveCfg = Release|Any CPU | |
| 259 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5}.Release|x86.Build.0 = Release|Any CPU | |
| 260 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 261 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 262 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 263 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x64.Build.0 = Debug|Any CPU | |
| 264 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 265 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Debug|x86.Build.0 = Debug|Any CPU | |
| 266 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 267 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 268 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x64.ActiveCfg = Release|Any CPU | |
| 269 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x64.Build.0 = Release|Any CPU | |
| 270 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x86.ActiveCfg = Release|Any CPU | |
| 271 | + {540AF573-0915-42A5-B616-F6395ED13AFA}.Release|x86.Build.0 = Release|Any CPU | |
| 272 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 273 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 274 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 275 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x64.Build.0 = Debug|Any CPU | |
| 276 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 277 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Debug|x86.Build.0 = Debug|Any CPU | |
| 278 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 279 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 280 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x64.ActiveCfg = Release|Any CPU | |
| 281 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x64.Build.0 = Release|Any CPU | |
| 282 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x86.ActiveCfg = Release|Any CPU | |
| 283 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD}.Release|x86.Build.0 = Release|Any CPU | |
| 284 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 285 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 286 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 287 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x64.Build.0 = Debug|Any CPU | |
| 288 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 289 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Debug|x86.Build.0 = Debug|Any CPU | |
| 290 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 291 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 292 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x64.ActiveCfg = Release|Any CPU | |
| 293 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x64.Build.0 = Release|Any CPU | |
| 294 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x86.ActiveCfg = Release|Any CPU | |
| 295 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB}.Release|x86.Build.0 = Release|Any CPU | |
| 296 | + EndGlobalSection | |
| 297 | + GlobalSection(SolutionProperties) = preSolution | |
| 298 | + HideSolutionNode = FALSE | |
| 299 | + EndGlobalSection | |
| 300 | + GlobalSection(NestedProjects) = preSolution | |
| 301 | + {634BAEEE-802C-4568-98D6-50FCD86372BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} | |
| 302 | + {C8E42992-5E42-0C2B-DBFE-AA848D06431C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} | |
| 303 | + {22C9586A-C6D7-4102-A474-69279D977486} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C} | |
| 304 | + {15E0A4BA-CF6C-4C0F-9F8F-CD0D071647A6} = {C8E42992-5E42-0C2B-DBFE-AA848D06431C} | |
| 305 | + {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} | |
| 306 | + {A26F344D-182E-CE53-AD51-2154946AC6F3} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} | |
| 307 | + {5CB7B8CB-A7F4-49C7-92F6-36EE3CC328C8} = {A26F344D-182E-CE53-AD51-2154946AC6F3} | |
| 308 | + {F356134B-BF21-4687-9BD8-342C40AD4B9D} = {A26F344D-182E-CE53-AD51-2154946AC6F3} | |
| 309 | + {E316CB96-ACE5-42EB-B8EC-95F22DDE2251} = {A26F344D-182E-CE53-AD51-2154946AC6F3} | |
| 310 | + {3B01763E-85E1-4F42-9A1C-6B0240810EA3} = {A26F344D-182E-CE53-AD51-2154946AC6F3} | |
| 311 | + {62DDD903-B3D7-4140-B200-D489CE7A567D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} | |
| 312 | + {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} | |
| 313 | + {B2A31D85-D7B1-4476-819B-7A413A875DA6} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4} | |
| 314 | + {7E0445E0-A676-4892-89C6-85C1738C1A49} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4} | |
| 315 | + {271E5E8E-F4C5-4C9A-8433-2AE921FFE0AD} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4} | |
| 316 | + {E0619779-5956-4D74-B683-BFDECBC89710} = {869668A3-D6B9-A2AB-F76D-9DAC2CDCE0F4} | |
| 317 | + {B4B81FD5-5EBD-49EF-9E9B-3F432AA2F6A7} = {0AB3BF05-4346-4AA6-1389-037BE0695223} | |
| 318 | + {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3} = {EC447DCF-ABFA-6E24-52A5-D7FD48A5C558} | |
| 319 | + {49DB325B-DE26-429F-AF4A-A0F6D56B854C} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3} | |
| 320 | + {0EF6B704-D7B3-4839-B811-753BAD35568F} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3} | |
| 321 | + {8CE6DD5E-6609-4DE5-83E8-072947EFE3B5} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3} | |
| 322 | + {540AF573-0915-42A5-B616-F6395ED13AFA} = {69B6DC19-77B3-B0C6-E9A1-F8EAF9AC00A3} | |
| 323 | + {037FD70C-41AF-4CBA-B7B7-93E9251EA6FD} = {0AB3BF05-4346-4AA6-1389-037BE0695223} | |
| 324 | + {4B0B55CE-A512-4C2F-BB69-A89A348B83AB} = {0AB3BF05-4346-4AA6-1389-037BE0695223} | |
| 325 | + EndGlobalSection | |
| 326 | +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 | +**Production:** https://travel.rasmusj.com/ | |
| 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 +278 −0
| @@ -0,0 +1,278 @@ | ||
| 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.Contracts.Users.Queries; | |
| 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 | + | |
| 26 | + public ExpensesController(IExpensesUnitOfWork uow, IMediator mediator) | |
| 27 | + { | |
| 28 | + _uow = uow; | |
| 29 | + _mediator = mediator; | |
| 30 | + } | |
| 31 | + | |
| 32 | + // Phase-2 frontend compatibility: path-based GET /api/v1/Expenses/trip/{tripId}. | |
| 33 | + // Query-form GET /api/v1/Expenses?tripId=... also works. | |
| 34 | + [HttpGet("trip/{tripId:guid}")] | |
| 35 | + [HttpGet] | |
| 36 | + public async Task<ActionResult<IEnumerable<ExpenseDto>>> List([FromRoute] Guid? tripId, [FromQuery] Guid? tripIdQuery = null) | |
| 37 | + { | |
| 38 | + var userId = CurrentUserId(); | |
| 39 | + if (userId == null) return Unauthorized(); | |
| 40 | + | |
| 41 | + var resolvedTripId = tripId ?? tripIdQuery ?? Guid.Empty; | |
| 42 | + if (resolvedTripId == Guid.Empty | |
| 43 | + && Request.Query.TryGetValue("tripId", out var v) && Guid.TryParse(v, out var parsed)) | |
| 44 | + { | |
| 45 | + resolvedTripId = parsed; | |
| 46 | + } | |
| 47 | + if (resolvedTripId == Guid.Empty) return BadRequest(new { error = "tripId is required." }); | |
| 48 | + | |
| 49 | + if (!await _mediator.Send(new IsTripParticipantQuery(resolvedTripId, userId.Value))) return Forbid(); | |
| 50 | + | |
| 51 | + var all = await _uow.Expenses.GetAllAsync(); | |
| 52 | + var forTrip = all.Where(e => e.TripId == resolvedTripId).ToList(); | |
| 53 | + var dtos = await BuildExpenseDtosAsync(forTrip, resolvedTripId, includeSplits: true); | |
| 54 | + return Ok(dtos); | |
| 55 | + } | |
| 56 | + | |
| 57 | + [HttpGet("{id:guid}")] | |
| 58 | + public async Task<ActionResult<ExpenseDto>> Get(Guid id) | |
| 59 | + { | |
| 60 | + var userId = CurrentUserId(); | |
| 61 | + if (userId == null) return Unauthorized(); | |
| 62 | + | |
| 63 | + var expense = await _uow.Expenses.GetByIdAsync(id); | |
| 64 | + if (expense == null) return NotFound(); | |
| 65 | + if (!await _mediator.Send(new IsTripParticipantQuery(expense.TripId, userId.Value))) return Forbid(); | |
| 66 | + | |
| 67 | + var dtos = await BuildExpenseDtosAsync(new[] { expense }, expense.TripId, includeSplits: true); | |
| 68 | + return Ok(dtos[0]); | |
| 69 | + } | |
| 70 | + | |
| 71 | + [HttpPost] | |
| 72 | + public async Task<ActionResult<ExpenseDto>> Create([FromBody] ExpenseCreateDto dto) | |
| 73 | + { | |
| 74 | + var userId = CurrentUserId(); | |
| 75 | + if (userId == null) return Unauthorized(); | |
| 76 | + | |
| 77 | + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, userId.Value))) return Forbid(); | |
| 78 | + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, dto.PaidByUserId))) | |
| 79 | + return BadRequest(new { error = "Payer is not a participant of this trip." }); | |
| 80 | + | |
| 81 | + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var split)) | |
| 82 | + return BadRequest(new { error = $"Unknown split method '{dto.SplitMethod}'." }); | |
| 83 | + | |
| 84 | + foreach (var s in dto.Splits) | |
| 85 | + { | |
| 86 | + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, s.UserId))) | |
| 87 | + return BadRequest(new { error = $"User {s.UserId} is not a participant of this trip." }); | |
| 88 | + } | |
| 89 | + | |
| 90 | + var expense = new Expense | |
| 91 | + { | |
| 92 | + TripId = dto.TripId, | |
| 93 | + PaidByUserId = dto.PaidByUserId, | |
| 94 | + CurrencyId = dto.CurrencyId, | |
| 95 | + BudgetCategoryId = dto.BudgetCategoryId, | |
| 96 | + Amount = dto.Amount, | |
| 97 | + Description = dto.Description, | |
| 98 | + ExpenseDate = dto.ExpenseDate, | |
| 99 | + SplitMethod = split, | |
| 100 | + }; | |
| 101 | + _uow.Expenses.Add(expense); | |
| 102 | + | |
| 103 | + foreach (var s in dto.Splits) | |
| 104 | + { | |
| 105 | + _uow.ExpenseSplits.Add(new ExpenseSplit | |
| 106 | + { | |
| 107 | + ExpenseId = expense.Id, | |
| 108 | + UserId = s.UserId, | |
| 109 | + Amount = s.Amount, | |
| 110 | + Percentage = s.Percentage, | |
| 111 | + }); | |
| 112 | + } | |
| 113 | + await _uow.SaveChangesAsync(); | |
| 114 | + | |
| 115 | + var dtos = await BuildExpenseDtosAsync(new[] { expense }, expense.TripId, includeSplits: true); | |
| 116 | + return CreatedAtAction(nameof(Get), new { id = expense.Id, version = "1.0" }, dtos[0]); | |
| 117 | + } | |
| 118 | + | |
| 119 | + [HttpPut("{id:guid}")] | |
| 120 | + public async Task<IActionResult> Update(Guid id, [FromBody] ExpenseCreateDto dto) | |
| 121 | + { | |
| 122 | + var userId = CurrentUserId(); | |
| 123 | + if (userId == null) return Unauthorized(); | |
| 124 | + | |
| 125 | + var existing = await _uow.Expenses.GetByIdAsync(id); | |
| 126 | + if (existing == null) return NotFound(); | |
| 127 | + | |
| 128 | + if (!await _mediator.Send(new IsTripParticipantQuery(existing.TripId, userId.Value))) return Forbid(); | |
| 129 | + | |
| 130 | + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var split)) | |
| 131 | + return BadRequest(new { error = $"Unknown split method '{dto.SplitMethod}'." }); | |
| 132 | + | |
| 133 | + existing.Amount = dto.Amount; | |
| 134 | + existing.Description = dto.Description; | |
| 135 | + existing.ExpenseDate = dto.ExpenseDate; | |
| 136 | + existing.SplitMethod = split; | |
| 137 | + existing.BudgetCategoryId = dto.BudgetCategoryId; | |
| 138 | + existing.CurrencyId = dto.CurrencyId; | |
| 139 | + existing.PaidByUserId = dto.PaidByUserId; | |
| 140 | + _uow.Expenses.Update(existing); | |
| 141 | + | |
| 142 | + var oldSplits = await _uow.ExpenseSplits.GetAllAsync(); | |
| 143 | + foreach (var s in oldSplits.Where(x => x.ExpenseId == id).ToList()) | |
| 144 | + { | |
| 145 | + await _uow.ExpenseSplits.RemoveAsync(s.Id); | |
| 146 | + } | |
| 147 | + foreach (var s in dto.Splits) | |
| 148 | + { | |
| 149 | + if (!await _mediator.Send(new IsTripParticipantQuery(existing.TripId, s.UserId))) | |
| 150 | + return BadRequest(new { error = $"User {s.UserId} is not a participant of this trip." }); | |
| 151 | + _uow.ExpenseSplits.Add(new ExpenseSplit | |
| 152 | + { | |
| 153 | + ExpenseId = id, | |
| 154 | + UserId = s.UserId, | |
| 155 | + Amount = s.Amount, | |
| 156 | + Percentage = s.Percentage, | |
| 157 | + }); | |
| 158 | + } | |
| 159 | + await _uow.SaveChangesAsync(); | |
| 160 | + return NoContent(); | |
| 161 | + } | |
| 162 | + | |
| 163 | + [HttpDelete("{id:guid}")] | |
| 164 | + public async Task<IActionResult> Delete(Guid id) | |
| 165 | + { | |
| 166 | + var userId = CurrentUserId(); | |
| 167 | + if (userId == null) return Unauthorized(); | |
| 168 | + | |
| 169 | + var expense = await _uow.Expenses.GetByIdAsync(id); | |
| 170 | + if (expense == null) return NotFound(); | |
| 171 | + if (!await _mediator.Send(new IsTripParticipantQuery(expense.TripId, userId.Value))) return Forbid(); | |
| 172 | + | |
| 173 | + var splits = (await _uow.ExpenseSplits.GetAllAsync()).Where(s => s.ExpenseId == id).ToList(); | |
| 174 | + foreach (var s in splits) await _uow.ExpenseSplits.RemoveAsync(s.Id); | |
| 175 | + | |
| 176 | + await _uow.Expenses.RemoveAsync(id); | |
| 177 | + await _uow.SaveChangesAsync(); | |
| 178 | + return NoContent(); | |
| 179 | + } | |
| 180 | + | |
| 181 | + private Guid? CurrentUserId() | |
| 182 | + { | |
| 183 | + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); | |
| 184 | + return Guid.TryParse(raw, out var id) ? id : null; | |
| 185 | + } | |
| 186 | + | |
| 187 | + /// <summary>Hydrates Expense entities into Vue-shaped DTOs in a single batch: | |
| 188 | + /// fetches user names, currency codes, and budget category names cross-module via MediatR | |
| 189 | + /// (one query per type, not per row) and computes amountInTripCurrency.</summary> | |
| 190 | + private async Task<List<ExpenseDto>> BuildExpenseDtosAsync( | |
| 191 | + IEnumerable<Expense> expenses, | |
| 192 | + Guid tripId, | |
| 193 | + bool includeSplits) | |
| 194 | + { | |
| 195 | + var list = expenses as IList<Expense> ?? expenses.ToList(); | |
| 196 | + if (list.Count == 0) return new List<ExpenseDto>(); | |
| 197 | + | |
| 198 | + // Trip + default currency for amountInTripCurrency conversion | |
| 199 | + var trip = await _mediator.Send(new GetTripByIdQuery(tripId)); | |
| 200 | + | |
| 201 | + var allSplits = includeSplits | |
| 202 | + ? (await _uow.ExpenseSplits.GetAllAsync()) | |
| 203 | + .Where(s => list.Any(e => e.Id == s.ExpenseId)) | |
| 204 | + .ToList() | |
| 205 | + : new List<ExpenseSplit>(); | |
| 206 | + | |
| 207 | + // Batch lookups | |
| 208 | + var userIds = list.Select(e => e.PaidByUserId) | |
| 209 | + .Concat(allSplits.Select(s => s.UserId)) | |
| 210 | + .Distinct().ToList(); | |
| 211 | + var users = userIds.Count == 0 | |
| 212 | + ? new List<SplitApp.Shared.Contracts.Users.UserDto>() | |
| 213 | + : (await _mediator.Send(new GetUsersByIdsQuery(userIds))).ToList(); | |
| 214 | + var userLookup = users.ToDictionary(u => u.Id); | |
| 215 | + | |
| 216 | + var currencyIds = list.Select(e => e.CurrencyId).Where(id => id.HasValue).Select(id => id!.Value).ToList(); | |
| 217 | + if (trip != null) currencyIds.Add(trip.DefaultCurrencyId); | |
| 218 | + currencyIds = currencyIds.Distinct().ToList(); | |
| 219 | + var currencies = currencyIds.Count == 0 | |
| 220 | + ? new List<SplitApp.Shared.Contracts.Expenses.CurrencyDto>() | |
| 221 | + : (await _mediator.Send(new SplitApp.Shared.Contracts.Expenses.Queries.GetCurrenciesByIdsQuery(currencyIds))).ToList(); | |
| 222 | + var currencyLookup = currencies.ToDictionary(c => c.Id); | |
| 223 | + var tripDefaultCurrency = trip != null && currencyLookup.TryGetValue(trip.DefaultCurrencyId, out var dc) ? dc : null; | |
| 224 | + | |
| 225 | + var categoryIds = list.Select(e => e.BudgetCategoryId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().ToList(); | |
| 226 | + var categories = categoryIds.Count == 0 | |
| 227 | + ? new List<SplitApp.Shared.Contracts.Trips.BudgetCategoryNameDto>() | |
| 228 | + : (await _mediator.Send(new SplitApp.Shared.Contracts.Trips.Queries.GetBudgetCategoryNamesByIdsQuery(categoryIds))).ToList(); | |
| 229 | + var categoryLookup = categories.ToDictionary(c => c.Id); | |
| 230 | + | |
| 231 | + return list.Select(e => | |
| 232 | + { | |
| 233 | + var currency = e.CurrencyId.HasValue && currencyLookup.TryGetValue(e.CurrencyId.Value, out var c) ? c : null; | |
| 234 | + decimal? converted = null; | |
| 235 | + if (currency != null && tripDefaultCurrency != null && currency.Code != tripDefaultCurrency.Code) | |
| 236 | + { | |
| 237 | + converted = CurrencyConverter.Convert(e.Amount, currency.Code, tripDefaultCurrency.Code); | |
| 238 | + } | |
| 239 | + userLookup.TryGetValue(e.PaidByUserId, out var paidByUser); | |
| 240 | + | |
| 241 | + var dto = new ExpenseDto | |
| 242 | + { | |
| 243 | + Id = e.Id, | |
| 244 | + TripId = e.TripId, | |
| 245 | + PaidByUserId = e.PaidByUserId, | |
| 246 | + PaidByUserName = paidByUser?.DisplayName, | |
| 247 | + BudgetCategoryId = e.BudgetCategoryId, | |
| 248 | + BudgetCategoryName = e.BudgetCategoryId.HasValue && categoryLookup.TryGetValue(e.BudgetCategoryId.Value, out var cat) | |
| 249 | + ? cat.Name : null, | |
| 250 | + CurrencyId = e.CurrencyId, | |
| 251 | + CurrencyCode = currency?.Code, | |
| 252 | + CurrencySymbol = currency?.Symbol, | |
| 253 | + Amount = e.Amount, | |
| 254 | + AmountInTripCurrency = converted, | |
| 255 | + Description = e.Description, | |
| 256 | + ExpenseDate = e.ExpenseDate, | |
| 257 | + SplitMethod = e.SplitMethod.ToString(), | |
| 258 | + }; | |
| 259 | + | |
| 260 | + if (includeSplits) | |
| 261 | + { | |
| 262 | + dto.Splits = allSplits.Where(s => s.ExpenseId == e.Id).Select(s => | |
| 263 | + { | |
| 264 | + userLookup.TryGetValue(s.UserId, out var splitUser); | |
| 265 | + return new ExpenseSplitDto | |
| 266 | + { | |
| 267 | + Id = s.Id, | |
| 268 | + UserId = s.UserId, | |
| 269 | + UserName = splitUser?.DisplayName, | |
| 270 | + Amount = s.Amount, | |
| 271 | + Percentage = s.Percentage, | |
| 272 | + }; | |
| 273 | + }).ToList(); | |
| 274 | + } | |
| 275 | + return dto; | |
| 276 | + }).ToList(); | |
| 277 | + } | |
| 278 | +} |
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.Contracts.Users.Queries; | |
| 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 | + | |
| 26 | + public SettlementsController(IExpensesUnitOfWork uow, IMediator mediator) | |
| 27 | + { | |
| 28 | + _uow = uow; | |
| 29 | + _mediator = mediator; | |
| 30 | + } | |
| 31 | + | |
| 32 | + [HttpGet("trip/{tripId:guid}")] | |
| 33 | + public async Task<ActionResult<SettlementPlanDto>> GetLatestPlan(Guid tripId) | |
| 34 | + { | |
| 35 | + var userId = CurrentUserId(); | |
| 36 | + if (userId == null) return Unauthorized(); | |
| 37 | + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid(); | |
| 38 | + | |
| 39 | + var plan = await GetLatestPlanForTripAsync(tripId); | |
| 40 | + if (plan == null) return NotFound(); | |
| 41 | + | |
| 42 | + return Ok(await BuildPlanDtoAsync(plan)); | |
| 43 | + } | |
| 44 | + | |
| 45 | + [HttpGet("trip/{tripId:guid}/balances")] | |
| 46 | + public async Task<ActionResult<List<BalanceDto>>> GetBalances(Guid tripId) | |
| 47 | + { | |
| 48 | + var userId = CurrentUserId(); | |
| 49 | + if (userId == null) return Unauthorized(); | |
| 50 | + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid(); | |
| 51 | + | |
| 52 | + return Ok(await CalculateBalancesAsync(tripId)); | |
| 53 | + } | |
| 54 | + | |
| 55 | + [HttpGet("trip/{tripId:guid}/summary")] | |
| 56 | + public async Task<ActionResult<SettlementSummaryDto>> GetSummary(Guid tripId) | |
| 57 | + { | |
| 58 | + var userId = CurrentUserId(); | |
| 59 | + if (userId == null) return Unauthorized(); | |
| 60 | + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid(); | |
| 61 | + | |
| 62 | + var balances = await CalculateBalancesAsync(tripId); | |
| 63 | + var plan = await GetLatestPlanForTripAsync(tripId); | |
| 64 | + | |
| 65 | + return Ok(new SettlementSummaryDto | |
| 66 | + { | |
| 67 | + Balances = balances, | |
| 68 | + LatestPlan = plan == null ? null : await BuildPlanDtoAsync(plan), | |
| 69 | + }); | |
| 70 | + } | |
| 71 | + | |
| 72 | + [HttpPost("payments/{paymentId:guid}/mark-paid")] | |
| 73 | + public async Task<IActionResult> MarkPaid(Guid paymentId) | |
| 74 | + { | |
| 75 | + var userId = CurrentUserId(); | |
| 76 | + if (userId == null) return Unauthorized(); | |
| 77 | + | |
| 78 | + var payment = await _uow.SettlementPayments.GetByIdAsync(paymentId); | |
| 79 | + if (payment == null) return NotFound(); | |
| 80 | + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId); | |
| 81 | + if (plan == null) return NotFound(); | |
| 82 | + | |
| 83 | + if (!await _mediator.Send(new IsTripParticipantQuery(plan.TripId, userId.Value))) return Forbid(); | |
| 84 | + if (payment.FromUserId != userId.Value) return Forbid(); | |
| 85 | + | |
| 86 | + payment.Status = EPaymentStatus.MarkedPaid; | |
| 87 | + payment.MarkedPaidAt = DateTime.UtcNow; | |
| 88 | + _uow.SettlementPayments.Update(payment); | |
| 89 | + await _uow.SaveChangesAsync(); | |
| 90 | + return Ok(); | |
| 91 | + } | |
| 92 | + | |
| 93 | + [HttpPost("payments/{paymentId:guid}/confirm")] | |
| 94 | + public async Task<IActionResult> ConfirmPayment(Guid paymentId) | |
| 95 | + { | |
| 96 | + var userId = CurrentUserId(); | |
| 97 | + if (userId == null) return Unauthorized(); | |
| 98 | + | |
| 99 | + var payment = await _uow.SettlementPayments.GetByIdAsync(paymentId); | |
| 100 | + if (payment == null) return NotFound(); | |
| 101 | + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId); | |
| 102 | + if (plan == null) return NotFound(); | |
| 103 | + | |
| 104 | + if (!await _mediator.Send(new IsTripParticipantQuery(plan.TripId, userId.Value))) return Forbid(); | |
| 105 | + if (payment.ToUserId != userId.Value) return Forbid(); | |
| 106 | + | |
| 107 | + payment.Status = EPaymentStatus.Confirmed; | |
| 108 | + payment.ConfirmedAt = DateTime.UtcNow; | |
| 109 | + _uow.SettlementPayments.Update(payment); | |
| 110 | + | |
| 111 | + var allPayments = (await _uow.SettlementPayments.GetAllAsync()) | |
| 112 | + .Where(p => p.SettlementPlanId == plan.Id) | |
| 113 | + .ToList(); | |
| 114 | + var allConfirmed = allPayments.All(p => p.Id == paymentId || p.Status == EPaymentStatus.Confirmed); | |
| 115 | + | |
| 116 | + plan.Status = allConfirmed ? ESettlementStatus.Completed : ESettlementStatus.InProgress; | |
| 117 | + if (allConfirmed) plan.CompletedAt = DateTime.UtcNow; | |
| 118 | + _uow.SettlementPlans.Update(plan); | |
| 119 | + | |
| 120 | + await _uow.SaveChangesAsync(); | |
| 121 | + | |
| 122 | + if (allConfirmed) | |
| 123 | + { | |
| 124 | + await _mediator.Publish(new SplitApp.Shared.Contracts.Expenses.Events.SettlementPlanCompletedEvent(plan.TripId, plan.Id)); | |
| 125 | + } | |
| 126 | + | |
| 127 | + return Ok(); | |
| 128 | + } | |
| 129 | + | |
| 130 | + [HttpPost("trip/{tripId:guid}/calculate")] | |
| 131 | + public async Task<ActionResult<SettlementPlanDto>> CalculatePlan(Guid tripId) | |
| 132 | + { | |
| 133 | + var userId = CurrentUserId(); | |
| 134 | + if (userId == null) return Unauthorized(); | |
| 135 | + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid(); | |
| 136 | + | |
| 137 | + var balances = await CalculateBalancesAsync(tripId); | |
| 138 | + var creditors = balances.Where(b => b.Balance > 0.01m) | |
| 139 | + .Select(b => new { b.UserId, Amount = b.Balance }) | |
| 140 | + .OrderByDescending(c => c.Amount).ToList(); | |
| 141 | + var debtors = balances.Where(b => b.Balance < -0.01m) | |
| 142 | + .Select(b => new { b.UserId, Amount = -b.Balance }) | |
| 143 | + .OrderByDescending(d => d.Amount).ToList(); | |
| 144 | + | |
| 145 | + if (creditors.Count == 0 || debtors.Count == 0) | |
| 146 | + return NotFound("No outstanding balances to settle."); | |
| 147 | + | |
| 148 | + var plan = new SettlementPlan | |
| 149 | + { | |
| 150 | + TripId = tripId, | |
| 151 | + CreatedByUserId = userId.Value, | |
| 152 | + TotalAmount = creditors.Sum(c => c.Amount), | |
| 153 | + Status = ESettlementStatus.Pending, | |
| 154 | + }; | |
| 155 | + _uow.SettlementPlans.Add(plan); | |
| 156 | + | |
| 157 | + var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount); | |
| 158 | + var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount); | |
| 159 | + var sortedCreditors = creditBalances.Keys.ToList(); | |
| 160 | + var sortedDebtors = debtBalances.Keys.ToList(); | |
| 161 | + var ci = 0; | |
| 162 | + var di = 0; | |
| 163 | + | |
| 164 | + while (ci < sortedCreditors.Count && di < sortedDebtors.Count) | |
| 165 | + { | |
| 166 | + var creditorId = sortedCreditors[ci]; | |
| 167 | + var debtorId = sortedDebtors[di]; | |
| 168 | + var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]); | |
| 169 | + | |
| 170 | + if (amount > 0.01m) | |
| 171 | + { | |
| 172 | + _uow.SettlementPayments.Add(new SettlementPayment | |
| 173 | + { | |
| 174 | + SettlementPlanId = plan.Id, | |
| 175 | + FromUserId = debtorId, | |
| 176 | + ToUserId = creditorId, | |
| 177 | + Amount = Math.Round(amount, 2), | |
| 178 | + Status = EPaymentStatus.Pending, | |
| 179 | + }); | |
| 180 | + } | |
| 181 | + | |
| 182 | + creditBalances[creditorId] -= amount; | |
| 183 | + debtBalances[debtorId] -= amount; | |
| 184 | + if (creditBalances[creditorId] < 0.01m) ci++; | |
| 185 | + if (debtBalances[debtorId] < 0.01m) di++; | |
| 186 | + } | |
| 187 | + | |
| 188 | + await _uow.SaveChangesAsync(); | |
| 189 | + | |
| 190 | + return Ok(await BuildPlanDtoAsync(plan)); | |
| 191 | + } | |
| 192 | + | |
| 193 | + private Guid? CurrentUserId() | |
| 194 | + { | |
| 195 | + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); | |
| 196 | + return Guid.TryParse(raw, out var id) ? id : null; | |
| 197 | + } | |
| 198 | + | |
| 199 | + private async Task<SettlementPlan?> GetLatestPlanForTripAsync(Guid tripId) | |
| 200 | + { | |
| 201 | + var all = await _uow.SettlementPlans.GetAllAsync(); | |
| 202 | + return all.Where(p => p.TripId == tripId) | |
| 203 | + .OrderByDescending(p => p.CreatedAt) | |
| 204 | + .FirstOrDefault(); | |
| 205 | + } | |
| 206 | + | |
| 207 | + private async Task<List<BalanceDto>> CalculateBalancesAsync(Guid tripId) | |
| 208 | + { | |
| 209 | + var trip = await _mediator.Send(new GetTripByIdQuery(tripId)); | |
| 210 | + var participants = await _mediator.Send(new GetTripParticipantsQuery(tripId)); | |
| 211 | + | |
| 212 | + if (trip == null || participants.Count == 0) return new List<BalanceDto>(); | |
| 213 | + | |
| 214 | + var defaultCurrency = await _uow.Currencies.GetByIdAsync(trip.DefaultCurrencyId); | |
| 215 | + var defaultCode = defaultCurrency?.Code ?? "EUR"; | |
| 216 | + | |
| 217 | + var userIds = participants.Select(p => p.UserId).Distinct().ToList(); | |
| 218 | + var users = await _mediator.Send(new GetUsersByIdsQuery(userIds)); | |
| 219 | + var nameLookup = users.ToDictionary(u => u.Id, u => u.DisplayName); | |
| 220 | + | |
| 221 | + var balances = participants.ToDictionary( | |
| 222 | + p => p.UserId, | |
| 223 | + p => new BalanceDto | |
| 224 | + { | |
| 225 | + UserId = p.UserId, | |
| 226 | + UserName = nameLookup.TryGetValue(p.UserId, out var name) ? name : null, | |
| 227 | + Balance = 0, | |
| 228 | + }); | |
| 229 | + | |
| 230 | + var allExpenses = (await _uow.Expenses.GetAllAsync()).Where(e => e.TripId == tripId).ToList(); | |
| 231 | + var allSplits = (await _uow.ExpenseSplits.GetAllAsync()).ToList(); | |
| 232 | + var allCurrencies = (await _uow.Currencies.GetAllAsync()).ToDictionary(c => c.Id, c => c.Code); | |
| 233 | + | |
| 234 | + foreach (var expense in allExpenses) | |
| 235 | + { | |
| 236 | + var fromCode = expense.CurrencyId.HasValue | |
| 237 | + && allCurrencies.TryGetValue(expense.CurrencyId.Value, out var code) | |
| 238 | + ? code : defaultCode; | |
| 239 | + var paidConverted = CurrencyConverter.Convert(expense.Amount, fromCode, defaultCode); | |
| 240 | + if (balances.TryGetValue(expense.PaidByUserId, out var payerEntry)) | |
| 241 | + { | |
| 242 | + payerEntry.Balance += paidConverted; | |
| 243 | + } | |
| 244 | + | |
| 245 | + var splitsForExpense = allSplits.Where(s => s.ExpenseId == expense.Id); | |
| 246 | + foreach (var split in splitsForExpense) | |
| 247 | + { | |
| 248 | + if (!balances.TryGetValue(split.UserId, out var splitEntry)) continue; | |
| 249 | + var splitConverted = CurrencyConverter.Convert(split.Amount, fromCode, defaultCode); | |
| 250 | + splitEntry.Balance -= splitConverted; | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + return balances.Values.OrderByDescending(b => b.Balance).ToList(); | |
| 255 | + } | |
| 256 | + | |
| 257 | + private async Task<SettlementPlanDto> BuildPlanDtoAsync(SettlementPlan plan) | |
| 258 | + { | |
| 259 | + var allPayments = (await _uow.SettlementPayments.GetAllAsync()) | |
| 260 | + .Where(p => p.SettlementPlanId == plan.Id) | |
| 261 | + .ToList(); | |
| 262 | + | |
| 263 | + var userIds = allPayments.SelectMany(p => new[] { p.FromUserId, p.ToUserId }).Distinct().ToList(); | |
| 264 | + var users = userIds.Count == 0 | |
| 265 | + ? new List<SplitApp.Shared.Contracts.Users.UserDto>() | |
| 266 | + : (await _mediator.Send(new GetUsersByIdsQuery(userIds))).ToList(); | |
| 267 | + var nameLookup = users.ToDictionary(u => u.Id, u => u.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.Contracts.Users.Queries; | |
| 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 | + | |
| 25 | + public SplitPresetsController(IExpensesUnitOfWork uow, IMediator mediator) | |
| 26 | + { | |
| 27 | + _uow = uow; | |
| 28 | + _mediator = mediator; | |
| 29 | + } | |
| 30 | + | |
| 31 | + [HttpGet("trip/{tripId:guid}")] | |
| 32 | + public async Task<ActionResult<List<SplitPresetDto>>> GetForTrip(Guid tripId) | |
| 33 | + { | |
| 34 | + var userId = CurrentUserId(); | |
| 35 | + if (userId == null) return Unauthorized(); | |
| 36 | + if (!await _mediator.Send(new IsTripParticipantQuery(tripId, userId.Value))) return Forbid(); | |
| 37 | + | |
| 38 | + var allPresets = await _uow.SplitPresets.GetAllAsync(); | |
| 39 | + var presets = allPresets.Where(p => p.TripId == tripId).ToList(); | |
| 40 | + return Ok(await BuildDtosAsync(presets)); | |
| 41 | + } | |
| 42 | + | |
| 43 | + [HttpGet("{id:guid}")] | |
| 44 | + public async Task<ActionResult<SplitPresetDto>> Get(Guid id) | |
| 45 | + { | |
| 46 | + var userId = CurrentUserId(); | |
| 47 | + if (userId == null) return Unauthorized(); | |
| 48 | + | |
| 49 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 50 | + if (preset == null) return NotFound(); | |
| 51 | + if (!await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value))) return NotFound(); | |
| 52 | + | |
| 53 | + var dtos = await BuildDtosAsync(new[] { preset }); | |
| 54 | + return Ok(dtos.Single()); | |
| 55 | + } | |
| 56 | + | |
| 57 | + [HttpPost] | |
| 58 | + public async Task<ActionResult<SplitPresetDto>> Create([FromBody] SplitPresetCreateDto dto) | |
| 59 | + { | |
| 60 | + var userId = CurrentUserId(); | |
| 61 | + if (userId == null) return Unauthorized(); | |
| 62 | + if (!await _mediator.Send(new IsTripParticipantQuery(dto.TripId, userId.Value))) return Forbid(); | |
| 63 | + | |
| 64 | + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var method)) | |
| 65 | + return BadRequest($"Unknown split method '{dto.SplitMethod}'."); | |
| 66 | + | |
| 67 | + var preset = new SplitPreset | |
| 68 | + { | |
| 69 | + TripId = dto.TripId, | |
| 70 | + Name = dto.Name, | |
| 71 | + SplitMethod = method, | |
| 72 | + CreatedById = userId.Value, | |
| 73 | + }; | |
| 74 | + _uow.SplitPresets.Add(preset); | |
| 75 | + | |
| 76 | + if (dto.Members != null) | |
| 77 | + { | |
| 78 | + foreach (var m in dto.Members) | |
| 79 | + { | |
| 80 | + _uow.SplitPresetMembers.Add(new SplitPresetMember | |
| 81 | + { | |
| 82 | + SplitPresetId = preset.Id, | |
| 83 | + UserId = m.UserId, | |
| 84 | + ShareWeight = m.ShareWeight, | |
| 85 | + Percentage = m.Percentage, | |
| 86 | + }); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + await _uow.SaveChangesAsync(); | |
| 91 | + | |
| 92 | + var dtos = await BuildDtosAsync(new[] { preset }); | |
| 93 | + return CreatedAtAction(nameof(Get), new { id = preset.Id }, dtos.Single()); | |
| 94 | + } | |
| 95 | + | |
| 96 | + [HttpPut("{id:guid}")] | |
| 97 | + public async Task<IActionResult> Update(Guid id, [FromBody] SplitPresetCreateDto dto) | |
| 98 | + { | |
| 99 | + var userId = CurrentUserId(); | |
| 100 | + if (userId == null) return Unauthorized(); | |
| 101 | + | |
| 102 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 103 | + if (preset == null) return NotFound(); | |
| 104 | + | |
| 105 | + var canEdit = preset.CreatedById == userId.Value | |
| 106 | + || await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value)); | |
| 107 | + if (!canEdit) return Forbid(); | |
| 108 | + | |
| 109 | + if (!Enum.TryParse<ESplitMethod>(dto.SplitMethod, true, out var method)) | |
| 110 | + return BadRequest($"Unknown split method '{dto.SplitMethod}'."); | |
| 111 | + | |
| 112 | + preset.Name = dto.Name; | |
| 113 | + preset.SplitMethod = method; | |
| 114 | + _uow.SplitPresets.Update(preset); | |
| 115 | + | |
| 116 | + var allMembers = await _uow.SplitPresetMembers.GetAllAsync(); | |
| 117 | + foreach (var existing in allMembers.Where(m => m.SplitPresetId == id)) | |
| 118 | + { | |
| 119 | + await _uow.SplitPresetMembers.RemoveAsync(existing.Id); | |
| 120 | + } | |
| 121 | + | |
| 122 | + if (dto.Members != null) | |
| 123 | + { | |
| 124 | + foreach (var m in dto.Members) | |
| 125 | + { | |
| 126 | + _uow.SplitPresetMembers.Add(new SplitPresetMember | |
| 127 | + { | |
| 128 | + SplitPresetId = preset.Id, | |
| 129 | + UserId = m.UserId, | |
| 130 | + ShareWeight = m.ShareWeight, | |
| 131 | + Percentage = m.Percentage, | |
| 132 | + }); | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + await _uow.SaveChangesAsync(); | |
| 137 | + return NoContent(); | |
| 138 | + } | |
| 139 | + | |
| 140 | + [HttpDelete("{id:guid}")] | |
| 141 | + public async Task<IActionResult> Delete(Guid id) | |
| 142 | + { | |
| 143 | + var userId = CurrentUserId(); | |
| 144 | + if (userId == null) return Unauthorized(); | |
| 145 | + | |
| 146 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 147 | + if (preset == null) return NotFound(); | |
| 148 | + | |
| 149 | + if (preset.CreatedById != userId.Value | |
| 150 | + && !await _mediator.Send(new IsTripParticipantQuery(preset.TripId, userId.Value))) | |
| 151 | + { | |
| 152 | + return Forbid(); | |
| 153 | + } | |
| 154 | + | |
| 155 | + var allMembers = await _uow.SplitPresetMembers.GetAllAsync(); | |
| 156 | + foreach (var m in allMembers.Where(m => m.SplitPresetId == id)) | |
| 157 | + { | |
| 158 | + await _uow.SplitPresetMembers.RemoveAsync(m.Id); | |
| 159 | + } | |
| 160 | + await _uow.SplitPresets.RemoveAsync(id); | |
| 161 | + await _uow.SaveChangesAsync(); | |
| 162 | + return NoContent(); | |
| 163 | + } | |
| 164 | + | |
| 165 | + private Guid? CurrentUserId() | |
| 166 | + { | |
| 167 | + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); | |
| 168 | + return Guid.TryParse(raw, out var id) ? id : null; | |
| 169 | + } | |
| 170 | + | |
| 171 | + private async Task<List<SplitPresetDto>> BuildDtosAsync(IEnumerable<SplitPreset> presets) | |
| 172 | + { | |
| 173 | + var presetList = presets.ToList(); | |
| 174 | + if (presetList.Count == 0) return new List<SplitPresetDto>(); | |
| 175 | + | |
| 176 | + var presetIds = presetList.Select(p => p.Id).ToHashSet(); | |
| 177 | + var allMembers = (await _uow.SplitPresetMembers.GetAllAsync()) | |
| 178 | + .Where(m => presetIds.Contains(m.SplitPresetId)) | |
| 179 | + .ToList(); | |
| 180 | + | |
| 181 | + var userIds = presetList.Select(p => p.CreatedById) | |
| 182 | + .Concat(allMembers.Select(m => m.UserId)) | |
| 183 | + .Distinct() | |
| 184 | + .ToList(); | |
| 185 | + | |
| 186 | + var users = userIds.Count == 0 | |
| 187 | + ? new List<SplitApp.Shared.Contracts.Users.UserDto>() | |
| 188 | + : (await _mediator.Send(new GetUsersByIdsQuery(userIds))).ToList(); | |
| 189 | + var nameLookup = users.ToDictionary(u => u.Id, u => u.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 +26 −0
| @@ -0,0 +1,26 @@ | ||
| 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 | + </ItemGroup> | |
| 25 | + | |
| 26 | +</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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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="..\..\Users\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.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/Handlers/UserDeletedHandler.cs +27 −0
| @@ -0,0 +1,27 @@ | ||
| 1 | +using MediatR; | |
| 2 | +using Microsoft.EntityFrameworkCore; | |
| 3 | +using SplitApp.Modules.Expenses.Infrastructure.Persistence; | |
| 4 | +using SplitApp.Shared.Contracts.Users.Events; | |
| 5 | + | |
| 6 | +namespace SplitApp.Modules.Expenses.Infrastructure.Handlers; | |
| 7 | + | |
| 8 | +public class UserDeletedHandler : INotificationHandler<UserDeletedEvent> | |
| 9 | +{ | |
| 10 | + private readonly ExpensesDbContext _db; | |
| 11 | + | |
| 12 | + public UserDeletedHandler(ExpensesDbContext db) | |
| 13 | + { | |
| 14 | + _db = db; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken) | |
| 18 | + { | |
| 19 | + await _db.ExpenseSplits | |
| 20 | + .Where(s => s.UserId == notification.UserId) | |
| 21 | + .ExecuteDeleteAsync(cancellationToken); | |
| 22 | + | |
| 23 | + await _db.SplitPresetMembers | |
| 24 | + .Where(m => m.UserId == notification.UserId) | |
| 25 | + .ExecuteDeleteAsync(cancellationToken); | |
| 26 | + } | |
| 27 | +} |
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 +198 −0
| @@ -0,0 +1,198 @@ | ||
| 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.Contracts.Users.Queries; | |
| 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 | + | |
| 25 | + public InvitationsController(ITripsUnitOfWork uow, IMediator mediator) | |
| 26 | + { | |
| 27 | + _uow = uow; | |
| 28 | + _mediator = mediator; | |
| 29 | + } | |
| 30 | + | |
| 31 | + [HttpPost] | |
| 32 | + public async Task<ActionResult<InvitationDto>> Create([FromBody] InvitationCreateDto dto) | |
| 33 | + { | |
| 34 | + var userId = CurrentUserId(); | |
| 35 | + if (userId == null) return Unauthorized(); | |
| 36 | + | |
| 37 | + if (!await IsOrganizerAsync(dto.TripId, userId.Value)) return Forbid(); | |
| 38 | + | |
| 39 | + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) | |
| 40 | + .Replace("+", "-").Replace("/", "_").TrimEnd('='); | |
| 41 | + | |
| 42 | + var invitation = new TripInvitation | |
| 43 | + { | |
| 44 | + TripId = dto.TripId, | |
| 45 | + InvitedByUserId = userId.Value, | |
| 46 | + Token = token, | |
| 47 | + Status = EInvitationStatus.Pending, | |
| 48 | + ExpiresAt = DateTime.UtcNow.AddDays(7), | |
| 49 | + }; | |
| 50 | + | |
| 51 | + _uow.Invitations.Add(invitation); | |
| 52 | + await _uow.SaveChangesAsync(); | |
| 53 | + | |
| 54 | + return CreatedAtAction(nameof(GetByToken), new { token = invitation.Token }, await BuildDtoAsync(invitation)); | |
| 55 | + } | |
| 56 | + | |
| 57 | + [HttpGet("{token}")] | |
| 58 | + [AllowAnonymous] | |
| 59 | + public async Task<ActionResult<InvitationDto>> GetByToken(string token) | |
| 60 | + { | |
| 61 | + var invitation = await FindByTokenAsync(token); | |
| 62 | + if (invitation == null) return NotFound(); | |
| 63 | + | |
| 64 | + return Ok(await BuildDtoAsync(invitation)); | |
| 65 | + } | |
| 66 | + | |
| 67 | + [HttpPost("{token}/accept")] | |
| 68 | + public async Task<IActionResult> Accept(string token) | |
| 69 | + { | |
| 70 | + var userId = CurrentUserId(); | |
| 71 | + if (userId == null) return Unauthorized(); | |
| 72 | + | |
| 73 | + var invitation = await FindByTokenAsync(token); | |
| 74 | + if (invitation == null) return NotFound(); | |
| 75 | + | |
| 76 | + if (invitation.Status != EInvitationStatus.Pending) | |
| 77 | + return BadRequest("Invitation is no longer pending."); | |
| 78 | + | |
| 79 | + if (invitation.ExpiresAt < DateTime.UtcNow) | |
| 80 | + { | |
| 81 | + invitation.Status = EInvitationStatus.Expired; | |
| 82 | + _uow.Invitations.Update(invitation); | |
| 83 | + await _uow.SaveChangesAsync(); | |
| 84 | + return BadRequest("Invitation has expired."); | |
| 85 | + } | |
| 86 | + | |
| 87 | + var allParticipants = await _uow.Participants.GetAllAsync(); | |
| 88 | + var existingActive = allParticipants.FirstOrDefault(p => | |
| 89 | + p.TripId == invitation.TripId && p.UserId == userId.Value && p.IsActive); | |
| 90 | + var existingInactive = allParticipants.FirstOrDefault(p => | |
| 91 | + p.TripId == invitation.TripId && p.UserId == userId.Value && !p.IsActive); | |
| 92 | + | |
| 93 | + if (existingActive != null) | |
| 94 | + { | |
| 95 | + return BadRequest("You are already a participant in this trip."); | |
| 96 | + } | |
| 97 | + | |
| 98 | + if (existingInactive != null) | |
| 99 | + { | |
| 100 | + existingInactive.IsActive = true; | |
| 101 | + existingInactive.LeftAt = null; | |
| 102 | + _uow.Participants.Update(existingInactive); | |
| 103 | + } | |
| 104 | + else | |
| 105 | + { | |
| 106 | + _uow.Participants.Add(new TripParticipant | |
| 107 | + { | |
| 108 | + TripId = invitation.TripId, | |
| 109 | + UserId = userId.Value, | |
| 110 | + Role = EParticipantRole.Participant, | |
| 111 | + JoinedAt = DateTime.UtcNow, | |
| 112 | + IsActive = true, | |
| 113 | + }); | |
| 114 | + } | |
| 115 | + | |
| 116 | + invitation.Status = EInvitationStatus.Accepted; | |
| 117 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 118 | + _uow.Invitations.Update(invitation); | |
| 119 | + | |
| 120 | + await _uow.SaveChangesAsync(); | |
| 121 | + return Ok(); | |
| 122 | + } | |
| 123 | + | |
| 124 | + [HttpPost("{token}/decline")] | |
| 125 | + public async Task<IActionResult> Decline(string token) | |
| 126 | + { | |
| 127 | + var invitation = await FindByTokenAsync(token); | |
| 128 | + if (invitation == null) return NotFound(); | |
| 129 | + | |
| 130 | + if (invitation.Status != EInvitationStatus.Pending) | |
| 131 | + return BadRequest("Invitation is no longer pending."); | |
| 132 | + | |
| 133 | + invitation.Status = EInvitationStatus.Declined; | |
| 134 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 135 | + _uow.Invitations.Update(invitation); | |
| 136 | + await _uow.SaveChangesAsync(); | |
| 137 | + return Ok(); | |
| 138 | + } | |
| 139 | + | |
| 140 | + [HttpPost("{token}/revoke")] | |
| 141 | + public async Task<IActionResult> Revoke(string token) | |
| 142 | + { | |
| 143 | + var userId = CurrentUserId(); | |
| 144 | + if (userId == null) return Unauthorized(); | |
| 145 | + | |
| 146 | + var invitation = await FindByTokenAsync(token); | |
| 147 | + if (invitation == null) return NotFound(); | |
| 148 | + | |
| 149 | + if (!await IsOrganizerAsync(invitation.TripId, userId.Value)) return Forbid(); | |
| 150 | + | |
| 151 | + invitation.Status = EInvitationStatus.Revoked; | |
| 152 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 153 | + _uow.Invitations.Update(invitation); | |
| 154 | + await _uow.SaveChangesAsync(); | |
| 155 | + return Ok(); | |
| 156 | + } | |
| 157 | + | |
| 158 | + private Guid? CurrentUserId() | |
| 159 | + { | |
| 160 | + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); | |
| 161 | + return Guid.TryParse(raw, out var id) ? id : null; | |
| 162 | + } | |
| 163 | + | |
| 164 | + private async Task<TripInvitation?> FindByTokenAsync(string token) | |
| 165 | + { | |
| 166 | + var all = await _uow.Invitations.GetAllAsync(); | |
| 167 | + return all.FirstOrDefault(i => i.Token == token); | |
| 168 | + } | |
| 169 | + | |
| 170 | + private async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId) | |
| 171 | + { | |
| 172 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 173 | + if (trip == null) return false; | |
| 174 | + if (trip.CreatedById == userId) return true; | |
| 175 | + var all = await _uow.Participants.GetAllAsync(); | |
| 176 | + return all.Any(p => p.TripId == tripId | |
| 177 | + && p.UserId == userId | |
| 178 | + && p.IsActive | |
| 179 | + && p.Role == EParticipantRole.Organizer); | |
| 180 | + } | |
| 181 | + | |
| 182 | + private async Task<InvitationDto> BuildDtoAsync(TripInvitation invitation) | |
| 183 | + { | |
| 184 | + var trip = await _uow.Trips.GetByIdAsync(invitation.TripId); | |
| 185 | + var inviter = await _mediator.Send(new GetUserByIdQuery(invitation.InvitedByUserId)); | |
| 186 | + | |
| 187 | + return new InvitationDto | |
| 188 | + { | |
| 189 | + Id = invitation.Id, | |
| 190 | + TripId = invitation.TripId, | |
| 191 | + TripName = trip?.Name, | |
| 192 | + Token = invitation.Token, | |
| 193 | + Status = invitation.Status.ToString(), | |
| 194 | + ExpiresAt = invitation.ExpiresAt, | |
| 195 | + InvitedByUserName = inviter?.DisplayName, | |
| 196 | + }; | |
| 197 | + } | |
| 198 | +} |
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 +328 −0
| @@ -0,0 +1,328 @@ | ||
| 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.Contracts.Users.Queries; | |
| 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 | + | |
| 27 | + public TripsController(ITripsUnitOfWork uow, IMediator mediator) | |
| 28 | + { | |
| 29 | + _uow = uow; | |
| 30 | + _mediator = mediator; | |
| 31 | + } | |
| 32 | + | |
| 33 | + [HttpGet] | |
| 34 | + public async Task<ActionResult<IEnumerable<TripDto>>> List() | |
| 35 | + { | |
| 36 | + var userId = CurrentUserId(); | |
| 37 | + if (userId == null) return Unauthorized(); | |
| 38 | + | |
| 39 | + var all = (await _uow.Trips.GetAllAsync()).ToList(); | |
| 40 | + var allParticipants = (await _uow.Participants.GetAllAsync()).ToList(); | |
| 41 | + var participantTripIds = allParticipants | |
| 42 | + .Where(p => p.UserId == userId.Value && p.IsActive) | |
| 43 | + .Select(p => p.TripId) | |
| 44 | + .ToHashSet(); | |
| 45 | + | |
| 46 | + var visible = all | |
| 47 | + .Where(t => t.CreatedById == userId.Value || participantTripIds.Contains(t.Id)) | |
| 48 | + .ToList(); | |
| 49 | + | |
| 50 | + // Cross-module currency lookup so frontend gets defaultCurrencyCode/Symbol. | |
| 51 | + var currencyIds = visible.Select(t => t.DefaultCurrencyId).Distinct().ToList(); | |
| 52 | + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(currencyIds))) | |
| 53 | + .ToDictionary(c => c.Id); | |
| 54 | + | |
| 55 | + // Per-trip participant counts, no embedded list (keeps payload small for index page). | |
| 56 | + var perTripParticipantCount = allParticipants | |
| 57 | + .Where(p => p.IsActive) | |
| 58 | + .GroupBy(p => p.TripId) | |
| 59 | + .ToDictionary(g => g.Key, g => g.Count()); | |
| 60 | + | |
| 61 | + return Ok(visible.Select(t => MapToDto(t, currencies, perTripParticipantCount.GetValueOrDefault(t.Id)))); | |
| 62 | + } | |
| 63 | + | |
| 64 | + [HttpGet("{id:guid}")] | |
| 65 | + public async Task<ActionResult<TripDto>> Get(Guid id) | |
| 66 | + { | |
| 67 | + var userId = CurrentUserId(); | |
| 68 | + if (userId == null) return Unauthorized(); | |
| 69 | + | |
| 70 | + var trip = await _uow.Trips.GetByIdAsync(id); | |
| 71 | + if (trip == null) return NotFound(); | |
| 72 | + if (!await CanReadAsync(trip, userId.Value)) return Forbid(); | |
| 73 | + | |
| 74 | + // Currency | |
| 75 | + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(new[] { trip.DefaultCurrencyId }))) | |
| 76 | + .ToDictionary(c => c.Id); | |
| 77 | + | |
| 78 | + // Participants + cross-module user name lookup | |
| 79 | + var allParticipants = await _uow.Participants.GetAllAsync(); | |
| 80 | + var tripParticipants = allParticipants | |
| 81 | + .Where(p => p.TripId == trip.Id) | |
| 82 | + .OrderBy(p => p.JoinedAt) | |
| 83 | + .ToList(); | |
| 84 | + var participantDtos = await BuildParticipantDtosAsync(tripParticipants); | |
| 85 | + | |
| 86 | + var dto = MapToDto(trip, currencies, tripParticipants.Count(p => p.IsActive)); | |
| 87 | + dto.Participants = participantDtos; | |
| 88 | + return Ok(dto); | |
| 89 | + } | |
| 90 | + | |
| 91 | + [HttpPost] | |
| 92 | + public async Task<ActionResult<TripDto>> Create([FromBody] TripCreateDto dto) | |
| 93 | + { | |
| 94 | + var userId = CurrentUserId(); | |
| 95 | + if (userId == null) return Unauthorized(); | |
| 96 | + | |
| 97 | + var trip = new Trip | |
| 98 | + { | |
| 99 | + Name = dto.Name, | |
| 100 | + Description = dto.Description, | |
| 101 | + Destination = dto.Destination, | |
| 102 | + StartDate = dto.StartDate, | |
| 103 | + EndDate = dto.EndDate, | |
| 104 | + DefaultCurrencyId = dto.DefaultCurrencyId, | |
| 105 | + CreatedById = userId.Value, | |
| 106 | + Status = ETripStatus.Active, | |
| 107 | + }; | |
| 108 | + _uow.Trips.Add(trip); | |
| 109 | + | |
| 110 | + _uow.Participants.Add(new TripParticipant | |
| 111 | + { | |
| 112 | + TripId = trip.Id, | |
| 113 | + UserId = userId.Value, | |
| 114 | + Role = EParticipantRole.Organizer, | |
| 115 | + JoinedAt = DateTime.UtcNow, | |
| 116 | + IsActive = true, | |
| 117 | + }); | |
| 118 | + | |
| 119 | + await _uow.SaveChangesAsync(); | |
| 120 | + | |
| 121 | + // Reload with full hydration so frontend gets a complete TripDto on POST response. | |
| 122 | + var currencies = (await _mediator.Send(new GetCurrenciesByIdsQuery(new[] { trip.DefaultCurrencyId }))) | |
| 123 | + .ToDictionary(c => c.Id); | |
| 124 | + var participants = (await _uow.Participants.GetAllAsync()) | |
| 125 | + .Where(p => p.TripId == trip.Id).OrderBy(p => p.JoinedAt).ToList(); | |
| 126 | + var participantDtos = await BuildParticipantDtosAsync(participants); | |
| 127 | + var responseDto = MapToDto(trip, currencies, participants.Count(p => p.IsActive)); | |
| 128 | + responseDto.Participants = participantDtos; | |
| 129 | + | |
| 130 | + return CreatedAtAction(nameof(Get), new { id = trip.Id, version = "1.0" }, responseDto); | |
| 131 | + } | |
| 132 | + | |
| 133 | + [HttpPut("{id:guid}")] | |
| 134 | + public async Task<IActionResult> Update(Guid id, [FromBody] TripUpdateDto dto) | |
| 135 | + { | |
| 136 | + var userId = CurrentUserId(); | |
| 137 | + if (userId == null) return Unauthorized(); | |
| 138 | + if (dto.Id != Guid.Empty && dto.Id != id) return BadRequest(); | |
| 139 | + | |
| 140 | + var trip = await _uow.Trips.GetByIdAsync(id); | |
| 141 | + if (trip == null) return NotFound(); | |
| 142 | + if (trip.CreatedById != userId.Value) return Forbid(); | |
| 143 | + | |
| 144 | + trip.Name = dto.Name; | |
| 145 | + trip.Description = dto.Description; | |
| 146 | + trip.Destination = dto.Destination; | |
| 147 | + trip.StartDate = dto.StartDate; | |
| 148 | + trip.EndDate = dto.EndDate; | |
| 149 | + trip.DefaultCurrencyId = dto.DefaultCurrencyId; | |
| 150 | + if (!string.IsNullOrEmpty(dto.Status) && Enum.TryParse<ETripStatus>(dto.Status, true, out var newStatus)) | |
| 151 | + { | |
| 152 | + trip.Status = newStatus; | |
| 153 | + } | |
| 154 | + _uow.Trips.Update(trip); | |
| 155 | + await _uow.SaveChangesAsync(); | |
| 156 | + return NoContent(); | |
| 157 | + } | |
| 158 | + | |
| 159 | + [HttpDelete("{id:guid}")] | |
| 160 | + public async Task<IActionResult> Delete(Guid id) | |
| 161 | + { | |
| 162 | + var userId = CurrentUserId(); | |
| 163 | + if (userId == null) return Unauthorized(); | |
| 164 | + | |
| 165 | + var trip = await _uow.Trips.GetByIdAsync(id); | |
| 166 | + if (trip == null) return NotFound(); | |
| 167 | + if (trip.CreatedById != userId.Value) return Forbid(); | |
| 168 | + | |
| 169 | + await _uow.Trips.RemoveAsync(id); | |
| 170 | + await _uow.SaveChangesAsync(); | |
| 171 | + | |
| 172 | + await _mediator.Publish(new TripDeletedEvent(id)); | |
| 173 | + return NoContent(); | |
| 174 | + } | |
| 175 | + | |
| 176 | + [HttpGet("{tripId:guid}/participants")] | |
| 177 | + public async Task<ActionResult<List<TripParticipantDto>>> GetParticipants(Guid tripId) | |
| 178 | + { | |
| 179 | + var userId = CurrentUserId(); | |
| 180 | + if (userId == null) return Unauthorized(); | |
| 181 | + | |
| 182 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 183 | + if (trip == null) return NotFound(); | |
| 184 | + if (!await CanReadAsync(trip, userId.Value)) return Forbid(); | |
| 185 | + | |
| 186 | + var rows = (await _uow.Participants.GetAllAsync()) | |
| 187 | + .Where(p => p.TripId == tripId) | |
| 188 | + .OrderBy(p => p.JoinedAt) | |
| 189 | + .ToList(); | |
| 190 | + return Ok(await BuildParticipantDtosAsync(rows)); | |
| 191 | + } | |
| 192 | + | |
| 193 | + [HttpPost("{id:guid}/finalize")] | |
| 194 | + public async Task<IActionResult> Finalize(Guid id) | |
| 195 | + { | |
| 196 | + var userId = CurrentUserId(); | |
| 197 | + if (userId == null) return Unauthorized(); | |
| 198 | + | |
| 199 | + var trip = await _uow.Trips.GetByIdAsync(id); | |
| 200 | + if (trip == null) return NotFound(); | |
| 201 | + if (trip.CreatedById != userId.Value) return Forbid(); | |
| 202 | + if (trip.Status != ETripStatus.Active) return BadRequest(new { error = "Trip must be active to finalize." }); | |
| 203 | + | |
| 204 | + trip.Status = ETripStatus.Finalizing; | |
| 205 | + _uow.Trips.Update(trip); | |
| 206 | + await _uow.SaveChangesAsync(); | |
| 207 | + | |
| 208 | + // Auto-create the settlement plan + concrete payments (phase-2 parity). | |
| 209 | + // The Vue front shows real Mark Paid / Confirm Receipt UI only when latestPlan is non-null; | |
| 210 | + // without this step it falls back to a "preview" view with no per-user actions. | |
| 211 | + var planId = await _mediator.Send(new CalculateSettlementCommand(id, userId.Value)); | |
| 212 | + | |
| 213 | + // No outstanding balances → trip is already settled. Mark accordingly. | |
| 214 | + if (planId == null) | |
| 215 | + { | |
| 216 | + trip.Status = ETripStatus.Settled; | |
| 217 | + _uow.Trips.Update(trip); | |
| 218 | + await _uow.SaveChangesAsync(); | |
| 219 | + } | |
| 220 | + | |
| 221 | + return Ok(); | |
| 222 | + } | |
| 223 | + | |
| 224 | + [HttpPost("{id:guid}/reopen")] | |
| 225 | + public async Task<IActionResult> Reopen(Guid id) | |
| 226 | + { | |
| 227 | + var userId = CurrentUserId(); | |
| 228 | + if (userId == null) return Unauthorized(); | |
| 229 | + | |
| 230 | + var trip = await _uow.Trips.GetByIdAsync(id); | |
| 231 | + if (trip == null) return NotFound(); | |
| 232 | + if (trip.CreatedById != userId.Value) return Forbid(); | |
| 233 | + if (trip.Status != ETripStatus.Finalizing && trip.Status != ETripStatus.Settled) | |
| 234 | + return BadRequest(new { error = "Trip can only be reopened from Finalizing or Settled." }); | |
| 235 | + | |
| 236 | + // Drop the existing settlement plan + payments so the next Finalize starts fresh. | |
| 237 | + // Returns false if any payment is already Confirmed — phase-2 parity. | |
| 238 | + var removed = await _mediator.Send(new RemoveSettlementPlanCommand(id)); | |
| 239 | + if (!removed) | |
| 240 | + { | |
| 241 | + return BadRequest(new { error = "Cannot reopen: settlement has confirmed payments." }); | |
| 242 | + } | |
| 243 | + | |
| 244 | + trip.Status = ETripStatus.Active; | |
| 245 | + _uow.Trips.Update(trip); | |
| 246 | + await _uow.SaveChangesAsync(); | |
| 247 | + return Ok(); | |
| 248 | + } | |
| 249 | + | |
| 250 | + [HttpDelete("{tripId:guid}/participants/{userId:guid}")] | |
| 251 | + public async Task<IActionResult> RemoveParticipant(Guid tripId, Guid userId) | |
| 252 | + { | |
| 253 | + var currentUserId = CurrentUserId(); | |
| 254 | + if (currentUserId == null) return Unauthorized(); | |
| 255 | + | |
| 256 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 257 | + if (trip == null) return NotFound(); | |
| 258 | + if (trip.CreatedById != currentUserId.Value) return Forbid(); | |
| 259 | + if (userId == currentUserId.Value) return BadRequest(new { error = "Cannot remove yourself." }); | |
| 260 | + | |
| 261 | + var participant = (await _uow.Participants.GetAllAsync()) | |
| 262 | + .FirstOrDefault(p => p.TripId == tripId && p.UserId == userId); | |
| 263 | + if (participant == null) return NotFound(); | |
| 264 | + if (participant.Role == EParticipantRole.Organizer) return BadRequest(new { error = "Cannot remove an organizer." }); | |
| 265 | + | |
| 266 | + participant.IsActive = false; | |
| 267 | + participant.LeftAt = DateTime.UtcNow; | |
| 268 | + _uow.Participants.Update(participant); | |
| 269 | + await _uow.SaveChangesAsync(); | |
| 270 | + return NoContent(); | |
| 271 | + } | |
| 272 | + | |
| 273 | + private Guid? CurrentUserId() | |
| 274 | + { | |
| 275 | + var raw = User.FindFirstValue(ClaimTypes.NameIdentifier); | |
| 276 | + return Guid.TryParse(raw, out var id) ? id : null; | |
| 277 | + } | |
| 278 | + | |
| 279 | + private async Task<bool> CanReadAsync(Trip trip, Guid userId) | |
| 280 | + { | |
| 281 | + if (trip.CreatedById == userId) return true; | |
| 282 | + var allParticipants = await _uow.Participants.GetAllAsync(); | |
| 283 | + return allParticipants.Any(p => p.TripId == trip.Id && p.UserId == userId && p.IsActive); | |
| 284 | + } | |
| 285 | + | |
| 286 | + private async Task<List<TripParticipantDto>> BuildParticipantDtosAsync(IList<TripParticipant> rows) | |
| 287 | + { | |
| 288 | + if (rows.Count == 0) return new List<TripParticipantDto>(); | |
| 289 | + var userIds = rows.Select(p => p.UserId).Distinct().ToList(); | |
| 290 | + var users = (await _mediator.Send(new GetUsersByIdsQuery(userIds))) | |
| 291 | + .ToDictionary(u => u.Id); | |
| 292 | + return rows.Select(p => | |
| 293 | + { | |
| 294 | + users.TryGetValue(p.UserId, out var u); | |
| 295 | + return new TripParticipantDto | |
| 296 | + { | |
| 297 | + Id = p.Id, | |
| 298 | + TripId = p.TripId, | |
| 299 | + UserId = p.UserId, | |
| 300 | + UserName = u?.DisplayName, | |
| 301 | + UserEmail = u?.Email, | |
| 302 | + Role = p.Role.ToString(), | |
| 303 | + Nickname = p.Nickname, | |
| 304 | + JoinedAt = p.JoinedAt, | |
| 305 | + IsActive = p.IsActive, | |
| 306 | + }; | |
| 307 | + }).ToList(); | |
| 308 | + } | |
| 309 | + | |
| 310 | + private static TripDto MapToDto( | |
| 311 | + Trip trip, | |
| 312 | + IDictionary<Guid, SplitApp.Shared.Contracts.Expenses.CurrencyDto> currencies, | |
| 313 | + int participantCount) => new() | |
| 314 | + { | |
| 315 | + Id = trip.Id, | |
| 316 | + Name = trip.Name, | |
| 317 | + Description = trip.Description, | |
| 318 | + Destination = trip.Destination, | |
| 319 | + StartDate = trip.StartDate, | |
| 320 | + EndDate = trip.EndDate, | |
| 321 | + Status = trip.Status.ToString(), | |
| 322 | + DefaultCurrencyId = trip.DefaultCurrencyId, | |
| 323 | + DefaultCurrencyCode = currencies.TryGetValue(trip.DefaultCurrencyId, out var c) ? c.Code : null, | |
| 324 | + DefaultCurrencySymbol = currencies.TryGetValue(trip.DefaultCurrencyId, out var c2) ? c2.Symbol : null, | |
| 325 | + CreatedById = trip.CreatedById, | |
| 326 | + ParticipantCount = participantCount, | |
| 327 | + }; | |
| 328 | +} |
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.Contracts.Users.Queries; | |
| 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 | + | |
| 24 | + public WishlistController(ITripsUnitOfWork uow, IMediator mediator) | |
| 25 | + { | |
| 26 | + _uow = uow; | |
| 27 | + _mediator = mediator; | |
| 28 | + } | |
| 29 | + | |
| 30 | + [HttpGet("trip/{tripId:guid}")] | |
| 31 | + public async Task<ActionResult<List<WishlistItemDto>>> GetForTrip(Guid tripId) | |
| 32 | + { | |
| 33 | + var userId = CurrentUserId(); | |
| 34 | + if (userId == null) return Unauthorized(); | |
| 35 | + | |
| 36 | + if (!await IsParticipantAsync(tripId, userId.Value)) return Forbid(); | |
| 37 | + | |
| 38 | + var allItems = await _uow.WishlistItems.GetAllAsync(); | |
| 39 | + var items = allItems.Where(i => i.TripId == tripId).OrderBy(i => i.DisplayOrder).ToList(); | |
| 40 | + var allVotes = await _uow.WishlistVotes.GetAllAsync(); | |
| 41 | + | |
| 42 | + var addedByIds = items.Select(i => i.AddedByUserId).Distinct().ToList(); | |
| 43 | + var users = addedByIds.Count == 0 | |
| 44 | + ? new List<SplitApp.Shared.Contracts.Users.UserDto>() | |
| 45 | + : (await _mediator.Send(new GetUsersByIdsQuery(addedByIds))).ToList(); | |
| 46 | + var nameLookup = users.ToDictionary(u => u.Id, u => u.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 +26 −0
| @@ -0,0 +1,26 @@ | ||
| 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 | + </ItemGroup> | |
| 25 | + | |
| 26 | +</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.Modules.Users.Domain.Entities; | |
| 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, never by EF (NotMapped).</summary> | |
| 34 | + [NotMapped] public AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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.Modules.Users.Domain.Entities; | |
| 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 AppUser? 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="..\..\Users\SplitApp.Modules.Users.Domain\SplitApp.Modules.Users.Domain.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/Handlers/UserDeletedHandler.cs +35 −0
| @@ -0,0 +1,35 @@ | ||
| 1 | +using MediatR; | |
| 2 | +using Microsoft.EntityFrameworkCore; | |
| 3 | +using SplitApp.Modules.Trips.Infrastructure.Persistence; | |
| 4 | +using SplitApp.Shared.Contracts.Users.Events; | |
| 5 | + | |
| 6 | +namespace SplitApp.Modules.Trips.Infrastructure.Handlers; | |
| 7 | + | |
| 8 | +public class UserDeletedHandler : INotificationHandler<UserDeletedEvent> | |
| 9 | +{ | |
| 10 | + private readonly TripsDbContext _db; | |
| 11 | + | |
| 12 | + public UserDeletedHandler(TripsDbContext db) | |
| 13 | + { | |
| 14 | + _db = db; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken) | |
| 18 | + { | |
| 19 | + await _db.TripParticipants | |
| 20 | + .Where(p => p.UserId == notification.UserId) | |
| 21 | + .ExecuteDeleteAsync(cancellationToken); | |
| 22 | + | |
| 23 | + await _db.TripInvitations | |
| 24 | + .Where(i => i.InvitedByUserId == notification.UserId) | |
| 25 | + .ExecuteDeleteAsync(cancellationToken); | |
| 26 | + | |
| 27 | + await _db.TripPollVotes | |
| 28 | + .Where(v => v.UserId == notification.UserId) | |
| 29 | + .ExecuteDeleteAsync(cancellationToken); | |
| 30 | + | |
| 31 | + await _db.TripWishlistVotes | |
| 32 | + .Where(v => v.UserId == notification.UserId) | |
| 33 | + .ExecuteDeleteAsync(cancellationToken); | |
| 34 | + } | |
| 35 | +} |
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/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 +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 | + <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 | + </ItemGroup> | |
| 26 | + | |
| 27 | +</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/Handlers/GetUserByIdHandler.cs +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +using MediatR; | |
| 2 | +using SplitApp.Modules.Users.Application.Contracts; | |
| 3 | +using SplitApp.Shared.Contracts.Users; | |
| 4 | +using SplitApp.Shared.Contracts.Users.Queries; | |
| 5 | + | |
| 6 | +namespace SplitApp.Modules.Users.Application.Handlers; | |
| 7 | + | |
| 8 | +public class GetUserByIdHandler : IRequestHandler<GetUserByIdQuery, UserDto?> | |
| 9 | +{ | |
| 10 | + private readonly IUserRepository _users; | |
| 11 | + | |
| 12 | + public GetUserByIdHandler(IUserRepository users) | |
| 13 | + { | |
| 14 | + _users = users; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task<UserDto?> Handle(GetUserByIdQuery request, CancellationToken cancellationToken) | |
| 18 | + { | |
| 19 | + var user = await _users.GetByIdAsync(request.UserId); | |
| 20 | + if (user == null) return null; | |
| 21 | + return new UserDto( | |
| 22 | + user.Id, | |
| 23 | + $"{user.FirstName} {user.LastName}".Trim(), | |
| 24 | + user.Email ?? ""); | |
| 25 | + } | |
| 26 | +} |
added SplitApp.Modular/src/Modules/Users/SplitApp.Modules.Users.Application/Handlers/GetUsersByIdsHandler.cs +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +using MediatR; | |
| 2 | +using SplitApp.Modules.Users.Application.Contracts; | |
| 3 | +using SplitApp.Shared.Contracts.Users; | |
| 4 | +using SplitApp.Shared.Contracts.Users.Queries; | |
| 5 | + | |
| 6 | +namespace SplitApp.Modules.Users.Application.Handlers; | |
| 7 | + | |
| 8 | +public class GetUsersByIdsHandler : IRequestHandler<GetUsersByIdsQuery, IReadOnlyList<UserDto>> | |
| 9 | +{ | |
| 10 | + private readonly IUserRepository _users; | |
| 11 | + | |
| 12 | + public GetUsersByIdsHandler(IUserRepository users) | |
| 13 | + { | |
| 14 | + _users = users; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task<IReadOnlyList<UserDto>> Handle(GetUsersByIdsQuery request, CancellationToken cancellationToken) | |
| 18 | + { | |
| 19 | + if (request.UserIds.Count == 0) return Array.Empty<UserDto>(); | |
| 20 | + var found = await _users.GetByIdsAsync(request.UserIds); | |
| 21 | + return found | |
| 22 | + .Select(u => new UserDto(u.Id, $"{u.FirstName} {u.LastName}".Trim(), u.Email ?? "")) | |
| 23 | + .ToList(); | |
| 24 | + } | |
| 25 | +} |
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 +271 −0
| @@ -0,0 +1,271 @@ | ||
| 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.Contracts.Users.Events; | |
| 9 | +using SplitApp.Shared.Kernel.Auth; | |
| 10 | + | |
| 11 | +namespace SplitApp.Modules.Users.Application.Services; | |
| 12 | + | |
| 13 | +public class IdentityService : IIdentityService | |
| 14 | +{ | |
| 15 | + private readonly IUsersUnitOfWork _uow; | |
| 16 | + private readonly UserManager<AppUser> _userManager; | |
| 17 | + private readonly IConfiguration _configuration; | |
| 18 | + private readonly IMediator _mediator; | |
| 19 | + | |
| 20 | + public IdentityService( | |
| 21 | + IUsersUnitOfWork uow, | |
| 22 | + UserManager<AppUser> userManager, | |
| 23 | + IConfiguration configuration, | |
| 24 | + IMediator mediator) | |
| 25 | + { | |
| 26 | + _uow = uow; | |
| 27 | + _userManager = userManager; | |
| 28 | + _configuration = configuration; | |
| 29 | + _mediator = mediator; | |
| 30 | + } | |
| 31 | + | |
| 32 | + public async Task<IdentityServiceResult> RegisterAsync(RegisterRequest request) | |
| 33 | + { | |
| 34 | + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds); | |
| 35 | + | |
| 36 | + var existing = await _userManager.FindByEmailAsync(request.Email); | |
| 37 | + if (existing != null) | |
| 38 | + { | |
| 39 | + return IdentityServiceResult.Fail( | |
| 40 | + $"User with email {request.Email} is already registered", | |
| 41 | + IdentityServiceErrorKind.BadRequest); | |
| 42 | + } | |
| 43 | + | |
| 44 | + var refreshToken = new AppRefreshToken(); | |
| 45 | + var appUser = new AppUser | |
| 46 | + { | |
| 47 | + Email = request.Email, | |
| 48 | + UserName = request.Email, | |
| 49 | + FirstName = request.FirstName, | |
| 50 | + LastName = request.LastName, | |
| 51 | + RefreshTokens = new List<AppRefreshToken> { refreshToken } | |
| 52 | + }; | |
| 53 | + refreshToken.AppUser = appUser; | |
| 54 | + | |
| 55 | + var createResult = await _userManager.CreateAsync(appUser, request.Password); | |
| 56 | + if (!createResult.Succeeded) | |
| 57 | + { | |
| 58 | + return IdentityServiceResult.Fail( | |
| 59 | + createResult.Errors.First().Description, | |
| 60 | + IdentityServiceErrorKind.BadRequest); | |
| 61 | + } | |
| 62 | + | |
| 63 | + await _userManager.AddToRoleAsync(appUser, "user"); | |
| 64 | + | |
| 65 | + var claimsResult = await _userManager.AddClaimsAsync(appUser, new List<Claim> | |
| 66 | + { | |
| 67 | + new(ClaimTypes.GivenName, appUser.FirstName), | |
| 68 | + new(ClaimTypes.Surname, appUser.LastName) | |
| 69 | + }); | |
| 70 | + if (!claimsResult.Succeeded) | |
| 71 | + { | |
| 72 | + return IdentityServiceResult.Fail( | |
| 73 | + claimsResult.Errors.First().Description, | |
| 74 | + IdentityServiceErrorKind.BadRequest); | |
| 75 | + } | |
| 76 | + | |
| 77 | + var reloaded = await _userManager.FindByEmailAsync(appUser.Email); | |
| 78 | + if (reloaded == null) | |
| 79 | + { | |
| 80 | + return IdentityServiceResult.Fail( | |
| 81 | + $"User with email {request.Email} is not found after registration", | |
| 82 | + IdentityServiceErrorKind.BadRequest); | |
| 83 | + } | |
| 84 | + | |
| 85 | + var jwt = await GenerateJwtAsync(reloaded, expiresInSeconds); | |
| 86 | + | |
| 87 | + return IdentityServiceResult.Ok(new IdentityJwtPayload | |
| 88 | + { | |
| 89 | + Jwt = jwt, | |
| 90 | + RefreshToken = refreshToken.RefreshToken, | |
| 91 | + FirstName = reloaded.FirstName, | |
| 92 | + LastName = reloaded.LastName | |
| 93 | + }); | |
| 94 | + } | |
| 95 | + | |
| 96 | + public async Task<IdentityServiceResult> LoginAsync(LoginRequest request) | |
| 97 | + { | |
| 98 | + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds); | |
| 99 | + | |
| 100 | + var appUser = await _userManager.FindByEmailAsync(request.Email); | |
| 101 | + if (appUser == null) | |
| 102 | + { | |
| 103 | + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound); | |
| 104 | + } | |
| 105 | + | |
| 106 | + var passwordOk = await _userManager.CheckPasswordAsync(appUser, request.Password); | |
| 107 | + if (!passwordOk) | |
| 108 | + { | |
| 109 | + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound); | |
| 110 | + } | |
| 111 | + | |
| 112 | + await _uow.RefreshTokens.RemoveExpiredForUserAsync(appUser.Id); | |
| 113 | + | |
| 114 | + var refreshToken = new AppRefreshToken | |
| 115 | + { | |
| 116 | + AppUserId = appUser.Id | |
| 117 | + }; | |
| 118 | + _uow.RefreshTokens.Add(refreshToken); | |
| 119 | + await _uow.SaveChangesAsync(); | |
| 120 | + | |
| 121 | + var jwt = await GenerateJwtAsync(appUser, expiresInSeconds); | |
| 122 | + | |
| 123 | + return IdentityServiceResult.Ok(new IdentityJwtPayload | |
| 124 | + { | |
| 125 | + Jwt = jwt, | |
| 126 | + RefreshToken = refreshToken.RefreshToken, | |
| 127 | + FirstName = appUser.FirstName, | |
| 128 | + LastName = appUser.LastName | |
| 129 | + }); | |
| 130 | + } | |
| 131 | + | |
| 132 | + public async Task<IdentityServiceResult> RefreshTokenAsync(RefreshRequest request) | |
| 133 | + { | |
| 134 | + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds); | |
| 135 | + | |
| 136 | + JwtSecurityToken? jwt; | |
| 137 | + try | |
| 138 | + { | |
| 139 | + jwt = new JwtSecurityTokenHandler().ReadJwtToken(request.Jwt); | |
| 140 | + } | |
| 141 | + catch (Exception) | |
| 142 | + { | |
| 143 | + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest); | |
| 144 | + } | |
| 145 | + | |
| 146 | + if (jwt == null) | |
| 147 | + { | |
| 148 | + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest); | |
| 149 | + } | |
| 150 | + | |
| 151 | + if (!IdentityHelpers.ValidateJWT( | |
| 152 | + request.Jwt, | |
| 153 | + _configuration.GetValue<string>("JWT:Key")!, | |
| 154 | + _configuration.GetValue<string>("JWT:Issuer")!, | |
| 155 | + _configuration.GetValue<string>("JWT:Audience")!)) | |
| 156 | + { | |
| 157 | + return IdentityServiceResult.Fail("JWT validation fail", IdentityServiceErrorKind.BadRequest); | |
| 158 | + } | |
| 159 | + | |
| 160 | + var userEmail = jwt.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value; | |
| 161 | + if (userEmail == null) | |
| 162 | + { | |
| 163 | + return IdentityServiceResult.Fail("No email in jwt", IdentityServiceErrorKind.BadRequest); | |
| 164 | + } | |
| 165 | + | |
| 166 | + var appUser = await _userManager.FindByEmailAsync(userEmail); | |
| 167 | + if (appUser == null) | |
| 168 | + { | |
| 169 | + return IdentityServiceResult.Fail($"User with email {userEmail} not found", IdentityServiceErrorKind.NotFound); | |
| 170 | + } | |
| 171 | + | |
| 172 | + var matchingTokens = (await _uow.RefreshTokens | |
| 173 | + .GetUserActiveTokensAsync(appUser.Id, request.RefreshToken)).ToList(); | |
| 174 | + | |
| 175 | + if (matchingTokens.Count == 0) | |
| 176 | + { | |
| 177 | + return IdentityServiceResult.Fail( | |
| 178 | + "RefreshTokens collection is null or empty - 0", | |
| 179 | + IdentityServiceErrorKind.NotFound); | |
| 180 | + } | |
| 181 | + | |
| 182 | + if (matchingTokens.Count != 1) | |
| 183 | + { | |
| 184 | + return IdentityServiceResult.Fail( | |
| 185 | + "More than one valid refresh token found", | |
| 186 | + IdentityServiceErrorKind.NotFound); | |
| 187 | + } | |
| 188 | + | |
| 189 | + var refreshToken = matchingTokens.First(); | |
| 190 | + if (refreshToken.RefreshToken == request.RefreshToken) | |
| 191 | + { | |
| 192 | + refreshToken.PreviousRefreshToken = refreshToken.RefreshToken; | |
| 193 | + refreshToken.PreviousExpirationDT = DateTime.UtcNow.AddMinutes(1); | |
| 194 | + refreshToken.RefreshToken = Guid.NewGuid().ToString(); | |
| 195 | + refreshToken.ExpirationDT = DateTime.UtcNow.AddDays(7); | |
| 196 | + _uow.RefreshTokens.Update(refreshToken); | |
| 197 | + await _uow.SaveChangesAsync(); | |
| 198 | + } | |
| 199 | + | |
| 200 | + var newJwt = await GenerateJwtAsync(appUser, expiresInSeconds); | |
| 201 | + | |
| 202 | + return IdentityServiceResult.Ok(new IdentityJwtPayload | |
| 203 | + { | |
| 204 | + Jwt = newJwt, | |
| 205 | + RefreshToken = refreshToken.RefreshToken, | |
| 206 | + FirstName = appUser.FirstName, | |
| 207 | + LastName = appUser.LastName | |
| 208 | + }); | |
| 209 | + } | |
| 210 | + | |
| 211 | + public async Task<IdentityServiceResult> LogoutAsync(LogoutRequest request) | |
| 212 | + { | |
| 213 | + var appUser = await _uow.Users.GetByIdAsync(request.UserId); | |
| 214 | + if (appUser == null) | |
| 215 | + { | |
| 216 | + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound); | |
| 217 | + } | |
| 218 | + | |
| 219 | + var tokens = (await _uow.RefreshTokens | |
| 220 | + .GetUserTokensByValueAsync(request.UserId, request.RefreshToken)).ToList(); | |
| 221 | + | |
| 222 | + foreach (var token in tokens) | |
| 223 | + { | |
| 224 | + _uow.RefreshTokens.Remove(token); | |
| 225 | + } | |
| 226 | + | |
| 227 | + var deleteCount = await _uow.SaveChangesAsync(); | |
| 228 | + return IdentityServiceResult.Logout(deleteCount); | |
| 229 | + } | |
| 230 | + | |
| 231 | + private int ResolveExpiresInSeconds(int requested) | |
| 232 | + { | |
| 233 | + if (requested <= 0) requested = int.MaxValue; | |
| 234 | + var configured = _configuration.GetValue<int>("JWT:ExpiresInSeconds"); | |
| 235 | + return requested < configured ? requested : configured; | |
| 236 | + } | |
| 237 | + | |
| 238 | + private async Task<string> GenerateJwtAsync(AppUser user, int expiresInSeconds) | |
| 239 | + { | |
| 240 | + var claims = new List<Claim> | |
| 241 | + { | |
| 242 | + new(ClaimTypes.NameIdentifier, user.Id.ToString()), | |
| 243 | + new(ClaimTypes.Email, user.Email ?? ""), | |
| 244 | + new(ClaimTypes.Name, user.UserName ?? user.Email ?? ""), | |
| 245 | + new(ClaimTypes.GivenName, user.FirstName), | |
| 246 | + new(ClaimTypes.Surname, user.LastName) | |
| 247 | + }; | |
| 248 | + | |
| 249 | + var userClaims = await _userManager.GetClaimsAsync(user); | |
| 250 | + foreach (var c in userClaims) | |
| 251 | + { | |
| 252 | + if (!claims.Any(existing => existing.Type == c.Type && existing.Value == c.Value)) | |
| 253 | + { | |
| 254 | + claims.Add(c); | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + var roles = await _userManager.GetRolesAsync(user); | |
| 259 | + foreach (var role in roles) | |
| 260 | + { | |
| 261 | + claims.Add(new Claim(ClaimTypes.Role, role)); | |
| 262 | + } | |
| 263 | + | |
| 264 | + return IdentityHelpers.GenerateJwt( | |
| 265 | + claims, | |
| 266 | + _configuration.GetValue<string>("JWT:Key")!, | |
| 267 | + _configuration.GetValue<string>("JWT:Issuer")!, | |
| 268 | + _configuration.GetValue<string>("JWT:Audience")!, | |
| 269 | + expiresInSeconds); | |
| 270 | + } | |
| 271 | +} |
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@taltech.ee", Password: demoPassword, FirstName: "Test", LastName: "User", Roles: new[] { "user" }), | |
| 106 | + (Email: "alice@taltech.ee", Password: demoPassword, FirstName: "Alice", LastName: "Johnson", Roles: new[] { "user" }), | |
| 107 | + (Email: "bob@taltech.ee", Password: demoPassword, FirstName: "Bob", LastName: "Smith", Roles: new[] { "user" }), | |
| 108 | + (Email: "charlie@taltech.ee", Password: demoPassword, FirstName: "Charlie", LastName: "Brown", Roles: new[] { "user" }), | |
| 109 | + (Email: "diana@taltech.ee", Password: demoPassword, FirstName: "Diana", LastName: "Miller", Roles: new[] { "user" }), | |
| 110 | + }; | |
| 111 | + | |
| 112 | + if (!string.IsNullOrWhiteSpace(adminPassword)) | |
| 113 | + { | |
| 114 | + seedUsers = | |
| 115 | + [ | |
| 116 | + (Email: "admin@taltech.ee", 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/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/Events/UserDeletedEvent.cs +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +using MediatR; | |
| 2 | + | |
| 3 | +namespace SplitApp.Shared.Contracts.Users.Events; | |
| 4 | + | |
| 5 | +public record UserDeletedEvent(Guid UserId) : INotification; |
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Queries/GetUserByIdQuery.cs +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +using MediatR; | |
| 2 | + | |
| 3 | +namespace SplitApp.Shared.Contracts.Users.Queries; | |
| 4 | + | |
| 5 | +public record GetUserByIdQuery(Guid UserId) : IRequest<UserDto?>; |
added SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/Users/Queries/GetUsersByIdsQuery.cs +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +using MediatR; | |
| 2 | + | |
| 3 | +namespace SplitApp.Shared.Contracts.Users.Queries; | |
| 4 | + | |
| 5 | +public record GetUsersByIdsQuery(IReadOnlyCollection<Guid> UserIds) : IRequest<IReadOnlyList<UserDto>>; |
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/SplitApp.WebApp/Application/Contracts/IAppUnitOfWork.cs +100 −0
| @@ -0,0 +1,100 @@ | ||
| 1 | +using SplitApp.Modules.Expenses.Domain.Entities; | |
| 2 | +using SplitApp.Modules.Trips.Domain.Entities; | |
| 3 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 4 | +using SplitApp.Shared.Kernel.Domain; | |
| 5 | +using SplitApp.Shared.Kernel.Persistence; | |
| 6 | + | |
| 7 | +namespace SplitApp.WebApp.Application.Contracts; | |
| 8 | + | |
| 9 | +/// <summary> | |
| 10 | +/// Composition-root unit-of-work facade. Aggregates the three module DbContexts | |
| 11 | +/// so legacy phase 2 BLL services (lifted into WebApp/Application) keep working. | |
| 12 | +/// Modules themselves never see this — they own their own UoWs. | |
| 13 | +/// </summary> | |
| 14 | +public interface IAppUnitOfWork : IUnitOfWork | |
| 15 | +{ | |
| 16 | + ITripRepository Trips { get; } | |
| 17 | + IExpenseRepository Expenses { get; } | |
| 18 | + ITripParticipantRepository TripParticipants { get; } | |
| 19 | + ITripInvitationRepository TripInvitations { get; } | |
| 20 | + ISettlementPlanRepository SettlementPlans { get; } | |
| 21 | + ISettlementPaymentRepository SettlementPayments { get; } | |
| 22 | + ITripPollRepository TripPolls { get; } | |
| 23 | + ITripWishlistItemRepository TripWishlistItems { get; } | |
| 24 | + ISplitPresetRepository SplitPresets { get; } | |
| 25 | + IBudgetCategoryRepository BudgetCategories { get; } | |
| 26 | + IRefreshTokenRepository RefreshTokens { get; } | |
| 27 | + IUserRepository Users { get; } | |
| 28 | + IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity; | |
| 29 | +} | |
| 30 | + | |
| 31 | +public interface ITripRepository : IBaseRepository<Trip> | |
| 32 | +{ | |
| 33 | + Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId); | |
| 34 | + Task<Trip?> GetByIdWithDetailsAsync(Guid id); | |
| 35 | +} | |
| 36 | + | |
| 37 | +public interface IExpenseRepository : IBaseRepository<Expense> | |
| 38 | +{ | |
| 39 | + Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId); | |
| 40 | + Task<Expense?> GetByIdWithDetailsAsync(Guid id); | |
| 41 | +} | |
| 42 | + | |
| 43 | +public interface ITripParticipantRepository : IBaseRepository<TripParticipant> | |
| 44 | +{ | |
| 45 | + Task<bool> IsParticipantAsync(Guid tripId, Guid userId); | |
| 46 | + Task<bool> IsOrganizerAsync(Guid tripId, Guid userId); | |
| 47 | + Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId); | |
| 48 | +} | |
| 49 | + | |
| 50 | +public interface ITripInvitationRepository : IBaseRepository<TripInvitation> | |
| 51 | +{ | |
| 52 | + Task<TripInvitation?> GetByTokenAsync(string token); | |
| 53 | + Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId); | |
| 54 | +} | |
| 55 | + | |
| 56 | +public interface ISettlementPlanRepository : IBaseRepository<SettlementPlan> | |
| 57 | +{ | |
| 58 | + Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId); | |
| 59 | + Task DeletePlanWithPaymentsAsync(Guid planId); | |
| 60 | +} | |
| 61 | + | |
| 62 | +public interface ISettlementPaymentRepository : IBaseRepository<SettlementPayment> | |
| 63 | +{ | |
| 64 | +} | |
| 65 | + | |
| 66 | +public interface ITripPollRepository : IBaseRepository<TripPoll> | |
| 67 | +{ | |
| 68 | + Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId); | |
| 69 | + Task<TripPoll?> GetByIdWithDetailsAsync(Guid id); | |
| 70 | +} | |
| 71 | + | |
| 72 | +public interface ITripWishlistItemRepository : IBaseRepository<TripWishlistItem> | |
| 73 | +{ | |
| 74 | + Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId); | |
| 75 | +} | |
| 76 | + | |
| 77 | +public interface ISplitPresetRepository : IBaseRepository<SplitPreset> | |
| 78 | +{ | |
| 79 | + Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId); | |
| 80 | +} | |
| 81 | + | |
| 82 | +public interface IBudgetCategoryRepository : IBaseRepository<BudgetCategory> | |
| 83 | +{ | |
| 84 | + Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId); | |
| 85 | +} | |
| 86 | + | |
| 87 | +public interface IRefreshTokenRepository : IBaseRepository<AppRefreshToken> | |
| 88 | +{ | |
| 89 | + Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue); | |
| 90 | + Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue); | |
| 91 | + Task<int> RemoveExpiredForUserAsync(Guid userId); | |
| 92 | + void Remove(AppRefreshToken token); | |
| 93 | +} | |
| 94 | + | |
| 95 | +public interface IUserRepository : IBaseRepository<AppUser> | |
| 96 | +{ | |
| 97 | + Task<int> CountAsync(); | |
| 98 | + Task<IEnumerable<AppUser>> GetRecentAsync(int take); | |
| 99 | + Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId); | |
| 100 | +} |
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 +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.Modules.Users.Domain.Entities; | |
| 6 | +using SplitApp.Shared.Kernel.Domain; | |
| 7 | +using SplitApp.Shared.Kernel.Localization; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.DTO; | |
| 10 | + | |
| 11 | +public class ExpenseBllDto | |
| 12 | +{ | |
| 13 | + public Guid Id { get; set; } | |
| 14 | + public DateTime CreatedAt { get; set; } | |
| 15 | + public DateTime UpdatedAt { get; set; } | |
| 16 | + | |
| 17 | + public Guid TripId { get; set; } | |
| 18 | + public TripBllDto? Trip { get; set; } | |
| 19 | + public string? TripName => Trip?.Name; | |
| 20 | + | |
| 21 | + public Guid PaidByUserId { get; set; } | |
| 22 | + public AppUserBllDto? PaidByUser { get; set; } | |
| 23 | + public string? PaidByUserFullName => PaidByUser?.FullName; | |
| 24 | + public string? PaidByUserEmail => PaidByUser?.Email; | |
| 25 | + | |
| 26 | + public Guid? BudgetCategoryId { get; set; } | |
| 27 | + public BudgetCategoryBllDto? BudgetCategory { get; set; } | |
| 28 | + | |
| 29 | + public Guid? CurrencyId { get; set; } | |
| 30 | + public CurrencyBllDto? Currency { get; set; } | |
| 31 | + | |
| 32 | + public decimal Amount { get; set; } | |
| 33 | + public string? Description { get; set; } | |
| 34 | + public DateTime ExpenseDate { get; set; } | |
| 35 | + public ESplitMethod SplitMethod { get; set; } | |
| 36 | + | |
| 37 | + public ICollection<ExpenseSplitBllDto>? Splits { get; set; } | |
| 38 | +} |
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 +31 −0
| @@ -0,0 +1,31 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 6 | +using SplitApp.Shared.Kernel.Domain; | |
| 7 | +using SplitApp.Shared.Kernel.Localization; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.DTO; | |
| 10 | + | |
| 11 | +public class SettlementPaymentBllDto | |
| 12 | +{ | |
| 13 | + public Guid Id { get; set; } | |
| 14 | + public DateTime CreatedAt { get; set; } | |
| 15 | + public DateTime UpdatedAt { get; set; } | |
| 16 | + | |
| 17 | + public Guid SettlementPlanId { get; set; } | |
| 18 | + | |
| 19 | + public Guid FromUserId { get; set; } | |
| 20 | + public AppUserBllDto? FromUser { get; set; } | |
| 21 | + public string? FromUserFullName => FromUser?.FullName; | |
| 22 | + | |
| 23 | + public Guid ToUserId { get; set; } | |
| 24 | + public AppUserBllDto? ToUser { get; set; } | |
| 25 | + public string? ToUserFullName => ToUser?.FullName; | |
| 26 | + | |
| 27 | + public decimal Amount { get; set; } | |
| 28 | + public EPaymentStatus Status { get; set; } = EPaymentStatus.Pending; | |
| 29 | + public DateTime? MarkedPaidAt { get; set; } | |
| 30 | + public DateTime? ConfirmedAt { get; set; } | |
| 31 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SettlementPlanBllDto.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.Modules.Users.Domain.Entities; | |
| 6 | +using SplitApp.Shared.Kernel.Domain; | |
| 7 | +using SplitApp.Shared.Kernel.Localization; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.DTO; | |
| 10 | + | |
| 11 | +public class SettlementPlanBllDto | |
| 12 | +{ | |
| 13 | + public Guid Id { get; set; } | |
| 14 | + public DateTime CreatedAt { get; set; } | |
| 15 | + public DateTime UpdatedAt { get; set; } | |
| 16 | + | |
| 17 | + public Guid TripId { get; set; } | |
| 18 | + public TripBllDto? Trip { get; set; } | |
| 19 | + public string? TripName => Trip?.Name; | |
| 20 | + | |
| 21 | + public Guid CreatedByUserId { get; set; } | |
| 22 | + public AppUserBllDto? CreatedByUser { get; set; } | |
| 23 | + public string? CreatedByUserFullName => CreatedByUser?.FullName; | |
| 24 | + | |
| 25 | + public decimal TotalAmount { get; set; } | |
| 26 | + public ESettlementStatus Status { get; set; } = ESettlementStatus.Pending; | |
| 27 | + public DateTime? CompletedAt { get; set; } | |
| 28 | + | |
| 29 | + public ICollection<SettlementPaymentBllDto>? Payments { get; set; } | |
| 30 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/SplitPresetBllDto.cs +42 −0
| @@ -0,0 +1,42 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 6 | +using SplitApp.Shared.Kernel.Domain; | |
| 7 | +using SplitApp.Shared.Kernel.Localization; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.DTO; | |
| 10 | + | |
| 11 | +public class SplitPresetBllDto | |
| 12 | +{ | |
| 13 | + public Guid Id { get; set; } | |
| 14 | + public DateTime CreatedAt { get; set; } | |
| 15 | + public DateTime UpdatedAt { get; set; } | |
| 16 | + | |
| 17 | + public Guid TripId { get; set; } | |
| 18 | + public TripBllDto? Trip { get; set; } | |
| 19 | + public string? TripName => Trip?.Name; | |
| 20 | + | |
| 21 | + public string Name { get; set; } = default!; | |
| 22 | + public ESplitMethod SplitMethod { get; set; } | |
| 23 | + | |
| 24 | + public Guid CreatedById { get; set; } | |
| 25 | + public AppUserBllDto? CreatedBy { get; set; } | |
| 26 | + public string? CreatedByFullName => CreatedBy?.FullName; | |
| 27 | + | |
| 28 | + public ICollection<SplitPresetMemberBllDto>? Members { get; set; } | |
| 29 | +} | |
| 30 | + | |
| 31 | +public class SplitPresetMemberBllDto | |
| 32 | +{ | |
| 33 | + public Guid Id { get; set; } | |
| 34 | + public DateTime CreatedAt { get; set; } | |
| 35 | + public DateTime UpdatedAt { get; set; } | |
| 36 | + | |
| 37 | + public Guid SplitPresetId { get; set; } | |
| 38 | + public Guid UserId { get; set; } | |
| 39 | + public string? UserFullName { get; set; } | |
| 40 | + public decimal? ShareWeight { get; set; } | |
| 41 | + public decimal? Percentage { get; set; } | |
| 42 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripBllDto.cs +39 −0
| @@ -0,0 +1,39 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 6 | +using SplitApp.Shared.Kernel.Domain; | |
| 7 | +using SplitApp.Shared.Kernel.Localization; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.DTO; | |
| 10 | + | |
| 11 | +public class TripBllDto | |
| 12 | +{ | |
| 13 | + public Guid Id { get; set; } | |
| 14 | + public DateTime CreatedAt { get; set; } | |
| 15 | + public DateTime UpdatedAt { get; set; } | |
| 16 | + | |
| 17 | + public string Name { get; set; } = default!; | |
| 18 | + public string? Description { get; set; } | |
| 19 | + public string? Destination { get; set; } | |
| 20 | + public DateTime? StartDate { get; set; } | |
| 21 | + public DateTime? EndDate { get; set; } | |
| 22 | + public ETripStatus Status { get; set; } = ETripStatus.Active; | |
| 23 | + | |
| 24 | + public Guid DefaultCurrencyId { get; set; } | |
| 25 | + public CurrencyBllDto? DefaultCurrency { get; set; } | |
| 26 | + | |
| 27 | + public Guid CreatedById { get; set; } | |
| 28 | + public AppUserBllDto? CreatedBy { get; set; } | |
| 29 | + public string? CreatedByFullName => CreatedBy?.FullName; | |
| 30 | + public string? CreatedByEmail => CreatedBy?.Email; | |
| 31 | + | |
| 32 | + public ICollection<TripParticipantBllDto>? Participants { get; set; } | |
| 33 | + public ICollection<ExpenseBllDto>? Expenses { get; set; } | |
| 34 | + public ICollection<BudgetCategoryBllDto>? BudgetCategories { get; set; } | |
| 35 | + public ICollection<TripWishlistItemBllDto>? WishlistItems { get; set; } | |
| 36 | + public ICollection<TripPollBllDto>? Polls { get; set; } | |
| 37 | + public ICollection<TripInvitationBllDto>? Invitations { get; set; } | |
| 38 | + public ICollection<SettlementPlanBllDto>? SettlementPlans { get; set; } | |
| 39 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripInvitationBllDto.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.Modules.Users.Domain.Entities; | |
| 6 | +using SplitApp.Shared.Kernel.Domain; | |
| 7 | +using SplitApp.Shared.Kernel.Localization; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.DTO; | |
| 10 | + | |
| 11 | +public class TripInvitationBllDto | |
| 12 | +{ | |
| 13 | + public Guid Id { get; set; } | |
| 14 | + public DateTime CreatedAt { get; set; } | |
| 15 | + public DateTime UpdatedAt { get; set; } | |
| 16 | + | |
| 17 | + public Guid TripId { get; set; } | |
| 18 | + public TripBllDto? Trip { get; set; } | |
| 19 | + public string? TripName => Trip?.Name; | |
| 20 | + | |
| 21 | + public Guid InvitedByUserId { get; set; } | |
| 22 | + public AppUserBllDto? InvitedByUser { get; set; } | |
| 23 | + public string? InvitedByUserFullName => InvitedByUser?.FullName; | |
| 24 | + public string? InvitedByUserEmail => InvitedByUser?.Email; | |
| 25 | + | |
| 26 | + public string Token { get; set; } = default!; | |
| 27 | + public EInvitationStatus Status { get; set; } = EInvitationStatus.Pending; | |
| 28 | + public DateTime ExpiresAt { get; set; } | |
| 29 | + public DateTime? RespondedAt { get; set; } | |
| 30 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/DTO/TripParticipantBllDto.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.Modules.Users.Domain.Entities; | |
| 6 | +using SplitApp.Shared.Kernel.Domain; | |
| 7 | +using SplitApp.Shared.Kernel.Localization; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.DTO; | |
| 10 | + | |
| 11 | +public class TripParticipantBllDto | |
| 12 | +{ | |
| 13 | + public Guid Id { get; set; } | |
| 14 | + public DateTime CreatedAt { get; set; } | |
| 15 | + public DateTime UpdatedAt { get; set; } | |
| 16 | + | |
| 17 | + public Guid TripId { get; set; } | |
| 18 | + public TripBllDto? Trip { get; set; } | |
| 19 | + public Guid UserId { get; set; } | |
| 20 | + public AppUserBllDto? User { get; set; } | |
| 21 | + public string? UserFirstName => User?.FirstName; | |
| 22 | + public string? UserLastName => User?.LastName; | |
| 23 | + public string? UserEmail => User?.Email; | |
| 24 | + | |
| 25 | + public EParticipantRole Role { get; set; } = EParticipantRole.Participant; | |
| 26 | + public string? Nickname { get; set; } | |
| 27 | + public DateTime JoinedAt { get; set; } | |
| 28 | + public DateTime? LeftAt { get; set; } | |
| 29 | + public bool IsActive { get; set; } = true; | |
| 30 | +} |
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 +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.Modules.Users.Domain.Entities; | |
| 6 | +using SplitApp.Shared.Kernel.Domain; | |
| 7 | +using SplitApp.Shared.Kernel.Localization; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.DTO; | |
| 10 | + | |
| 11 | +public class TripWishlistItemBllDto | |
| 12 | +{ | |
| 13 | + public Guid Id { get; set; } | |
| 14 | + public DateTime CreatedAt { get; set; } | |
| 15 | + public DateTime UpdatedAt { get; set; } | |
| 16 | + | |
| 17 | + public Guid TripId { get; set; } | |
| 18 | + public TripBllDto? Trip { get; set; } | |
| 19 | + public string? TripName => Trip?.Name; | |
| 20 | + | |
| 21 | + public Guid AddedByUserId { get; set; } | |
| 22 | + public AppUserBllDto? AddedByUser { get; set; } | |
| 23 | + public string? AddedByUserFullName => AddedByUser?.FullName; | |
| 24 | + | |
| 25 | + public string Title { get; set; } = default!; | |
| 26 | + public string? Description { get; set; } | |
| 27 | + public EWishlistCategory Category { get; set; } | |
| 28 | + public EWishlistPriority Priority { get; set; } | |
| 29 | + public decimal? EstimatedCost { get; set; } | |
| 30 | + public string? Url { get; set; } | |
| 31 | + public string? Location { get; set; } | |
| 32 | + public bool IsCompleted { get; set; } | |
| 33 | + public DateTime? CompletedAt { get; set; } | |
| 34 | + public int DisplayOrder { get; set; } | |
| 35 | + | |
| 36 | + public int VoteCount { get; set; } | |
| 37 | + public List<Guid> VoterUserIds { get; set; } = new(); | |
| 38 | +} |
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 +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 2 | +using SplitApp.WebApp.Application.DTO; | |
| 3 | + | |
| 4 | +namespace SplitApp.WebApp.Application.Mappers; | |
| 5 | + | |
| 6 | +public static class AppUserBllDtoFactory | |
| 7 | +{ | |
| 8 | + public static AppUserBllDto? Create(AppUser? entity) => entity == null ? null : new AppUserBllDto | |
| 9 | + { | |
| 10 | + Id = entity.Id, | |
| 11 | + FirstName = entity.FirstName, | |
| 12 | + LastName = entity.LastName, | |
| 13 | + Email = entity.Email | |
| 14 | + }; | |
| 15 | +} |
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 +32 −0
| @@ -0,0 +1,32 @@ | ||
| 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 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 7 | + | |
| 8 | +namespace SplitApp.WebApp.Application.Mappers; | |
| 9 | + | |
| 10 | +public static class CurrencyBllDtoFactory | |
| 11 | +{ | |
| 12 | + public static CurrencyBllDto Create(Currency entity) => new() | |
| 13 | + { | |
| 14 | + Id = entity.Id, | |
| 15 | + CreatedAt = entity.CreatedAt, | |
| 16 | + UpdatedAt = entity.UpdatedAt, | |
| 17 | + Code = entity.Code, | |
| 18 | + Name = entity.Name, | |
| 19 | + Symbol = entity.Symbol | |
| 20 | + }; | |
| 21 | + | |
| 22 | + public static List<CurrencyBllDto> CreateList(IEnumerable<Currency> entities) | |
| 23 | + => entities.Select(Create).ToList(); | |
| 24 | + | |
| 25 | + public static Currency ToEntity(CurrencyBllDto dto) => new() | |
| 26 | + { | |
| 27 | + Id = dto.Id, | |
| 28 | + Code = dto.Code, | |
| 29 | + Name = dto.Name, | |
| 30 | + Symbol = dto.Symbol | |
| 31 | + }; | |
| 32 | +} |
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 != null ? $"{entity.User.FirstName} {entity.User.LastName}".Trim() : null, | |
| 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 != null ? $"{entity.User.FirstName} {entity.User.LastName}".Trim() : null, | |
| 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 +54 −0
| @@ -0,0 +1,54 @@ | ||
| 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 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 7 | + | |
| 8 | +namespace SplitApp.WebApp.Application.Mappers; | |
| 9 | + | |
| 10 | +public static class WishlistBllDtoFactory | |
| 11 | +{ | |
| 12 | + public static TripWishlistItemBllDto Create(TripWishlistItem entity) => new() | |
| 13 | + { | |
| 14 | + Id = entity.Id, | |
| 15 | + CreatedAt = entity.CreatedAt, | |
| 16 | + UpdatedAt = entity.UpdatedAt, | |
| 17 | + TripId = entity.TripId, | |
| 18 | + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null, | |
| 19 | + AddedByUserId = entity.AddedByUserId, | |
| 20 | + AddedByUser = AppUserBllDtoFactory.Create(entity.AddedByUser), | |
| 21 | + Title = entity.Title, | |
| 22 | + Description = entity.Description, | |
| 23 | + Category = entity.Category, | |
| 24 | + Priority = entity.Priority, | |
| 25 | + EstimatedCost = entity.EstimatedCost, | |
| 26 | + Url = entity.Url, | |
| 27 | + Location = entity.Location, | |
| 28 | + IsCompleted = entity.IsCompleted, | |
| 29 | + CompletedAt = entity.CompletedAt, | |
| 30 | + DisplayOrder = entity.DisplayOrder, | |
| 31 | + VoteCount = entity.Votes?.Count(v => v.IsInterested) ?? 0, | |
| 32 | + VoterUserIds = entity.Votes?.Where(v => v.IsInterested).Select(v => v.UserId).ToList() ?? new List<Guid>() | |
| 33 | + }; | |
| 34 | + | |
| 35 | + public static List<TripWishlistItemBllDto> CreateList(IEnumerable<TripWishlistItem> entities) | |
| 36 | + => entities.Select(Create).ToList(); | |
| 37 | + | |
| 38 | + public static TripWishlistItem ToEntity(TripWishlistItemBllDto dto) => new() | |
| 39 | + { | |
| 40 | + Id = dto.Id, | |
| 41 | + TripId = dto.TripId, | |
| 42 | + AddedByUserId = dto.AddedByUserId, | |
| 43 | + Title = dto.Title, | |
| 44 | + Description = dto.Description, | |
| 45 | + Category = dto.Category, | |
| 46 | + Priority = dto.Priority, | |
| 47 | + EstimatedCost = dto.EstimatedCost, | |
| 48 | + Url = dto.Url, | |
| 49 | + Location = dto.Location, | |
| 50 | + IsCompleted = dto.IsCompleted, | |
| 51 | + CompletedAt = dto.CompletedAt, | |
| 52 | + DisplayOrder = dto.DisplayOrder | |
| 53 | + }; | |
| 54 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs +653 −0
| @@ -0,0 +1,653 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 7 | +using SplitApp.Modules.Users.Infrastructure.Persistence; | |
| 8 | +using SplitApp.Shared.Kernel.Domain; | |
| 9 | +using SplitApp.Shared.Kernel.Persistence; | |
| 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 three module DbContexts. The | |
| 16 | +/// repositories below each route to the appropriate module DbContext. Cross-module | |
| 17 | +/// joins happen in C# (LINQ) rather than SQL — modules still own their schemas. | |
| 18 | +/// Modules themselves never see this; only the WebApp host does. | |
| 19 | +/// </summary> | |
| 20 | +public class AppUnitOfWork : IAppUnitOfWork | |
| 21 | +{ | |
| 22 | + private readonly TripsDbContext _tripsDb; | |
| 23 | + private readonly ExpensesDbContext _expensesDb; | |
| 24 | + private readonly UsersDbContext _usersDb; | |
| 25 | + | |
| 26 | + public AppUnitOfWork(TripsDbContext tripsDb, ExpensesDbContext expensesDb, UsersDbContext usersDb) | |
| 27 | + { | |
| 28 | + _tripsDb = tripsDb; | |
| 29 | + _expensesDb = expensesDb; | |
| 30 | + _usersDb = usersDb; | |
| 31 | + | |
| 32 | + // Repos that load entities with [NotMapped] cross-module navs (e.g. AppUser, Currency) | |
| 33 | + // receive the foreign DbContexts so they can hydrate those navs in C# after the | |
| 34 | + // primary query — EF can't follow them because the FK crosses Postgres schemas. | |
| 35 | + Trips = new TripRepo(tripsDb, usersDb, expensesDb); | |
| 36 | + Expenses = new ExpenseRepo(expensesDb, usersDb, tripsDb); | |
| 37 | + TripParticipants = new TripParticipantRepo(tripsDb, usersDb); | |
| 38 | + TripInvitations = new TripInvitationRepo(tripsDb, usersDb); | |
| 39 | + SettlementPlans = new SettlementPlanRepo(expensesDb, usersDb, tripsDb); | |
| 40 | + SettlementPayments = new SettlementPaymentRepo(expensesDb, usersDb); | |
| 41 | + TripPolls = new TripPollRepo(tripsDb, usersDb); | |
| 42 | + TripWishlistItems = new TripWishlistItemRepo(tripsDb, usersDb); | |
| 43 | + SplitPresets = new SplitPresetRepo(expensesDb, usersDb, tripsDb); | |
| 44 | + BudgetCategories = new BudgetCategoryRepo(tripsDb); | |
| 45 | + RefreshTokens = new RefreshTokenRepo(usersDb); | |
| 46 | + Users = new UserRepo(usersDb); | |
| 47 | + } | |
| 48 | + | |
| 49 | + public ITripRepository Trips { get; } | |
| 50 | + public IExpenseRepository Expenses { get; } | |
| 51 | + public ITripParticipantRepository TripParticipants { get; } | |
| 52 | + public ITripInvitationRepository TripInvitations { get; } | |
| 53 | + public ISettlementPlanRepository SettlementPlans { get; } | |
| 54 | + public ISettlementPaymentRepository SettlementPayments { get; } | |
| 55 | + public ITripPollRepository TripPolls { get; } | |
| 56 | + public ITripWishlistItemRepository TripWishlistItems { get; } | |
| 57 | + public ISplitPresetRepository SplitPresets { get; } | |
| 58 | + public IBudgetCategoryRepository BudgetCategories { get; } | |
| 59 | + public IRefreshTokenRepository RefreshTokens { get; } | |
| 60 | + public IUserRepository Users { get; } | |
| 61 | + | |
| 62 | + public async Task<int> SaveChangesAsync() | |
| 63 | + { | |
| 64 | + var trips = await _tripsDb.SaveChangesAsync(); | |
| 65 | + var expenses = await _expensesDb.SaveChangesAsync(); | |
| 66 | + var users = await _usersDb.SaveChangesAsync(); | |
| 67 | + return trips + expenses + users; | |
| 68 | + } | |
| 69 | + | |
| 70 | + public IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity | |
| 71 | + { | |
| 72 | + var t = typeof(TEntity); | |
| 73 | + if (t == typeof(SettlementPayment)) return (IBaseRepository<TEntity>)SettlementPayments; | |
| 74 | + if (t == typeof(SettlementPlan)) return (IBaseRepository<TEntity>)SettlementPlans; | |
| 75 | + if (t == typeof(Trip)) return (IBaseRepository<TEntity>)Trips; | |
| 76 | + if (t == typeof(TripParticipant)) return (IBaseRepository<TEntity>)TripParticipants; | |
| 77 | + if (t == typeof(Expense)) return (IBaseRepository<TEntity>)Expenses; | |
| 78 | + if (t == typeof(TripInvitation)) return (IBaseRepository<TEntity>)TripInvitations; | |
| 79 | + if (t == typeof(BudgetCategory)) return (IBaseRepository<TEntity>)BudgetCategories; | |
| 80 | + if (t == typeof(TripPoll)) return (IBaseRepository<TEntity>)TripPolls; | |
| 81 | + if (t == typeof(TripWishlistItem)) return (IBaseRepository<TEntity>)TripWishlistItems; | |
| 82 | + if (t == typeof(SplitPreset)) return (IBaseRepository<TEntity>)SplitPresets; | |
| 83 | + if (t == typeof(AppRefreshToken)) return (IBaseRepository<TEntity>)RefreshTokens; | |
| 84 | + if (t == typeof(AppUser)) return (IBaseRepository<TEntity>)Users; | |
| 85 | + // Generic per-DbContext repos for entities BLL services touch via GetRepository<T> | |
| 86 | + // but that aren't called out in the typed properties above. | |
| 87 | + if (t == typeof(Currency)) return new GenericExpensesRepo<Currency>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException(); | |
| 88 | + if (t == typeof(ExpenseSplit)) return new GenericExpensesRepo<ExpenseSplit>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException(); | |
| 89 | + if (t == typeof(SplitPresetMember)) return new GenericExpensesRepo<SplitPresetMember>(_expensesDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException(); | |
| 90 | + if (t == typeof(TripPollOption)) return new GenericTripsRepo<TripPollOption>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException(); | |
| 91 | + if (t == typeof(TripPollVote)) return new GenericTripsRepo<TripPollVote>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException(); | |
| 92 | + if (t == typeof(TripWishlistVote)) return new GenericTripsRepo<TripWishlistVote>(_tripsDb) as IBaseRepository<TEntity> ?? throw new InvalidOperationException(); | |
| 93 | + throw new InvalidOperationException($"No repository registered for {t.Name}"); | |
| 94 | + } | |
| 95 | +} | |
| 96 | + | |
| 97 | +internal class GenericTripsRepo<TEntity> : GenericRepo<TEntity, TripsDbContext> | |
| 98 | + where TEntity : class, IBaseEntity | |
| 99 | +{ | |
| 100 | + public GenericTripsRepo(TripsDbContext db) : base(db) { } | |
| 101 | +} | |
| 102 | + | |
| 103 | +internal class GenericExpensesRepo<TEntity> : GenericRepo<TEntity, ExpensesDbContext> | |
| 104 | + where TEntity : class, IBaseEntity | |
| 105 | +{ | |
| 106 | + public GenericExpensesRepo(ExpensesDbContext db) : base(db) { } | |
| 107 | +} | |
| 108 | + | |
| 109 | +internal class GenericRepo<TEntity, TDbContext> : IBaseRepository<TEntity> | |
| 110 | + where TEntity : class, IBaseEntity | |
| 111 | + where TDbContext : DbContext | |
| 112 | +{ | |
| 113 | + protected readonly TDbContext Db; | |
| 114 | + protected readonly DbSet<TEntity> Set; | |
| 115 | + | |
| 116 | + protected GenericRepo(TDbContext db) | |
| 117 | + { | |
| 118 | + Db = db; | |
| 119 | + Set = db.Set<TEntity>(); | |
| 120 | + } | |
| 121 | + | |
| 122 | + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await Set.ToListAsync(); | |
| 123 | + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await Set.FirstOrDefaultAsync(e => e.Id == id); | |
| 124 | + public virtual TEntity Add(TEntity entity) => Set.Add(entity).Entity; | |
| 125 | + public virtual TEntity Update(TEntity entity) => Set.Update(entity).Entity; | |
| 126 | + public virtual async Task<TEntity?> RemoveAsync(Guid id) | |
| 127 | + { | |
| 128 | + var e = await GetByIdAsync(id); | |
| 129 | + if (e == null) return null; | |
| 130 | + return Set.Remove(e).Entity; | |
| 131 | + } | |
| 132 | + public virtual async Task<bool> ExistsAsync(Guid id) => await Set.AnyAsync(e => e.Id == id); | |
| 133 | +} | |
| 134 | + | |
| 135 | +internal static class CrossModuleHydration | |
| 136 | +{ | |
| 137 | + /// <summary>Batches a single AspNetUsers lookup, populates each item's User nav.</summary> | |
| 138 | + public static async Task HydrateUsersAsync<T>( | |
| 139 | + UsersDbContext usersDb, | |
| 140 | + IEnumerable<T> items, | |
| 141 | + Func<T, Guid> getUserId, | |
| 142 | + Action<T, AppUser?> setUser) where T : class | |
| 143 | + { | |
| 144 | + var list = items as IList<T> ?? items.ToList(); | |
| 145 | + var ids = list.Select(getUserId).Where(id => id != Guid.Empty).Distinct().ToList(); | |
| 146 | + if (ids.Count == 0) return; | |
| 147 | + var users = await usersDb.Users | |
| 148 | + .Where(u => ids.Contains(u.Id)) | |
| 149 | + .ToDictionaryAsync(u => u.Id); | |
| 150 | + foreach (var item in list) | |
| 151 | + { | |
| 152 | + users.TryGetValue(getUserId(item), out var u); | |
| 153 | + setUser(item, u); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + | |
| 157 | + public static async Task HydrateUsersAsync<T>( | |
| 158 | + UsersDbContext usersDb, | |
| 159 | + IEnumerable<T> items, | |
| 160 | + Func<T, Guid?> getUserId, | |
| 161 | + Action<T, AppUser?> setUser) where T : class | |
| 162 | + { | |
| 163 | + var list = items as IList<T> ?? items.ToList(); | |
| 164 | + var ids = list.Select(getUserId).Where(id => id is { } x && x != Guid.Empty).Select(id => id!.Value).Distinct().ToList(); | |
| 165 | + if (ids.Count == 0) return; | |
| 166 | + var users = await usersDb.Users | |
| 167 | + .Where(u => ids.Contains(u.Id)) | |
| 168 | + .ToDictionaryAsync(u => u.Id); | |
| 169 | + foreach (var item in list) | |
| 170 | + { | |
| 171 | + var id = getUserId(item); | |
| 172 | + if (id.HasValue && users.TryGetValue(id.Value, out var u)) setUser(item, u); | |
| 173 | + else setUser(item, null); | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + /// <summary>Batches a single Trips lookup, sets each item's Trip nav (boxed as object since | |
| 178 | + /// Expenses-module entities can't reference the Trips-module Trip type directly).</summary> | |
| 179 | + public static async Task HydrateTripsAsync<T>( | |
| 180 | + TripsDbContext tripsDb, | |
| 181 | + IEnumerable<T> items, | |
| 182 | + Func<T, Guid> getTripId, | |
| 183 | + Action<T, Trip?> setTrip) where T : class | |
| 184 | + { | |
| 185 | + var list = items as IList<T> ?? items.ToList(); | |
| 186 | + var ids = list.Select(getTripId).Where(id => id != Guid.Empty).Distinct().ToList(); | |
| 187 | + if (ids.Count == 0) return; | |
| 188 | + var trips = await tripsDb.Trips | |
| 189 | + .Where(t => ids.Contains(t.Id)) | |
| 190 | + .ToDictionaryAsync(t => t.Id); | |
| 191 | + foreach (var item in list) | |
| 192 | + { | |
| 193 | + trips.TryGetValue(getTripId(item), out var t); | |
| 194 | + setTrip(item, t); | |
| 195 | + } | |
| 196 | + } | |
| 197 | +} | |
| 198 | + | |
| 199 | +internal class TripRepo : GenericRepo<Trip, TripsDbContext>, ITripRepository | |
| 200 | +{ | |
| 201 | + private readonly UsersDbContext _users; | |
| 202 | + private readonly ExpensesDbContext _expenses; | |
| 203 | + | |
| 204 | + public TripRepo(TripsDbContext db, UsersDbContext users, ExpensesDbContext expenses) : base(db) | |
| 205 | + { | |
| 206 | + _users = users; | |
| 207 | + _expenses = expenses; | |
| 208 | + } | |
| 209 | + | |
| 210 | + private async Task HydrateTripAsync(Trip trip) | |
| 211 | + { | |
| 212 | + // Trip-level navs | |
| 213 | + var creator = await _users.Users.FirstOrDefaultAsync(u => u.Id == trip.CreatedById); | |
| 214 | + trip.CreatedBy = creator; | |
| 215 | + trip.DefaultCurrency = await _expenses.Currencies.FirstOrDefaultAsync(c => c.Id == trip.DefaultCurrencyId); | |
| 216 | + | |
| 217 | + if (trip.Participants is { Count: > 0 } parts) | |
| 218 | + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u); | |
| 219 | + if (trip.Invitations is { Count: > 0 } invs) | |
| 220 | + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u); | |
| 221 | + if (trip.Polls is { Count: > 0 } polls) | |
| 222 | + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u); | |
| 223 | + if (trip.WishlistItems is { Count: > 0 } wls) | |
| 224 | + await CrossModuleHydration.HydrateUsersAsync(_users, wls, w => w.AddedByUserId, (w, u) => w.AddedByUser = u); | |
| 225 | + } | |
| 226 | + | |
| 227 | + public override async Task<Trip?> GetByIdAsync(Guid id) | |
| 228 | + { | |
| 229 | + var trip = await base.GetByIdAsync(id); | |
| 230 | + if (trip != null) await HydrateTripAsync(trip); | |
| 231 | + return trip; | |
| 232 | + } | |
| 233 | + | |
| 234 | + public override async Task<IEnumerable<Trip>> GetAllAsync() | |
| 235 | + { | |
| 236 | + var trips = (await Db.Trips.ToListAsync()); | |
| 237 | + await CrossModuleHydration.HydrateUsersAsync(_users, trips, t => t.CreatedById, (t, u) => t.CreatedBy = u); | |
| 238 | + var currencyIds = trips.Select(t => t.DefaultCurrencyId).Where(id => id != Guid.Empty).Distinct().ToList(); | |
| 239 | + if (currencyIds.Count > 0) | |
| 240 | + { | |
| 241 | + var currencies = await _expenses.Currencies | |
| 242 | + .Where(c => currencyIds.Contains(c.Id)) | |
| 243 | + .ToDictionaryAsync(c => c.Id); | |
| 244 | + foreach (var t in trips) | |
| 245 | + { | |
| 246 | + if (currencies.TryGetValue(t.DefaultCurrencyId, out var c)) t.DefaultCurrency = c; | |
| 247 | + } | |
| 248 | + } | |
| 249 | + return trips; | |
| 250 | + } | |
| 251 | + | |
| 252 | + public async Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId) | |
| 253 | + { | |
| 254 | + var trips = await Db.Trips | |
| 255 | + .Include(t => t.Participants) | |
| 256 | + .Where(t => t.CreatedById == userId | |
| 257 | + || (t.Participants != null | |
| 258 | + && t.Participants.Any(p => p.UserId == userId && p.IsActive))) | |
| 259 | + .ToListAsync(); | |
| 260 | + foreach (var t in trips) await HydrateTripAsync(t); | |
| 261 | + return trips; | |
| 262 | + } | |
| 263 | + | |
| 264 | + public async Task<Trip?> GetByIdWithDetailsAsync(Guid id) | |
| 265 | + { | |
| 266 | + var trip = await Db.Trips | |
| 267 | + .Include(t => t.Participants) | |
| 268 | + .Include(t => t.BudgetCategories) | |
| 269 | + .Include(t => t.WishlistItems) | |
| 270 | + .Include(t => t.Polls)!.ThenInclude(p => p.Options) | |
| 271 | + .Include(t => t.Invitations) | |
| 272 | + .FirstOrDefaultAsync(t => t.Id == id); | |
| 273 | + if (trip != null) await HydrateTripAsync(trip); | |
| 274 | + return trip; | |
| 275 | + } | |
| 276 | +} | |
| 277 | + | |
| 278 | +internal class ExpenseRepo : GenericRepo<Expense, ExpensesDbContext>, IExpenseRepository | |
| 279 | +{ | |
| 280 | + private readonly UsersDbContext _users; | |
| 281 | + private readonly TripsDbContext _trips; | |
| 282 | + public ExpenseRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db) | |
| 283 | + { | |
| 284 | + _users = users; | |
| 285 | + _trips = trips; | |
| 286 | + } | |
| 287 | + | |
| 288 | + public override async Task<IEnumerable<Expense>> GetAllAsync() | |
| 289 | + { | |
| 290 | + var expenses = await Db.Expenses.Include(e => e.Currency).ToListAsync(); | |
| 291 | + await CrossModuleHydration.HydrateUsersAsync(_users, expenses, e => e.PaidByUserId, (e, u) => e.PaidByUser = u); | |
| 292 | + await CrossModuleHydration.HydrateTripsAsync(_trips, expenses, e => e.TripId, (e, t) => e.Trip = t); | |
| 293 | + return expenses; | |
| 294 | + } | |
| 295 | + | |
| 296 | + public override async Task<Expense?> GetByIdAsync(Guid id) | |
| 297 | + { | |
| 298 | + var e = await Db.Expenses.Include(x => x.Currency).FirstOrDefaultAsync(x => x.Id == id); | |
| 299 | + if (e != null) | |
| 300 | + { | |
| 301 | + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { e }, x => x.PaidByUserId, (x, u) => x.PaidByUser = u); | |
| 302 | + await CrossModuleHydration.HydrateTripsAsync(_trips, new[] { e }, x => x.TripId, (x, t) => x.Trip = t); | |
| 303 | + } | |
| 304 | + return e; | |
| 305 | + } | |
| 306 | + | |
| 307 | + public async Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId) | |
| 308 | + { | |
| 309 | + var expenses = await Db.Expenses | |
| 310 | + .Include(e => e.Currency) | |
| 311 | + .Where(e => e.TripId == tripId) | |
| 312 | + .ToListAsync(); | |
| 313 | + await CrossModuleHydration.HydrateUsersAsync(_users, expenses, e => e.PaidByUserId, (e, u) => e.PaidByUser = u); | |
| 314 | + return expenses; | |
| 315 | + } | |
| 316 | + | |
| 317 | + public async Task<Expense?> GetByIdWithDetailsAsync(Guid id) | |
| 318 | + { | |
| 319 | + var expense = await Db.Expenses | |
| 320 | + .Include(e => e.Currency) | |
| 321 | + .Include(e => e.Splits) | |
| 322 | + .FirstOrDefaultAsync(e => e.Id == id); | |
| 323 | + if (expense == null) return null; | |
| 324 | + | |
| 325 | + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { expense }, e => e.PaidByUserId, (e, u) => e.PaidByUser = u); | |
| 326 | + if (expense.Splits is { Count: > 0 } splits) | |
| 327 | + await CrossModuleHydration.HydrateUsersAsync(_users, splits, s => s.UserId, (s, u) => s.User = u); | |
| 328 | + return expense; | |
| 329 | + } | |
| 330 | +} | |
| 331 | + | |
| 332 | +internal class TripParticipantRepo : GenericRepo<TripParticipant, TripsDbContext>, ITripParticipantRepository | |
| 333 | +{ | |
| 334 | + private readonly UsersDbContext _users; | |
| 335 | + public TripParticipantRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; } | |
| 336 | + | |
| 337 | + public async Task<bool> IsParticipantAsync(Guid tripId, Guid userId) | |
| 338 | + => await Db.TripParticipants.AnyAsync(p => p.TripId == tripId && p.UserId == userId && p.IsActive); | |
| 339 | + | |
| 340 | + public async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId) | |
| 341 | + { | |
| 342 | + var trip = await Db.Trips.FirstOrDefaultAsync(t => t.Id == tripId); | |
| 343 | + if (trip == null) return false; | |
| 344 | + if (trip.CreatedById == userId) return true; | |
| 345 | + return await Db.TripParticipants.AnyAsync(p => | |
| 346 | + p.TripId == tripId && p.UserId == userId && p.IsActive | |
| 347 | + && p.Role == SplitApp.Modules.Trips.Domain.Enums.EParticipantRole.Organizer); | |
| 348 | + } | |
| 349 | + | |
| 350 | + public async Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId) | |
| 351 | + { | |
| 352 | + var parts = await Db.TripParticipants.Where(p => p.TripId == tripId).ToListAsync(); | |
| 353 | + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u); | |
| 354 | + return parts; | |
| 355 | + } | |
| 356 | + | |
| 357 | + public override async Task<TripParticipant?> GetByIdAsync(Guid id) | |
| 358 | + { | |
| 359 | + var p = await Db.TripParticipants.Include(x => x.Trip).FirstOrDefaultAsync(x => x.Id == id); | |
| 360 | + if (p != null) | |
| 361 | + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { p }, x => x.UserId, (x, u) => x.User = u); | |
| 362 | + return p; | |
| 363 | + } | |
| 364 | + | |
| 365 | + public override async Task<IEnumerable<TripParticipant>> GetAllAsync() | |
| 366 | + { | |
| 367 | + var parts = await Db.TripParticipants.Include(p => p.Trip).ToListAsync(); | |
| 368 | + await CrossModuleHydration.HydrateUsersAsync(_users, parts, p => p.UserId, (p, u) => p.User = u); | |
| 369 | + return parts; | |
| 370 | + } | |
| 371 | +} | |
| 372 | + | |
| 373 | +internal class TripInvitationRepo : GenericRepo<TripInvitation, TripsDbContext>, ITripInvitationRepository | |
| 374 | +{ | |
| 375 | + private readonly UsersDbContext _users; | |
| 376 | + public TripInvitationRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; } | |
| 377 | + | |
| 378 | + public override async Task<IEnumerable<TripInvitation>> GetAllAsync() | |
| 379 | + { | |
| 380 | + var invs = await Db.TripInvitations.Include(i => i.Trip).ToListAsync(); | |
| 381 | + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u); | |
| 382 | + return invs; | |
| 383 | + } | |
| 384 | + | |
| 385 | + public async Task<TripInvitation?> GetByTokenAsync(string token) | |
| 386 | + { | |
| 387 | + var inv = await Db.TripInvitations.Include(i => i.Trip).FirstOrDefaultAsync(i => i.Token == token); | |
| 388 | + if (inv != null) | |
| 389 | + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { inv }, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u); | |
| 390 | + return inv; | |
| 391 | + } | |
| 392 | + | |
| 393 | + public async Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId) | |
| 394 | + { | |
| 395 | + var invs = await Db.TripInvitations | |
| 396 | + .Where(i => i.TripId == tripId | |
| 397 | + && i.Status == SplitApp.Modules.Trips.Domain.Enums.EInvitationStatus.Pending) | |
| 398 | + .ToListAsync(); | |
| 399 | + await CrossModuleHydration.HydrateUsersAsync(_users, invs, i => i.InvitedByUserId, (i, u) => i.InvitedByUser = u); | |
| 400 | + return invs; | |
| 401 | + } | |
| 402 | +} | |
| 403 | + | |
| 404 | +internal class SettlementPlanRepo : GenericRepo<SettlementPlan, ExpensesDbContext>, ISettlementPlanRepository | |
| 405 | +{ | |
| 406 | + private readonly UsersDbContext _users; | |
| 407 | + private readonly TripsDbContext _trips; | |
| 408 | + public SettlementPlanRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db) | |
| 409 | + { | |
| 410 | + _users = users; | |
| 411 | + _trips = trips; | |
| 412 | + } | |
| 413 | + | |
| 414 | + public override async Task<IEnumerable<SettlementPlan>> GetAllAsync() | |
| 415 | + { | |
| 416 | + var plans = await Db.SettlementPlans.ToListAsync(); | |
| 417 | + await CrossModuleHydration.HydrateUsersAsync(_users, plans, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u); | |
| 418 | + await CrossModuleHydration.HydrateTripsAsync(_trips, plans, p => p.TripId, (p, t) => p.Trip = t); | |
| 419 | + return plans; | |
| 420 | + } | |
| 421 | + | |
| 422 | + private async Task HydratePlanAsync(SettlementPlan plan) | |
| 423 | + { | |
| 424 | + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { plan }, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u); | |
| 425 | + await CrossModuleHydration.HydrateTripsAsync(_trips, new[] { plan }, p => p.TripId, (p, t) => p.Trip = t); | |
| 426 | + if (plan.Payments is { Count: > 0 } payments) | |
| 427 | + { | |
| 428 | + // Batch From + To together — same AspNetUsers query. | |
| 429 | + var ids = payments.SelectMany(p => new[] { p.FromUserId, p.ToUserId }) | |
| 430 | + .Where(id => id != Guid.Empty).Distinct().ToList(); | |
| 431 | + if (ids.Count > 0) | |
| 432 | + { | |
| 433 | + var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id); | |
| 434 | + foreach (var p in payments) | |
| 435 | + { | |
| 436 | + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu; | |
| 437 | + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu; | |
| 438 | + } | |
| 439 | + } | |
| 440 | + } | |
| 441 | + } | |
| 442 | + | |
| 443 | + public override async Task<SettlementPlan?> GetByIdAsync(Guid id) | |
| 444 | + { | |
| 445 | + var plan = await Db.SettlementPlans.Include(p => p.Payments).FirstOrDefaultAsync(p => p.Id == id); | |
| 446 | + if (plan != null) await HydratePlanAsync(plan); | |
| 447 | + return plan; | |
| 448 | + } | |
| 449 | + | |
| 450 | + public async Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId) | |
| 451 | + { | |
| 452 | + var plan = await Db.SettlementPlans | |
| 453 | + .Include(p => p.Payments) | |
| 454 | + .Where(p => p.TripId == tripId) | |
| 455 | + .OrderByDescending(p => p.CreatedAt) | |
| 456 | + .FirstOrDefaultAsync(); | |
| 457 | + if (plan != null) await HydratePlanAsync(plan); | |
| 458 | + return plan; | |
| 459 | + } | |
| 460 | + | |
| 461 | + public async Task DeletePlanWithPaymentsAsync(Guid planId) | |
| 462 | + { | |
| 463 | + var payments = await Db.SettlementPayments.Where(p => p.SettlementPlanId == planId).ToListAsync(); | |
| 464 | + Db.SettlementPayments.RemoveRange(payments); | |
| 465 | + var plan = await Db.SettlementPlans.FirstOrDefaultAsync(p => p.Id == planId); | |
| 466 | + if (plan != null) Db.SettlementPlans.Remove(plan); | |
| 467 | + await Db.SaveChangesAsync(); | |
| 468 | + } | |
| 469 | +} | |
| 470 | + | |
| 471 | +internal class SettlementPaymentRepo : GenericRepo<SettlementPayment, ExpensesDbContext>, ISettlementPaymentRepository | |
| 472 | +{ | |
| 473 | + private readonly UsersDbContext _users; | |
| 474 | + public SettlementPaymentRepo(ExpensesDbContext db, UsersDbContext users) : base(db) { _users = users; } | |
| 475 | + | |
| 476 | + public override async Task<IEnumerable<SettlementPayment>> GetAllAsync() | |
| 477 | + { | |
| 478 | + var payments = await Db.SettlementPayments.ToListAsync(); | |
| 479 | + var ids = payments.SelectMany(p => new[] { p.FromUserId, p.ToUserId }) | |
| 480 | + .Where(id => id != Guid.Empty).Distinct().ToList(); | |
| 481 | + if (ids.Count > 0) | |
| 482 | + { | |
| 483 | + var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id); | |
| 484 | + foreach (var p in payments) | |
| 485 | + { | |
| 486 | + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu; | |
| 487 | + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu; | |
| 488 | + } | |
| 489 | + } | |
| 490 | + return payments; | |
| 491 | + } | |
| 492 | + | |
| 493 | + public override async Task<SettlementPayment?> GetByIdAsync(Guid id) | |
| 494 | + { | |
| 495 | + var p = await base.GetByIdAsync(id); | |
| 496 | + if (p != null) | |
| 497 | + { | |
| 498 | + var ids = new[] { p.FromUserId, p.ToUserId }.Where(x => x != Guid.Empty).Distinct().ToList(); | |
| 499 | + var users = await _users.Users.Where(u => ids.Contains(u.Id)).ToDictionaryAsync(u => u.Id); | |
| 500 | + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu; | |
| 501 | + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu; | |
| 502 | + } | |
| 503 | + return p; | |
| 504 | + } | |
| 505 | +} | |
| 506 | + | |
| 507 | +internal class TripPollRepo : GenericRepo<TripPoll, TripsDbContext>, ITripPollRepository | |
| 508 | +{ | |
| 509 | + private readonly UsersDbContext _users; | |
| 510 | + public TripPollRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; } | |
| 511 | + | |
| 512 | + public override async Task<IEnumerable<TripPoll>> GetAllAsync() | |
| 513 | + { | |
| 514 | + var polls = await Db.TripPolls.Include(p => p.Trip).ToListAsync(); | |
| 515 | + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u); | |
| 516 | + return polls; | |
| 517 | + } | |
| 518 | + | |
| 519 | + public async Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId) | |
| 520 | + { | |
| 521 | + var polls = await Db.TripPolls | |
| 522 | + .Include(p => p.Options)! | |
| 523 | + .ThenInclude(o => o.Votes) | |
| 524 | + .Where(p => p.TripId == tripId) | |
| 525 | + .ToListAsync(); | |
| 526 | + await CrossModuleHydration.HydrateUsersAsync(_users, polls, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u); | |
| 527 | + return polls; | |
| 528 | + } | |
| 529 | + | |
| 530 | + public async Task<TripPoll?> GetByIdWithDetailsAsync(Guid id) | |
| 531 | + { | |
| 532 | + var poll = await Db.TripPolls | |
| 533 | + .Include(p => p.Options)! | |
| 534 | + .ThenInclude(o => o.Votes) | |
| 535 | + .FirstOrDefaultAsync(p => p.Id == id); | |
| 536 | + if (poll != null) | |
| 537 | + await CrossModuleHydration.HydrateUsersAsync(_users, new[] { poll }, p => p.CreatedByUserId, (p, u) => p.CreatedByUser = u); | |
| 538 | + return poll; | |
| 539 | + } | |
| 540 | +} | |
| 541 | + | |
| 542 | +internal class TripWishlistItemRepo : GenericRepo<TripWishlistItem, TripsDbContext>, ITripWishlistItemRepository | |
| 543 | +{ | |
| 544 | + private readonly UsersDbContext _users; | |
| 545 | + public TripWishlistItemRepo(TripsDbContext db, UsersDbContext users) : base(db) { _users = users; } | |
| 546 | + | |
| 547 | + public override async Task<IEnumerable<TripWishlistItem>> GetAllAsync() | |
| 548 | + { | |
| 549 | + var items = await Db.TripWishlistItems.Include(i => i.Trip).ToListAsync(); | |
| 550 | + await CrossModuleHydration.HydrateUsersAsync(_users, items, i => i.AddedByUserId, (i, u) => i.AddedByUser = u); | |
| 551 | + return items; | |
| 552 | + } | |
| 553 | + | |
| 554 | + public async Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId) | |
| 555 | + { | |
| 556 | + var items = await Db.TripWishlistItems | |
| 557 | + .Include(i => i.Votes) | |
| 558 | + .Where(i => i.TripId == tripId) | |
| 559 | + .ToListAsync(); | |
| 560 | + await CrossModuleHydration.HydrateUsersAsync(_users, items, i => i.AddedByUserId, (i, u) => i.AddedByUser = u); | |
| 561 | + return items; | |
| 562 | + } | |
| 563 | +} | |
| 564 | + | |
| 565 | +internal class SplitPresetRepo : GenericRepo<SplitPreset, ExpensesDbContext>, ISplitPresetRepository | |
| 566 | +{ | |
| 567 | + private readonly UsersDbContext _users; | |
| 568 | + private readonly TripsDbContext _trips; | |
| 569 | + public SplitPresetRepo(ExpensesDbContext db, UsersDbContext users, TripsDbContext trips) : base(db) | |
| 570 | + { | |
| 571 | + _users = users; | |
| 572 | + _trips = trips; | |
| 573 | + } | |
| 574 | + | |
| 575 | + public override async Task<IEnumerable<SplitPreset>> GetAllAsync() | |
| 576 | + { | |
| 577 | + var presets = await Db.SplitPresets.ToListAsync(); | |
| 578 | + await CrossModuleHydration.HydrateUsersAsync(_users, presets, p => p.CreatedById, (p, u) => p.CreatedBy = u); | |
| 579 | + await CrossModuleHydration.HydrateTripsAsync(_trips, presets, p => p.TripId, (p, t) => p.Trip = t); | |
| 580 | + return presets; | |
| 581 | + } | |
| 582 | + | |
| 583 | + public async Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId) | |
| 584 | + { | |
| 585 | + var presets = await Db.SplitPresets | |
| 586 | + .Include(p => p.Members) | |
| 587 | + .Where(p => p.TripId == tripId) | |
| 588 | + .ToListAsync(); | |
| 589 | + await CrossModuleHydration.HydrateUsersAsync(_users, presets, p => p.CreatedById, (p, u) => p.CreatedBy = u); | |
| 590 | + var members = presets.SelectMany(p => p.Members ?? Enumerable.Empty<SplitPresetMember>()).ToList(); | |
| 591 | + if (members.Count > 0) | |
| 592 | + await CrossModuleHydration.HydrateUsersAsync(_users, members, m => m.UserId, (m, u) => m.User = u); | |
| 593 | + return presets; | |
| 594 | + } | |
| 595 | +} | |
| 596 | + | |
| 597 | +internal class BudgetCategoryRepo : GenericRepo<BudgetCategory, TripsDbContext>, IBudgetCategoryRepository | |
| 598 | +{ | |
| 599 | + public BudgetCategoryRepo(TripsDbContext db) : base(db) { } | |
| 600 | + | |
| 601 | + public async Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId) | |
| 602 | + => await Db.BudgetCategories.Where(c => c.TripId == tripId).ToListAsync(); | |
| 603 | +} | |
| 604 | + | |
| 605 | +internal class RefreshTokenRepo : GenericRepo<AppRefreshToken, UsersDbContext>, IRefreshTokenRepository | |
| 606 | +{ | |
| 607 | + public RefreshTokenRepo(UsersDbContext db) : base(db) { } | |
| 608 | + | |
| 609 | + public async Task<IEnumerable<AppRefreshToken>> GetUserActiveTokensAsync(Guid userId, string refreshTokenValue) | |
| 610 | + { | |
| 611 | + var now = DateTime.UtcNow; | |
| 612 | + return await Db.RefreshTokens | |
| 613 | + .Where(t => t.AppUserId == userId | |
| 614 | + && (t.RefreshToken == refreshTokenValue && t.ExpirationDT > now | |
| 615 | + || t.PreviousRefreshToken == refreshTokenValue && t.PreviousExpirationDT > now)) | |
| 616 | + .ToListAsync(); | |
| 617 | + } | |
| 618 | + | |
| 619 | + public async Task<IEnumerable<AppRefreshToken>> GetUserTokensByValueAsync(Guid userId, string refreshTokenValue) | |
| 620 | + { | |
| 621 | + return await Db.RefreshTokens | |
| 622 | + .Where(t => t.AppUserId == userId | |
| 623 | + && (t.RefreshToken == refreshTokenValue || t.PreviousRefreshToken == refreshTokenValue)) | |
| 624 | + .ToListAsync(); | |
| 625 | + } | |
| 626 | + | |
| 627 | + public async Task<int> RemoveExpiredForUserAsync(Guid userId) | |
| 628 | + { | |
| 629 | + var now = DateTime.UtcNow; | |
| 630 | + var expired = await Db.RefreshTokens | |
| 631 | + .Where(t => t.AppUserId == userId | |
| 632 | + && t.ExpirationDT < now | |
| 633 | + && t.PreviousExpirationDT < now) | |
| 634 | + .ToListAsync(); | |
| 635 | + Db.RefreshTokens.RemoveRange(expired); | |
| 636 | + return expired.Count; | |
| 637 | + } | |
| 638 | + | |
| 639 | + public void Remove(AppRefreshToken token) => Db.RefreshTokens.Remove(token); | |
| 640 | +} | |
| 641 | + | |
| 642 | +internal class UserRepo : GenericRepo<AppUser, UsersDbContext>, IUserRepository | |
| 643 | +{ | |
| 644 | + public UserRepo(UsersDbContext db) : base(db) { } | |
| 645 | + | |
| 646 | + public async Task<int> CountAsync() => await Db.Users.CountAsync(); | |
| 647 | + | |
| 648 | + public async Task<IEnumerable<AppUser>> GetRecentAsync(int take) | |
| 649 | + => await Db.Users.OrderByDescending(u => u.Id).Take(take).ToListAsync(); | |
| 650 | + | |
| 651 | + public async Task<AppUser?> GetByIdWithRefreshTokensAsync(Guid userId) | |
| 652 | + => await Db.Users.Include(u => u.RefreshTokens).FirstOrDefaultAsync(u => u.Id == userId); | |
| 653 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/CrossModuleNavigationLoader.cs +179 −0
| @@ -0,0 +1,179 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 7 | +using SplitApp.Modules.Users.Infrastructure.Persistence; | |
| 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 — the WebApp does it manually. | |
| 15 | +/// </summary> | |
| 16 | +public class CrossModuleNavigationLoader | |
| 17 | +{ | |
| 18 | + private readonly UsersDbContext _users; | |
| 19 | + private readonly TripsDbContext _trips; | |
| 20 | + private readonly ExpensesDbContext _expenses; | |
| 21 | + | |
| 22 | + public CrossModuleNavigationLoader(UsersDbContext users, TripsDbContext trips, ExpensesDbContext expenses) | |
| 23 | + { | |
| 24 | + _users = users; | |
| 25 | + _trips = trips; | |
| 26 | + _expenses = expenses; | |
| 27 | + } | |
| 28 | + | |
| 29 | + private async Task<Dictionary<Guid, AppUser>> UsersByIdAsync(IEnumerable<Guid> ids) | |
| 30 | + { | |
| 31 | + var idList = ids.Where(id => id != Guid.Empty).Distinct().ToList(); | |
| 32 | + if (idList.Count == 0) return new Dictionary<Guid, AppUser>(); | |
| 33 | + return await _users.Users.Where(u => idList.Contains(u.Id)).ToDictionaryAsync(u => u.Id); | |
| 34 | + } | |
| 35 | + | |
| 36 | + private async Task<Dictionary<Guid, Currency>> CurrenciesByIdAsync(IEnumerable<Guid> ids) | |
| 37 | + { | |
| 38 | + var idList = ids.Where(id => id != Guid.Empty).Distinct().ToList(); | |
| 39 | + if (idList.Count == 0) return new Dictionary<Guid, Currency>(); | |
| 40 | + return await _expenses.Currencies.Where(c => idList.Contains(c.Id)).ToDictionaryAsync(c => c.Id); | |
| 41 | + } | |
| 42 | + | |
| 43 | + private async Task<Dictionary<Guid, Trip>> TripsByIdAsync(IEnumerable<Guid> ids) | |
| 44 | + { | |
| 45 | + var idList = ids.Where(id => id != Guid.Empty).Distinct().ToList(); | |
| 46 | + if (idList.Count == 0) return new Dictionary<Guid, Trip>(); | |
| 47 | + return await _trips.Trips.Where(t => idList.Contains(t.Id)).ToDictionaryAsync(t => t.Id); | |
| 48 | + } | |
| 49 | + | |
| 50 | + public async Task PopulateAsync(Trip? trip) | |
| 51 | + { | |
| 52 | + if (trip == null) return; | |
| 53 | + var users = await UsersByIdAsync(new[] { trip.CreatedById }); | |
| 54 | + var currencies = await CurrenciesByIdAsync(new[] { trip.DefaultCurrencyId }); | |
| 55 | + if (users.TryGetValue(trip.CreatedById, out var creator)) trip.CreatedBy = creator; | |
| 56 | + if (currencies.TryGetValue(trip.DefaultCurrencyId, out var currency)) trip.DefaultCurrency = currency; | |
| 57 | + if (trip.Participants != null) | |
| 58 | + { | |
| 59 | + var pUsers = await UsersByIdAsync(trip.Participants.Select(p => p.UserId)); | |
| 60 | + foreach (var p in trip.Participants) | |
| 61 | + if (pUsers.TryGetValue(p.UserId, out var u)) p.User = u; | |
| 62 | + } | |
| 63 | + if (trip.Invitations != null) | |
| 64 | + { | |
| 65 | + var iUsers = await UsersByIdAsync(trip.Invitations.Select(i => i.InvitedByUserId)); | |
| 66 | + foreach (var i in trip.Invitations) | |
| 67 | + if (iUsers.TryGetValue(i.InvitedByUserId, out var u)) i.InvitedByUser = u; | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + public async Task PopulateAsync(IEnumerable<Trip> trips) | |
| 72 | + { | |
| 73 | + foreach (var t in trips) await PopulateAsync(t); | |
| 74 | + } | |
| 75 | + | |
| 76 | + public async Task PopulateAsync(TripParticipant? p) | |
| 77 | + { | |
| 78 | + if (p == null) return; | |
| 79 | + var users = await UsersByIdAsync(new[] { p.UserId }); | |
| 80 | + if (users.TryGetValue(p.UserId, out var u)) p.User = u; | |
| 81 | + } | |
| 82 | + | |
| 83 | + public async Task PopulateAsync(IEnumerable<TripParticipant> ps) | |
| 84 | + { | |
| 85 | + var ids = ps.Select(p => p.UserId).ToList(); | |
| 86 | + var users = await UsersByIdAsync(ids); | |
| 87 | + foreach (var p in ps) if (users.TryGetValue(p.UserId, out var u)) p.User = u; | |
| 88 | + } | |
| 89 | + | |
| 90 | + public async Task PopulateAsync(Expense? e) | |
| 91 | + { | |
| 92 | + if (e == null) return; | |
| 93 | + var users = await UsersByIdAsync(new[] { e.PaidByUserId }); | |
| 94 | + if (users.TryGetValue(e.PaidByUserId, out var u)) e.PaidByUser = u; | |
| 95 | + if (e.Splits != null) | |
| 96 | + { | |
| 97 | + var splitUsers = await UsersByIdAsync(e.Splits.Select(s => s.UserId)); | |
| 98 | + foreach (var s in e.Splits) | |
| 99 | + if (splitUsers.TryGetValue(s.UserId, out var su)) s.User = su; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + public async Task PopulateAsync(IEnumerable<Expense> es) | |
| 104 | + { | |
| 105 | + foreach (var e in es) await PopulateAsync(e); | |
| 106 | + } | |
| 107 | + | |
| 108 | + public async Task PopulateAsync(SettlementPlan? plan) | |
| 109 | + { | |
| 110 | + if (plan == null) return; | |
| 111 | + var users = await UsersByIdAsync( | |
| 112 | + new[] { plan.CreatedByUserId } | |
| 113 | + .Concat(plan.Payments?.Select(p => p.FromUserId) ?? Enumerable.Empty<Guid>()) | |
| 114 | + .Concat(plan.Payments?.Select(p => p.ToUserId) ?? Enumerable.Empty<Guid>())); | |
| 115 | + if (users.TryGetValue(plan.CreatedByUserId, out var creator)) plan.CreatedByUser = creator; | |
| 116 | + if (plan.Payments != null) | |
| 117 | + foreach (var p in plan.Payments) | |
| 118 | + { | |
| 119 | + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu; | |
| 120 | + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu; | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 124 | + public async Task PopulateAsync(SettlementPayment? p) | |
| 125 | + { | |
| 126 | + if (p == null) return; | |
| 127 | + var users = await UsersByIdAsync(new[] { p.FromUserId, p.ToUserId }); | |
| 128 | + if (users.TryGetValue(p.FromUserId, out var fu)) p.FromUser = fu; | |
| 129 | + if (users.TryGetValue(p.ToUserId, out var tu)) p.ToUser = tu; | |
| 130 | + } | |
| 131 | + | |
| 132 | + public async Task PopulateAsync(SplitPreset? sp) | |
| 133 | + { | |
| 134 | + if (sp == null) return; | |
| 135 | + var users = await UsersByIdAsync( | |
| 136 | + new[] { sp.CreatedById } | |
| 137 | + .Concat(sp.Members?.Select(m => m.UserId) ?? Enumerable.Empty<Guid>())); | |
| 138 | + if (users.TryGetValue(sp.CreatedById, out var creator)) sp.CreatedBy = creator; | |
| 139 | + if (sp.Members != null) | |
| 140 | + foreach (var m in sp.Members) | |
| 141 | + if (users.TryGetValue(m.UserId, out var u)) m.User = u; | |
| 142 | + } | |
| 143 | + | |
| 144 | + public async Task PopulateAsync(IEnumerable<SplitPreset> sps) | |
| 145 | + { | |
| 146 | + foreach (var sp in sps) await PopulateAsync(sp); | |
| 147 | + } | |
| 148 | + | |
| 149 | + public async Task PopulateAsync(TripPoll? poll) | |
| 150 | + { | |
| 151 | + if (poll == null) return; | |
| 152 | + var users = await UsersByIdAsync(new[] { poll.CreatedByUserId }); | |
| 153 | + if (users.TryGetValue(poll.CreatedByUserId, out var u)) poll.CreatedByUser = u; | |
| 154 | + } | |
| 155 | + | |
| 156 | + public async Task PopulateAsync(IEnumerable<TripPoll> polls) | |
| 157 | + { | |
| 158 | + foreach (var p in polls) await PopulateAsync(p); | |
| 159 | + } | |
| 160 | + | |
| 161 | + public async Task PopulateAsync(TripWishlistItem? item) | |
| 162 | + { | |
| 163 | + if (item == null) return; | |
| 164 | + var users = await UsersByIdAsync(new[] { item.AddedByUserId }); | |
| 165 | + if (users.TryGetValue(item.AddedByUserId, out var u)) item.AddedByUser = u; | |
| 166 | + } | |
| 167 | + | |
| 168 | + public async Task PopulateAsync(IEnumerable<TripWishlistItem> items) | |
| 169 | + { | |
| 170 | + foreach (var i in items) await PopulateAsync(i); | |
| 171 | + } | |
| 172 | + | |
| 173 | + public async Task PopulateAsync(TripInvitation? inv) | |
| 174 | + { | |
| 175 | + if (inv == null) return; | |
| 176 | + var users = await UsersByIdAsync(new[] { inv.InvitedByUserId }); | |
| 177 | + if (users.TryGetValue(inv.InvitedByUserId, out var u)) inv.InvitedByUser = u; | |
| 178 | + } | |
| 179 | +} |
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 +203 −0
| @@ -0,0 +1,203 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | + | |
| 10 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 11 | + | |
| 12 | +public class AdminStatsService : IAdminStatsService | |
| 13 | +{ | |
| 14 | + private readonly IAppUnitOfWork _uow; | |
| 15 | + | |
| 16 | + public AdminStatsService(IAppUnitOfWork uow) | |
| 17 | + { | |
| 18 | + _uow = uow; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public async Task<AdminDashboardData> GetDashboardStatsAsync() | |
| 22 | + { | |
| 23 | + var trips = (await _uow.Trips.GetAllAsync()).ToList(); | |
| 24 | + var expenses = (await _uow.Expenses.GetAllAsync()).ToList(); | |
| 25 | + var budgetCategories = (await _uow.BudgetCategories.GetAllAsync()).ToList(); | |
| 26 | + var settlementPlans = (await _uow.SettlementPlans.GetAllAsync()).ToList(); | |
| 27 | + var wishlistItems = (await _uow.TripWishlistItems.GetAllAsync()).ToList(); | |
| 28 | + var polls = (await _uow.TripPolls.GetAllAsync()).ToList(); | |
| 29 | + var invitations = (await _uow.TripInvitations.GetAllAsync()).ToList(); | |
| 30 | + var currencies = (await _uow.GetRepository<Currency>().GetAllAsync()).ToList(); | |
| 31 | + var participants = (await _uow.TripParticipants.GetAllAsync()).ToList(); | |
| 32 | + var settlementPayments = (await _uow.SettlementPayments.GetAllAsync()).ToList(); | |
| 33 | + var allUsers = (await _uow.Users.GetAllAsync()).ToList(); | |
| 34 | + | |
| 35 | + // === Counts === | |
| 36 | + var tripCount = trips.Count; | |
| 37 | + var userCount = allUsers.Count; | |
| 38 | + var expenseCount = expenses.Count; | |
| 39 | + | |
| 40 | + // === Trip status breakdown === | |
| 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 = (await _uow.Users.GetRecentAsync(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 | + // === Top Active Trips === | |
| 66 | + var topActiveTrips = trips | |
| 67 | + .Where(t => t.Status == ETripStatus.Active) | |
| 68 | + .Select(t => | |
| 69 | + { | |
| 70 | + var tripExpenses = expenses.Where(e => e.TripId == t.Id).ToList(); | |
| 71 | + return new AdminTopActiveTripItem | |
| 72 | + { | |
| 73 | + Trip = TripBllDtoFactory.Create(t), | |
| 74 | + ParticipantCount = participants.Count(p => p.TripId == t.Id), | |
| 75 | + ExpenseSum = tripExpenses.Sum(e => e.Amount), | |
| 76 | + ExpenseCount = tripExpenses.Count | |
| 77 | + }; | |
| 78 | + }) | |
| 79 | + .OrderByDescending(x => x.ExpenseSum) | |
| 80 | + .ThenByDescending(x => x.ParticipantCount) | |
| 81 | + .Take(5) | |
| 82 | + .ToList(); | |
| 83 | + | |
| 84 | + // === Top 10 biggest expenses === | |
| 85 | + var biggestExpensesEntities = expenses | |
| 86 | + .OrderByDescending(e => e.Amount) | |
| 87 | + .Take(10) | |
| 88 | + .ToList(); | |
| 89 | + foreach (var e in biggestExpensesEntities) | |
| 90 | + { | |
| 91 | + e.Trip ??= trips.FirstOrDefault(t => t.Id == e.TripId); | |
| 92 | + e.PaidByUser ??= allUsers.FirstOrDefault(u => u.Id == e.PaidByUserId); | |
| 93 | + } | |
| 94 | + var biggestExpenses = ExpenseBllDtoFactory.CreateList(biggestExpensesEntities); | |
| 95 | + | |
| 96 | + // === Active users in last N days === | |
| 97 | + var now = DateTime.UtcNow; | |
| 98 | + var cutoff7 = now.AddDays(-7); | |
| 99 | + var cutoff30 = now.AddDays(-30); | |
| 100 | + | |
| 101 | + var activeUserIds7 = expenses.Where(e => e.CreatedAt >= cutoff7) | |
| 102 | + .Select(e => e.PaidByUserId).Distinct().Count(); | |
| 103 | + var activeUserIds30 = expenses.Where(e => e.CreatedAt >= cutoff30) | |
| 104 | + .Select(e => e.PaidByUserId).Distinct().Count(); | |
| 105 | + | |
| 106 | + // === Top 5 most active users by expense count === | |
| 107 | + var topActiveUsers = expenses | |
| 108 | + .GroupBy(e => e.PaidByUserId) | |
| 109 | + .Select(g => | |
| 110 | + { | |
| 111 | + var user = allUsers.FirstOrDefault(u => u.Id == g.Key); | |
| 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 | + // === Activity feed (chronological) === | |
| 126 | + var feed = new List<AdminActivityFeedItem>(); | |
| 127 | + foreach (var t in trips) | |
| 128 | + { | |
| 129 | + feed.Add(new AdminActivityFeedItem | |
| 130 | + { | |
| 131 | + Type = "trip", | |
| 132 | + Date = t.CreatedAt, | |
| 133 | + MessageKey = "Trip \"{0}\" created", | |
| 134 | + MessageArgs = new object[] { t.Name }, | |
| 135 | + IconCssClass = "bi-suitcase-lg", | |
| 136 | + BadgeCssClass = "bg-primary" | |
| 137 | + }); | |
| 138 | + } | |
| 139 | + foreach (var e in expenses.OrderByDescending(x => x.CreatedAt).Take(30)) | |
| 140 | + { | |
| 141 | + var tripName = trips.FirstOrDefault(t => t.Id == e.TripId)?.Name ?? "?"; | |
| 142 | + feed.Add(new AdminActivityFeedItem | |
| 143 | + { | |
| 144 | + Type = "expense", | |
| 145 | + Date = e.CreatedAt, | |
| 146 | + MessageKey = "Expense {0:0.00} added to \"{1}\"", | |
| 147 | + MessageArgs = new object[] { e.Amount, tripName }, | |
| 148 | + IconCssClass = "bi-cash-coin", | |
| 149 | + BadgeCssClass = "bg-success" | |
| 150 | + }); | |
| 151 | + } | |
| 152 | + foreach (var s in settlementPlans) | |
| 153 | + { | |
| 154 | + var tripName = trips.FirstOrDefault(t => t.Id == s.TripId)?.Name ?? "?"; | |
| 155 | + feed.Add(new AdminActivityFeedItem | |
| 156 | + { | |
| 157 | + Type = "settlement", | |
| 158 | + Date = s.CreatedAt, | |
| 159 | + MessageKey = "Settlement plan for \"{0}\" ({1})", | |
| 160 | + MessageArgs = new object[] { tripName, s.Status }, | |
| 161 | + IconCssClass = "bi-diagram-3", | |
| 162 | + BadgeCssClass = "bg-info" | |
| 163 | + }); | |
| 164 | + } | |
| 165 | + var activityFeed = feed.OrderByDescending(f => f.Date).Take(15).ToList(); | |
| 166 | + | |
| 167 | + return new AdminDashboardData | |
| 168 | + { | |
| 169 | + TripCount = tripCount, | |
| 170 | + UserCount = userCount, | |
| 171 | + ExpenseCount = expenseCount, | |
| 172 | + CategoryCount = budgetCategories.Count, | |
| 173 | + SettlementCount = settlementPlans.Count, | |
| 174 | + WishlistCount = wishlistItems.Count, | |
| 175 | + PollCount = polls.Count, | |
| 176 | + InvitationCount = invitations.Count, | |
| 177 | + CurrencyCount = currencies.Count, | |
| 178 | + ParticipantCount = participants.Count, | |
| 179 | + | |
| 180 | + ActiveTrips = activeTrips, | |
| 181 | + SettledTrips = settledTrips, | |
| 182 | + ArchivedTrips = archivedTrips, | |
| 183 | + TotalExpenseAmount = totalExpenseAmount, | |
| 184 | + PendingSettlements = pendingSettlements, | |
| 185 | + InProgressSettlements = inProgressSettlements, | |
| 186 | + CompletedSettlements = completedSettlements, | |
| 187 | + PendingInvitations = pendingInvitations, | |
| 188 | + PendingPayments = pendingPayments, | |
| 189 | + MarkedPaidPayments = markedPaidPayments, | |
| 190 | + | |
| 191 | + RecentTrips = recentTrips, | |
| 192 | + RecentExpenses = recentExpenses, | |
| 193 | + RecentUsers = recentUsers, | |
| 194 | + | |
| 195 | + TopActiveTrips = topActiveTrips, | |
| 196 | + BiggestExpenses = biggestExpenses, | |
| 197 | + NewUsersLast7Days = activeUserIds7, | |
| 198 | + NewUsersLast30Days = activeUserIds30, | |
| 199 | + TopActiveUsers = topActiveUsers, | |
| 200 | + ActivityFeed = activityFeed | |
| 201 | + }; | |
| 202 | + } | |
| 203 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/BudgetCategoryAdminService.cs +81 −0
| @@ -0,0 +1,81 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | +using SplitApp.Shared.Kernel.Domain; | |
| 10 | +using SplitApp.Shared.Kernel.Localization; | |
| 11 | + | |
| 12 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 13 | + | |
| 14 | +public class BudgetCategoryAdminService : IBudgetCategoryAdminService | |
| 15 | +{ | |
| 16 | + private readonly IAppUnitOfWork _uow; | |
| 17 | + | |
| 18 | + public BudgetCategoryAdminService(IAppUnitOfWork uow) | |
| 19 | + { | |
| 20 | + _uow = uow; | |
| 21 | + } | |
| 22 | + | |
| 23 | + public async Task<List<BudgetCategoryBllDto>> GetAllAsync(Guid? tripId, string? search) | |
| 24 | + { | |
| 25 | + var items = (await _uow.BudgetCategories.GetAllAsync()).ToList(); | |
| 26 | + if (tripId.HasValue) | |
| 27 | + items = items.Where(b => b.TripId == tripId.Value).ToList(); | |
| 28 | + if (!string.IsNullOrEmpty(search)) | |
| 29 | + items = items.Where(b => b.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); | |
| 30 | + return BudgetCategoryBllDtoFactory.CreateList(items.OrderBy(b => b.DisplayOrder)); | |
| 31 | + } | |
| 32 | + | |
| 33 | + public async Task<List<TripBllDto>> GetAllTripsAsync() | |
| 34 | + { | |
| 35 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 36 | + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name)); | |
| 37 | + } | |
| 38 | + | |
| 39 | + public async Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id) | |
| 40 | + { | |
| 41 | + var category = await _uow.BudgetCategories.GetByIdAsync(id); | |
| 42 | + return category == null ? null : BudgetCategoryBllDtoFactory.Create(category); | |
| 43 | + } | |
| 44 | + | |
| 45 | + public async Task CreateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt) | |
| 46 | + { | |
| 47 | + var domainEntity = BudgetCategoryBllDtoFactory.ToEntity(entity); | |
| 48 | + ApplyLangStr(domainEntity, nameEn, nameEt); | |
| 49 | + domainEntity.Id = Guid.NewGuid(); | |
| 50 | + _uow.BudgetCategories.Add(domainEntity); | |
| 51 | + await _uow.SaveChangesAsync(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + public async Task UpdateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt) | |
| 55 | + { | |
| 56 | + var existing = await _uow.BudgetCategories.GetByIdAsync(entity.Id); | |
| 57 | + if (existing == null) return; | |
| 58 | + existing.TripId = entity.TripId; | |
| 59 | + existing.IconName = entity.IconName; | |
| 60 | + existing.PlannedAmount = entity.PlannedAmount; | |
| 61 | + existing.DisplayOrder = entity.DisplayOrder; | |
| 62 | + ApplyLangStr(existing, nameEn, nameEt); | |
| 63 | + _uow.BudgetCategories.Update(existing); | |
| 64 | + await _uow.SaveChangesAsync(); | |
| 65 | + } | |
| 66 | + | |
| 67 | + public async Task DeleteAsync(Guid id) | |
| 68 | + { | |
| 69 | + await _uow.BudgetCategories.RemoveAsync(id); | |
| 70 | + await _uow.SaveChangesAsync(); | |
| 71 | + } | |
| 72 | + | |
| 73 | + public Task<bool> ExistsAsync(Guid id) => _uow.BudgetCategories.ExistsAsync(id); | |
| 74 | + | |
| 75 | + private static void ApplyLangStr(BudgetCategory entity, string? nameEn, string? nameEt) | |
| 76 | + { | |
| 77 | + var name = new LangStr(nameEn ?? "", "en"); | |
| 78 | + name.SetTranslation(nameEt ?? nameEn ?? "", "et"); | |
| 79 | + entity.Name = name; | |
| 80 | + } | |
| 81 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/CurrencyAdminService.cs +73 −0
| @@ -0,0 +1,73 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | +using SplitApp.Shared.Kernel.Domain; | |
| 10 | +using SplitApp.Shared.Kernel.Localization; | |
| 11 | + | |
| 12 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 13 | + | |
| 14 | +public class CurrencyAdminService : ICurrencyAdminService | |
| 15 | +{ | |
| 16 | + private readonly IAppUnitOfWork _uow; | |
| 17 | + | |
| 18 | + public CurrencyAdminService(IAppUnitOfWork uow) | |
| 19 | + { | |
| 20 | + _uow = uow; | |
| 21 | + } | |
| 22 | + | |
| 23 | + public async Task<List<CurrencyBllDto>> GetAllAsync(string? search) | |
| 24 | + { | |
| 25 | + var items = (await _uow.GetRepository<Currency>().GetAllAsync()).OrderBy(c => c.Code).ToList(); | |
| 26 | + if (!string.IsNullOrEmpty(search)) | |
| 27 | + items = items.Where(c => | |
| 28 | + c.Code.Contains(search, StringComparison.OrdinalIgnoreCase) || | |
| 29 | + c.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); | |
| 30 | + return CurrencyBllDtoFactory.CreateList(items); | |
| 31 | + } | |
| 32 | + | |
| 33 | + public async Task<CurrencyBllDto?> GetByIdAsync(Guid id) | |
| 34 | + { | |
| 35 | + var entity = await _uow.GetRepository<Currency>().GetByIdAsync(id); | |
| 36 | + return entity == null ? null : CurrencyBllDtoFactory.Create(entity); | |
| 37 | + } | |
| 38 | + | |
| 39 | + public async Task CreateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt) | |
| 40 | + { | |
| 41 | + var domainEntity = CurrencyBllDtoFactory.ToEntity(entity); | |
| 42 | + ApplyLangStr(domainEntity, nameEn, nameEt); | |
| 43 | + domainEntity.Id = Guid.NewGuid(); | |
| 44 | + _uow.GetRepository<Currency>().Add(domainEntity); | |
| 45 | + await _uow.SaveChangesAsync(); | |
| 46 | + } | |
| 47 | + | |
| 48 | + public async Task UpdateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt) | |
| 49 | + { | |
| 50 | + var existing = await _uow.GetRepository<Currency>().GetByIdAsync(entity.Id); | |
| 51 | + if (existing == null) return; | |
| 52 | + existing.Code = entity.Code; | |
| 53 | + existing.Symbol = entity.Symbol; | |
| 54 | + ApplyLangStr(existing, nameEn, nameEt); | |
| 55 | + _uow.GetRepository<Currency>().Update(existing); | |
| 56 | + await _uow.SaveChangesAsync(); | |
| 57 | + } | |
| 58 | + | |
| 59 | + public async Task DeleteAsync(Guid id) | |
| 60 | + { | |
| 61 | + await _uow.GetRepository<Currency>().RemoveAsync(id); | |
| 62 | + await _uow.SaveChangesAsync(); | |
| 63 | + } | |
| 64 | + | |
| 65 | + public Task<bool> ExistsAsync(Guid id) => _uow.GetRepository<Currency>().ExistsAsync(id); | |
| 66 | + | |
| 67 | + private static void ApplyLangStr(Currency entity, string? nameEn, string? nameEt) | |
| 68 | + { | |
| 69 | + var name = new LangStr(nameEn ?? "", "en"); | |
| 70 | + name.SetTranslation(nameEt ?? nameEn ?? "", "et"); | |
| 71 | + entity.Name = name; | |
| 72 | + } | |
| 73 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/ExpenseAdminService.cs +99 −0
| @@ -0,0 +1,99 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 10 | + | |
| 11 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 12 | + | |
| 13 | +public class ExpenseAdminService : IExpenseAdminService | |
| 14 | +{ | |
| 15 | + private readonly IAppUnitOfWork _uow; | |
| 16 | + | |
| 17 | + public ExpenseAdminService(IAppUnitOfWork uow) | |
| 18 | + { | |
| 19 | + _uow = uow; | |
| 20 | + } | |
| 21 | + | |
| 22 | + public async Task<List<ExpenseBllDto>> GetAllAsync(Guid? tripId, string? search) | |
| 23 | + { | |
| 24 | + var items = (await _uow.Expenses.GetAllAsync()).ToList(); | |
| 25 | + if (tripId.HasValue) | |
| 26 | + items = items.Where(e => e.TripId == tripId.Value).ToList(); | |
| 27 | + if (!string.IsNullOrEmpty(search)) | |
| 28 | + items = items.Where(e => e.Description != null && e.Description.Contains(search)).ToList(); | |
| 29 | + return ExpenseBllDtoFactory.CreateList(items.OrderByDescending(e => e.ExpenseDate)); | |
| 30 | + } | |
| 31 | + | |
| 32 | + public async Task<ExpenseBllDto?> GetByIdAsync(Guid id) | |
| 33 | + { | |
| 34 | + var entity = await _uow.Expenses.GetByIdAsync(id); | |
| 35 | + return entity == null ? null : ExpenseBllDtoFactory.Create(entity); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task CreateAsync(ExpenseBllDto entity) | |
| 39 | + { | |
| 40 | + var domainEntity = ExpenseBllDtoFactory.ToEntity(entity); | |
| 41 | + domainEntity.Id = Guid.NewGuid(); | |
| 42 | + _uow.Expenses.Add(domainEntity); | |
| 43 | + await _uow.SaveChangesAsync(); | |
| 44 | + } | |
| 45 | + | |
| 46 | + public async Task UpdateAsync(ExpenseBllDto entity) | |
| 47 | + { | |
| 48 | + var existing = await _uow.Expenses.GetByIdAsync(entity.Id); | |
| 49 | + if (existing == null) return; | |
| 50 | + existing.TripId = entity.TripId; | |
| 51 | + existing.PaidByUserId = entity.PaidByUserId; | |
| 52 | + existing.BudgetCategoryId = entity.BudgetCategoryId; | |
| 53 | + existing.CurrencyId = entity.CurrencyId; | |
| 54 | + existing.Amount = entity.Amount; | |
| 55 | + existing.Description = entity.Description; | |
| 56 | + existing.ExpenseDate = entity.ExpenseDate; | |
| 57 | + existing.SplitMethod = entity.SplitMethod; | |
| 58 | + _uow.Expenses.Update(existing); | |
| 59 | + await _uow.SaveChangesAsync(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + public async Task DeleteAsync(Guid id) | |
| 63 | + { | |
| 64 | + await _uow.Expenses.RemoveAsync(id); | |
| 65 | + await _uow.SaveChangesAsync(); | |
| 66 | + } | |
| 67 | + | |
| 68 | + public Task<bool> ExistsAsync(Guid id) => _uow.Expenses.ExistsAsync(id); | |
| 69 | + | |
| 70 | + public async Task<List<TripBllDto>> GetTripsAsync() | |
| 71 | + { | |
| 72 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 73 | + return TripBllDtoFactory.CreateList(trips); | |
| 74 | + } | |
| 75 | + | |
| 76 | + public async Task<List<AppUserBllDto>> GetUsersAsync() | |
| 77 | + { | |
| 78 | + var users = await _uow.Users.GetAllAsync(); | |
| 79 | + return users.Select(u => new AppUserBllDto | |
| 80 | + { | |
| 81 | + Id = u.Id, | |
| 82 | + FirstName = u.FirstName, | |
| 83 | + LastName = u.LastName, | |
| 84 | + Email = u.Email | |
| 85 | + }).ToList(); | |
| 86 | + } | |
| 87 | + | |
| 88 | + public async Task<List<CurrencyBllDto>> GetCurrenciesAsync() | |
| 89 | + { | |
| 90 | + var currencies = await _uow.GetRepository<Currency>().GetAllAsync(); | |
| 91 | + return CurrencyBllDtoFactory.CreateList(currencies); | |
| 92 | + } | |
| 93 | + | |
| 94 | + public async Task<List<BudgetCategoryBllDto>> GetBudgetCategoriesAsync() | |
| 95 | + { | |
| 96 | + var categories = await _uow.BudgetCategories.GetAllAsync(); | |
| 97 | + return BudgetCategoryBllDtoFactory.CreateList(categories); | |
| 98 | + } | |
| 99 | +} |
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 +77 −0
| @@ -0,0 +1,77 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 2 | +using SplitApp.WebApp.Application.Mappers; | |
| 3 | +using SplitApp.WebApp.Application.Contracts; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 6 | + | |
| 7 | +public class InvitationAdminService : IInvitationAdminService | |
| 8 | +{ | |
| 9 | + private readonly IAppUnitOfWork _uow; | |
| 10 | + | |
| 11 | + public InvitationAdminService(IAppUnitOfWork uow) | |
| 12 | + { | |
| 13 | + _uow = uow; | |
| 14 | + } | |
| 15 | + | |
| 16 | + public async Task<List<TripInvitationBllDto>> GetAllAsync(string? search) | |
| 17 | + { | |
| 18 | + var invitations = (await _uow.TripInvitations.GetAllAsync()).ToList(); | |
| 19 | + if (!string.IsNullOrEmpty(search)) | |
| 20 | + invitations = invitations.Where(i => i.Token.Contains(search)).ToList(); | |
| 21 | + return InvitationBllDtoFactory.CreateList(invitations.OrderByDescending(i => i.Id)); | |
| 22 | + } | |
| 23 | + | |
| 24 | + public async Task<TripInvitationBllDto?> GetByIdAsync(Guid id) | |
| 25 | + { | |
| 26 | + var entity = await _uow.TripInvitations.GetByIdAsync(id); | |
| 27 | + return entity == null ? null : InvitationBllDtoFactory.Create(entity); | |
| 28 | + } | |
| 29 | + | |
| 30 | + public async Task CreateAsync(TripInvitationBllDto entity) | |
| 31 | + { | |
| 32 | + var domainEntity = InvitationBllDtoFactory.ToEntity(entity); | |
| 33 | + domainEntity.Id = Guid.NewGuid(); | |
| 34 | + if (string.IsNullOrEmpty(domainEntity.Token)) | |
| 35 | + domainEntity.Token = Guid.NewGuid().ToString("N"); | |
| 36 | + _uow.TripInvitations.Add(domainEntity); | |
| 37 | + await _uow.SaveChangesAsync(); | |
| 38 | + } | |
| 39 | + | |
| 40 | + public async Task UpdateAsync(TripInvitationBllDto entity) | |
| 41 | + { | |
| 42 | + var existing = await _uow.TripInvitations.GetByIdAsync(entity.Id); | |
| 43 | + if (existing == null) return; | |
| 44 | + existing.TripId = entity.TripId; | |
| 45 | + existing.InvitedByUserId = entity.InvitedByUserId; | |
| 46 | + existing.Token = entity.Token; | |
| 47 | + existing.Status = entity.Status; | |
| 48 | + existing.ExpiresAt = entity.ExpiresAt; | |
| 49 | + existing.RespondedAt = entity.RespondedAt; | |
| 50 | + _uow.TripInvitations.Update(existing); | |
| 51 | + await _uow.SaveChangesAsync(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + public async Task DeleteAsync(Guid id) | |
| 55 | + { | |
| 56 | + await _uow.TripInvitations.RemoveAsync(id); | |
| 57 | + await _uow.SaveChangesAsync(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + public async Task<List<TripBllDto>> GetTripsAsync() | |
| 61 | + { | |
| 62 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 63 | + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name)); | |
| 64 | + } | |
| 65 | + | |
| 66 | + public async Task<List<AppUserBllDto>> GetUsersAsync() | |
| 67 | + { | |
| 68 | + var users = await _uow.Users.GetAllAsync(); | |
| 69 | + return users.Select(u => new AppUserBllDto | |
| 70 | + { | |
| 71 | + Id = u.Id, | |
| 72 | + FirstName = u.FirstName, | |
| 73 | + LastName = u.LastName, | |
| 74 | + Email = u.Email | |
| 75 | + }).ToList(); | |
| 76 | + } | |
| 77 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/PollAdminService.cs +77 −0
| @@ -0,0 +1,77 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 2 | +using SplitApp.WebApp.Application.Mappers; | |
| 3 | +using SplitApp.WebApp.Application.Contracts; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 6 | + | |
| 7 | +public class PollAdminService : IPollAdminService | |
| 8 | +{ | |
| 9 | + private readonly IAppUnitOfWork _uow; | |
| 10 | + | |
| 11 | + public PollAdminService(IAppUnitOfWork uow) | |
| 12 | + { | |
| 13 | + _uow = uow; | |
| 14 | + } | |
| 15 | + | |
| 16 | + public async Task<List<TripPollBllDto>> GetAllAsync(string? search) | |
| 17 | + { | |
| 18 | + var items = (await _uow.TripPolls.GetAllAsync()).ToList(); | |
| 19 | + if (!string.IsNullOrEmpty(search)) | |
| 20 | + items = items.Where(p => p.Question.Contains(search)).ToList(); | |
| 21 | + return PollBllDtoFactory.CreateList(items.OrderByDescending(p => p.Id), includeOptions: true); | |
| 22 | + } | |
| 23 | + | |
| 24 | + public async Task<TripPollBllDto?> GetByIdAsync(Guid id) | |
| 25 | + { | |
| 26 | + var entity = await _uow.TripPolls.GetByIdAsync(id); | |
| 27 | + return entity == null ? null : PollBllDtoFactory.Create(entity); | |
| 28 | + } | |
| 29 | + | |
| 30 | + public async Task CreateAsync(TripPollBllDto entity) | |
| 31 | + { | |
| 32 | + var domainEntity = PollBllDtoFactory.ToEntity(entity); | |
| 33 | + domainEntity.Id = Guid.NewGuid(); | |
| 34 | + _uow.TripPolls.Add(domainEntity); | |
| 35 | + await _uow.SaveChangesAsync(); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task UpdateAsync(TripPollBllDto entity) | |
| 39 | + { | |
| 40 | + var existing = await _uow.TripPolls.GetByIdAsync(entity.Id); | |
| 41 | + if (existing == null) return; | |
| 42 | + existing.TripId = entity.TripId; | |
| 43 | + existing.CreatedByUserId = entity.CreatedByUserId; | |
| 44 | + existing.Question = entity.Question; | |
| 45 | + existing.AllowMultipleVotes = entity.AllowMultipleVotes; | |
| 46 | + existing.IsAnonymous = entity.IsAnonymous; | |
| 47 | + existing.ClosedAt = entity.ClosedAt; | |
| 48 | + _uow.TripPolls.Update(existing); | |
| 49 | + await _uow.SaveChangesAsync(); | |
| 50 | + } | |
| 51 | + | |
| 52 | + public async Task DeleteAsync(Guid id) | |
| 53 | + { | |
| 54 | + await _uow.TripPolls.RemoveAsync(id); | |
| 55 | + await _uow.SaveChangesAsync(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + public Task<bool> ExistsAsync(Guid id) => _uow.TripPolls.ExistsAsync(id); | |
| 59 | + | |
| 60 | + public async Task<List<TripBllDto>> GetTripsAsync() | |
| 61 | + { | |
| 62 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 63 | + return TripBllDtoFactory.CreateList(trips); | |
| 64 | + } | |
| 65 | + | |
| 66 | + public async Task<List<AppUserBllDto>> GetUsersAsync() | |
| 67 | + { | |
| 68 | + var users = await _uow.Users.GetAllAsync(); | |
| 69 | + return users.Select(u => new AppUserBllDto | |
| 70 | + { | |
| 71 | + Id = u.Id, | |
| 72 | + FirstName = u.FirstName, | |
| 73 | + LastName = u.LastName, | |
| 74 | + Email = u.Email | |
| 75 | + }).ToList(); | |
| 76 | + } | |
| 77 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPaymentAdminService.cs +78 −0
| @@ -0,0 +1,78 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 2 | +using SplitApp.WebApp.Application.Mappers; | |
| 3 | +using SplitApp.WebApp.Application.Contracts; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 6 | + | |
| 7 | +public class SettlementPaymentAdminService : ISettlementPaymentAdminService | |
| 8 | +{ | |
| 9 | + private readonly IAppUnitOfWork _uow; | |
| 10 | + | |
| 11 | + public SettlementPaymentAdminService(IAppUnitOfWork uow) | |
| 12 | + { | |
| 13 | + _uow = uow; | |
| 14 | + } | |
| 15 | + | |
| 16 | + public async Task<List<SettlementPaymentBllDto>> GetAllAsync(string? search) | |
| 17 | + { | |
| 18 | + var payments = (await _uow.SettlementPayments.GetAllAsync()).ToList(); | |
| 19 | + if (!string.IsNullOrEmpty(search)) | |
| 20 | + payments = payments.Where(s => | |
| 21 | + (s.FromUser?.Email != null && s.FromUser.Email.Contains(search)) || | |
| 22 | + (s.ToUser?.Email != null && s.ToUser.Email.Contains(search))).ToList(); | |
| 23 | + return SettlementPaymentBllDtoFactory.CreateList(payments.OrderByDescending(s => s.Id)); | |
| 24 | + } | |
| 25 | + | |
| 26 | + public async Task<SettlementPaymentBllDto?> GetByIdAsync(Guid id) | |
| 27 | + { | |
| 28 | + var entity = await _uow.SettlementPayments.GetByIdAsync(id); | |
| 29 | + return entity == null ? null : SettlementPaymentBllDtoFactory.Create(entity); | |
| 30 | + } | |
| 31 | + | |
| 32 | + public async Task CreateAsync(SettlementPaymentBllDto entity) | |
| 33 | + { | |
| 34 | + var domainEntity = SettlementPaymentBllDtoFactory.ToEntity(entity); | |
| 35 | + domainEntity.Id = Guid.NewGuid(); | |
| 36 | + _uow.SettlementPayments.Add(domainEntity); | |
| 37 | + await _uow.SaveChangesAsync(); | |
| 38 | + } | |
| 39 | + | |
| 40 | + public async Task UpdateAsync(SettlementPaymentBllDto entity) | |
| 41 | + { | |
| 42 | + var existing = await _uow.SettlementPayments.GetByIdAsync(entity.Id); | |
| 43 | + if (existing == null) return; | |
| 44 | + existing.SettlementPlanId = entity.SettlementPlanId; | |
| 45 | + existing.FromUserId = entity.FromUserId; | |
| 46 | + existing.ToUserId = entity.ToUserId; | |
| 47 | + existing.Amount = entity.Amount; | |
| 48 | + existing.Status = entity.Status; | |
| 49 | + existing.MarkedPaidAt = entity.MarkedPaidAt; | |
| 50 | + existing.ConfirmedAt = entity.ConfirmedAt; | |
| 51 | + _uow.SettlementPayments.Update(existing); | |
| 52 | + await _uow.SaveChangesAsync(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + public async Task DeleteAsync(Guid id) | |
| 56 | + { | |
| 57 | + await _uow.SettlementPayments.RemoveAsync(id); | |
| 58 | + await _uow.SaveChangesAsync(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + public async Task<List<SettlementPlanBllDto>> GetSettlementPlansAsync() | |
| 62 | + { | |
| 63 | + var plans = await _uow.SettlementPlans.GetAllAsync(); | |
| 64 | + return SettlementBllDtoFactory.CreateList(plans); | |
| 65 | + } | |
| 66 | + | |
| 67 | + public async Task<List<AppUserBllDto>> GetUsersAsync() | |
| 68 | + { | |
| 69 | + var users = await _uow.Users.GetAllAsync(); | |
| 70 | + return users.Select(u => new AppUserBllDto | |
| 71 | + { | |
| 72 | + Id = u.Id, | |
| 73 | + FirstName = u.FirstName, | |
| 74 | + LastName = u.LastName, | |
| 75 | + Email = u.Email | |
| 76 | + }).ToList(); | |
| 77 | + } | |
| 78 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SettlementPlanAdminService.cs +76 −0
| @@ -0,0 +1,76 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 2 | +using SplitApp.WebApp.Application.Mappers; | |
| 3 | +using SplitApp.WebApp.Application.Contracts; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 6 | + | |
| 7 | +public class SettlementPlanAdminService : ISettlementPlanAdminService | |
| 8 | +{ | |
| 9 | + private readonly IAppUnitOfWork _uow; | |
| 10 | + | |
| 11 | + public SettlementPlanAdminService(IAppUnitOfWork uow) | |
| 12 | + { | |
| 13 | + _uow = uow; | |
| 14 | + } | |
| 15 | + | |
| 16 | + public async Task<List<SettlementPlanBllDto>> GetAllAsync(Guid? tripId) | |
| 17 | + { | |
| 18 | + var plans = (await _uow.SettlementPlans.GetAllAsync()).ToList(); | |
| 19 | + if (tripId.HasValue) | |
| 20 | + plans = plans.Where(s => s.TripId == tripId.Value).ToList(); | |
| 21 | + return SettlementBllDtoFactory.CreateList(plans.OrderByDescending(s => s.Id)); | |
| 22 | + } | |
| 23 | + | |
| 24 | + public async Task<SettlementPlanBllDto?> GetByIdAsync(Guid id) | |
| 25 | + { | |
| 26 | + var entity = await _uow.SettlementPlans.GetByIdAsync(id); | |
| 27 | + return entity == null ? null : SettlementBllDtoFactory.Create(entity); | |
| 28 | + } | |
| 29 | + | |
| 30 | + public async Task CreateAsync(SettlementPlanBllDto entity) | |
| 31 | + { | |
| 32 | + var domainEntity = SettlementBllDtoFactory.ToEntity(entity); | |
| 33 | + domainEntity.Id = Guid.NewGuid(); | |
| 34 | + _uow.SettlementPlans.Add(domainEntity); | |
| 35 | + await _uow.SaveChangesAsync(); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task UpdateAsync(SettlementPlanBllDto entity) | |
| 39 | + { | |
| 40 | + var existing = await _uow.SettlementPlans.GetByIdAsync(entity.Id); | |
| 41 | + if (existing == null) return; | |
| 42 | + existing.TripId = entity.TripId; | |
| 43 | + existing.CreatedByUserId = entity.CreatedByUserId; | |
| 44 | + existing.TotalAmount = entity.TotalAmount; | |
| 45 | + existing.Status = entity.Status; | |
| 46 | + existing.CompletedAt = entity.CompletedAt; | |
| 47 | + _uow.SettlementPlans.Update(existing); | |
| 48 | + await _uow.SaveChangesAsync(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + public async Task DeleteAsync(Guid id) | |
| 52 | + { | |
| 53 | + await _uow.SettlementPlans.RemoveAsync(id); | |
| 54 | + await _uow.SaveChangesAsync(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + public Task<bool> ExistsAsync(Guid id) => _uow.SettlementPlans.ExistsAsync(id); | |
| 58 | + | |
| 59 | + public async Task<List<TripBllDto>> GetTripsAsync() | |
| 60 | + { | |
| 61 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 62 | + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name)); | |
| 63 | + } | |
| 64 | + | |
| 65 | + public async Task<List<AppUserBllDto>> GetUsersAsync() | |
| 66 | + { | |
| 67 | + var users = await _uow.Users.GetAllAsync(); | |
| 68 | + return users.Select(u => new AppUserBllDto | |
| 69 | + { | |
| 70 | + Id = u.Id, | |
| 71 | + FirstName = u.FirstName, | |
| 72 | + LastName = u.LastName, | |
| 73 | + Email = u.Email | |
| 74 | + }).ToList(); | |
| 75 | + } | |
| 76 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/SplitPresetAdminService.cs +61 −0
| @@ -0,0 +1,61 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 2 | +using SplitApp.WebApp.Application.Mappers; | |
| 3 | +using SplitApp.WebApp.Application.Contracts; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 6 | + | |
| 7 | +public class SplitPresetAdminService : ISplitPresetAdminService | |
| 8 | +{ | |
| 9 | + private readonly IAppUnitOfWork _uow; | |
| 10 | + | |
| 11 | + public SplitPresetAdminService(IAppUnitOfWork uow) | |
| 12 | + { | |
| 13 | + _uow = uow; | |
| 14 | + } | |
| 15 | + | |
| 16 | + public async Task<List<SplitPresetBllDto>> GetAllAsync(string? search) | |
| 17 | + { | |
| 18 | + var presets = (await _uow.SplitPresets.GetAllAsync()).ToList(); | |
| 19 | + if (!string.IsNullOrEmpty(search)) | |
| 20 | + presets = presets.Where(s => s.Name.Contains(search)).ToList(); | |
| 21 | + return SplitPresetBllDtoFactory.CreateList(presets.OrderByDescending(s => s.Id), includeMembers: true); | |
| 22 | + } | |
| 23 | + | |
| 24 | + public async Task<SplitPresetBllDto?> GetByIdAsync(Guid id) | |
| 25 | + { | |
| 26 | + var entity = await _uow.SplitPresets.GetByIdAsync(id); | |
| 27 | + return entity == null ? null : SplitPresetBllDtoFactory.Create(entity, includeMembers: true); | |
| 28 | + } | |
| 29 | + | |
| 30 | + public async Task CreateAsync(SplitPresetBllDto entity) | |
| 31 | + { | |
| 32 | + var domainEntity = SplitPresetBllDtoFactory.ToEntity(entity); | |
| 33 | + domainEntity.Id = Guid.NewGuid(); | |
| 34 | + _uow.SplitPresets.Add(domainEntity); | |
| 35 | + await _uow.SaveChangesAsync(); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task DeleteAsync(Guid id) | |
| 39 | + { | |
| 40 | + await _uow.SplitPresets.RemoveAsync(id); | |
| 41 | + await _uow.SaveChangesAsync(); | |
| 42 | + } | |
| 43 | + | |
| 44 | + public async Task<List<TripBllDto>> GetTripsAsync() | |
| 45 | + { | |
| 46 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 47 | + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name)); | |
| 48 | + } | |
| 49 | + | |
| 50 | + public async Task<List<AppUserBllDto>> GetUsersAsync() | |
| 51 | + { | |
| 52 | + var users = await _uow.Users.GetAllAsync(); | |
| 53 | + return users.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 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripAdminService.cs +71 −0
| @@ -0,0 +1,71 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | + | |
| 10 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 11 | + | |
| 12 | +public class TripAdminService : ITripAdminService | |
| 13 | +{ | |
| 14 | + private readonly IAppUnitOfWork _uow; | |
| 15 | + | |
| 16 | + public TripAdminService(IAppUnitOfWork uow) | |
| 17 | + { | |
| 18 | + _uow = uow; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public async Task<List<TripBllDto>> GetAllAsync(string? search) | |
| 22 | + { | |
| 23 | + var items = (await _uow.Trips.GetAllAsync()).ToList(); | |
| 24 | + if (!string.IsNullOrEmpty(search)) | |
| 25 | + items = items.Where(t => t.Name.Contains(search)).ToList(); | |
| 26 | + return TripBllDtoFactory.CreateList(items.OrderByDescending(t => t.CreatedAt)); | |
| 27 | + } | |
| 28 | + | |
| 29 | + public async Task<List<CurrencyBllDto>> GetAllCurrenciesAsync() | |
| 30 | + { | |
| 31 | + var currencies = await _uow.GetRepository<Currency>().GetAllAsync(); | |
| 32 | + return CurrencyBllDtoFactory.CreateList(currencies); | |
| 33 | + } | |
| 34 | + | |
| 35 | + public async Task<TripBllDto?> GetByIdAsync(Guid id) | |
| 36 | + { | |
| 37 | + var trip = await _uow.Trips.GetByIdAsync(id); | |
| 38 | + return trip == null ? null : TripBllDtoFactory.Create(trip); | |
| 39 | + } | |
| 40 | + | |
| 41 | + public async Task CreateAsync(TripBllDto entity) | |
| 42 | + { | |
| 43 | + var domainEntity = TripBllDtoFactory.ToEntity(entity); | |
| 44 | + domainEntity.Id = Guid.NewGuid(); | |
| 45 | + _uow.Trips.Add(domainEntity); | |
| 46 | + await _uow.SaveChangesAsync(); | |
| 47 | + } | |
| 48 | + | |
| 49 | + public async Task UpdateAsync(TripBllDto entity) | |
| 50 | + { | |
| 51 | + var existing = await _uow.Trips.GetByIdAsync(entity.Id); | |
| 52 | + if (existing == null) return; | |
| 53 | + existing.Name = entity.Name; | |
| 54 | + existing.Description = entity.Description; | |
| 55 | + existing.Destination = entity.Destination; | |
| 56 | + existing.StartDate = entity.StartDate; | |
| 57 | + existing.EndDate = entity.EndDate; | |
| 58 | + existing.Status = entity.Status; | |
| 59 | + existing.DefaultCurrencyId = entity.DefaultCurrencyId; | |
| 60 | + _uow.Trips.Update(existing); | |
| 61 | + await _uow.SaveChangesAsync(); | |
| 62 | + } | |
| 63 | + | |
| 64 | + public async Task DeleteAsync(Guid id) | |
| 65 | + { | |
| 66 | + await _uow.Trips.RemoveAsync(id); | |
| 67 | + await _uow.SaveChangesAsync(); | |
| 68 | + } | |
| 69 | + | |
| 70 | + public Task<bool> ExistsAsync(Guid id) => _uow.Trips.ExistsAsync(id); | |
| 71 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Admin/TripParticipantAdminService.cs +86 −0
| @@ -0,0 +1,86 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 2 | +using SplitApp.WebApp.Application.Mappers; | |
| 3 | +using SplitApp.WebApp.Application.Contracts; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 6 | + | |
| 7 | +public class TripParticipantAdminService : ITripParticipantAdminService | |
| 8 | +{ | |
| 9 | + private readonly IAppUnitOfWork _uow; | |
| 10 | + | |
| 11 | + public TripParticipantAdminService(IAppUnitOfWork uow) | |
| 12 | + { | |
| 13 | + _uow = uow; | |
| 14 | + } | |
| 15 | + | |
| 16 | + public async Task<List<TripParticipantBllDto>> GetAllAsync(Guid? tripId, string? search) | |
| 17 | + { | |
| 18 | + var participants = (await _uow.TripParticipants.GetAllAsync()).ToList(); | |
| 19 | + | |
| 20 | + if (tripId.HasValue) | |
| 21 | + participants = participants.Where(tp => tp.TripId == tripId.Value).ToList(); | |
| 22 | + | |
| 23 | + if (!string.IsNullOrEmpty(search)) | |
| 24 | + participants = participants.Where(tp => tp.User != null && ( | |
| 25 | + tp.User.FirstName.Contains(search) || | |
| 26 | + tp.User.LastName.Contains(search) || | |
| 27 | + (tp.User.Email != null && tp.User.Email.Contains(search)))).ToList(); | |
| 28 | + | |
| 29 | + return TripParticipantBllDtoFactory.CreateList(participants.OrderByDescending(tp => tp.JoinedAt)); | |
| 30 | + } | |
| 31 | + | |
| 32 | + public async Task<TripParticipantBllDto?> GetByIdAsync(Guid id) | |
| 33 | + { | |
| 34 | + var entity = await _uow.TripParticipants.GetByIdAsync(id); | |
| 35 | + return entity == null ? null : TripParticipantBllDtoFactory.Create(entity); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task CreateAsync(TripParticipantBllDto entity) | |
| 39 | + { | |
| 40 | + var domainEntity = TripParticipantBllDtoFactory.ToEntity(entity); | |
| 41 | + domainEntity.Id = Guid.NewGuid(); | |
| 42 | + _uow.TripParticipants.Add(domainEntity); | |
| 43 | + await _uow.SaveChangesAsync(); | |
| 44 | + } | |
| 45 | + | |
| 46 | + public async Task UpdateAsync(TripParticipantBllDto entity) | |
| 47 | + { | |
| 48 | + var existing = await _uow.TripParticipants.GetByIdAsync(entity.Id); | |
| 49 | + if (existing == null) return; | |
| 50 | + existing.TripId = entity.TripId; | |
| 51 | + existing.UserId = entity.UserId; | |
| 52 | + existing.Role = entity.Role; | |
| 53 | + existing.Nickname = entity.Nickname; | |
| 54 | + existing.JoinedAt = entity.JoinedAt; | |
| 55 | + existing.LeftAt = entity.LeftAt; | |
| 56 | + existing.IsActive = entity.IsActive; | |
| 57 | + _uow.TripParticipants.Update(existing); | |
| 58 | + await _uow.SaveChangesAsync(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + public async Task DeleteAsync(Guid id) | |
| 62 | + { | |
| 63 | + await _uow.TripParticipants.RemoveAsync(id); | |
| 64 | + await _uow.SaveChangesAsync(); | |
| 65 | + } | |
| 66 | + | |
| 67 | + public Task<bool> ExistsAsync(Guid id) => _uow.TripParticipants.ExistsAsync(id); | |
| 68 | + | |
| 69 | + public async Task<List<TripBllDto>> GetTripsAsync() | |
| 70 | + { | |
| 71 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 72 | + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name)); | |
| 73 | + } | |
| 74 | + | |
| 75 | + public async Task<List<AppUserBllDto>> GetUsersAsync() | |
| 76 | + { | |
| 77 | + var users = await _uow.Users.GetAllAsync(); | |
| 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/Admin/WishlistAdminService.cs +83 −0
| @@ -0,0 +1,83 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 2 | +using SplitApp.WebApp.Application.Mappers; | |
| 3 | +using SplitApp.WebApp.Application.Contracts; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.Application.Services.Admin; | |
| 6 | + | |
| 7 | +public class WishlistAdminService : IWishlistAdminService | |
| 8 | +{ | |
| 9 | + private readonly IAppUnitOfWork _uow; | |
| 10 | + | |
| 11 | + public WishlistAdminService(IAppUnitOfWork uow) | |
| 12 | + { | |
| 13 | + _uow = uow; | |
| 14 | + } | |
| 15 | + | |
| 16 | + public async Task<List<TripWishlistItemBllDto>> GetAllAsync(string? search) | |
| 17 | + { | |
| 18 | + var items = (await _uow.TripWishlistItems.GetAllAsync()).ToList(); | |
| 19 | + if (!string.IsNullOrEmpty(search)) | |
| 20 | + items = items.Where(w => w.Title.Contains(search)).ToList(); | |
| 21 | + return WishlistBllDtoFactory.CreateList(items.OrderByDescending(w => w.Id)); | |
| 22 | + } | |
| 23 | + | |
| 24 | + public async Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id) | |
| 25 | + { | |
| 26 | + var entity = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 27 | + return entity == null ? null : WishlistBllDtoFactory.Create(entity); | |
| 28 | + } | |
| 29 | + | |
| 30 | + public async Task CreateAsync(TripWishlistItemBllDto entity) | |
| 31 | + { | |
| 32 | + var domainEntity = WishlistBllDtoFactory.ToEntity(entity); | |
| 33 | + domainEntity.Id = Guid.NewGuid(); | |
| 34 | + _uow.TripWishlistItems.Add(domainEntity); | |
| 35 | + await _uow.SaveChangesAsync(); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task UpdateAsync(TripWishlistItemBllDto entity) | |
| 39 | + { | |
| 40 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(entity.Id); | |
| 41 | + if (existing == null) return; | |
| 42 | + existing.TripId = entity.TripId; | |
| 43 | + existing.AddedByUserId = entity.AddedByUserId; | |
| 44 | + existing.Title = entity.Title; | |
| 45 | + existing.Description = entity.Description; | |
| 46 | + existing.Category = entity.Category; | |
| 47 | + existing.Priority = entity.Priority; | |
| 48 | + existing.EstimatedCost = entity.EstimatedCost; | |
| 49 | + existing.Url = entity.Url; | |
| 50 | + existing.Location = entity.Location; | |
| 51 | + existing.IsCompleted = entity.IsCompleted; | |
| 52 | + existing.CompletedAt = entity.CompletedAt; | |
| 53 | + existing.DisplayOrder = entity.DisplayOrder; | |
| 54 | + _uow.TripWishlistItems.Update(existing); | |
| 55 | + await _uow.SaveChangesAsync(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + public async Task DeleteAsync(Guid id) | |
| 59 | + { | |
| 60 | + await _uow.TripWishlistItems.RemoveAsync(id); | |
| 61 | + await _uow.SaveChangesAsync(); | |
| 62 | + } | |
| 63 | + | |
| 64 | + public Task<bool> ExistsAsync(Guid id) => _uow.TripWishlistItems.ExistsAsync(id); | |
| 65 | + | |
| 66 | + public async Task<List<TripBllDto>> GetTripsAsync() | |
| 67 | + { | |
| 68 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 69 | + return TripBllDtoFactory.CreateList(trips); | |
| 70 | + } | |
| 71 | + | |
| 72 | + public async Task<List<AppUserBllDto>> GetUsersAsync() | |
| 73 | + { | |
| 74 | + var users = await _uow.Users.GetAllAsync(); | |
| 75 | + return users.Select(u => new AppUserBllDto | |
| 76 | + { | |
| 77 | + Id = u.Id, | |
| 78 | + FirstName = u.FirstName, | |
| 79 | + LastName = u.LastName, | |
| 80 | + Email = u.Email | |
| 81 | + }).ToList(); | |
| 82 | + } | |
| 83 | +} |
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 +348 −0
| @@ -0,0 +1,348 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | + | |
| 10 | +namespace SplitApp.WebApp.Application.Services; | |
| 11 | + | |
| 12 | +public class ExpenseService : IExpenseService | |
| 13 | +{ | |
| 14 | + private readonly IAppUnitOfWork _uow; | |
| 15 | + | |
| 16 | + public ExpenseService(IAppUnitOfWork uow) | |
| 17 | + { | |
| 18 | + _uow = uow; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public async Task<ExpenseBllDto> CreateExpenseWithSplitsAsync(ExpenseBllDto expense, Guid[] participants, decimal[] amounts, decimal[] percentages) | |
| 22 | + { | |
| 23 | + var entity = ExpenseBllDtoFactory.ToEntity(expense); | |
| 24 | + entity.Id = Guid.NewGuid(); | |
| 25 | + _uow.Expenses.Add(entity); | |
| 26 | + | |
| 27 | + var splitRepo = _uow.GetRepository<ExpenseSplit>(); | |
| 28 | + | |
| 29 | + switch (entity.SplitMethod) | |
| 30 | + { | |
| 31 | + case ESplitMethod.EqualAll: | |
| 32 | + { | |
| 33 | + var allParticipants = (await _uow.TripParticipants.GetByTripIdAsync(entity.TripId)).ToList(); | |
| 34 | + var count = allParticipants.Count; | |
| 35 | + if (count > 0) | |
| 36 | + { | |
| 37 | + var baseAmount = Math.Floor(entity.Amount / count * 100) / 100; | |
| 38 | + var remainder = entity.Amount - baseAmount * count; | |
| 39 | + | |
| 40 | + for (var i = 0; i < allParticipants.Count; i++) | |
| 41 | + { | |
| 42 | + var amount = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0); | |
| 43 | + splitRepo.Add(new ExpenseSplit | |
| 44 | + { | |
| 45 | + Id = Guid.NewGuid(), | |
| 46 | + ExpenseId = entity.Id, | |
| 47 | + UserId = allParticipants[i].UserId, | |
| 48 | + Amount = amount | |
| 49 | + }); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + break; | |
| 53 | + } | |
| 54 | + case ESplitMethod.EqualSubset: | |
| 55 | + { | |
| 56 | + if (participants.Length > 0) | |
| 57 | + { | |
| 58 | + var count = participants.Length; | |
| 59 | + var baseAmount = Math.Floor(entity.Amount / count * 100) / 100; | |
| 60 | + var remainder = entity.Amount - baseAmount * count; | |
| 61 | + | |
| 62 | + for (var i = 0; i < participants.Length; i++) | |
| 63 | + { | |
| 64 | + var amount = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0); | |
| 65 | + splitRepo.Add(new ExpenseSplit | |
| 66 | + { | |
| 67 | + Id = Guid.NewGuid(), | |
| 68 | + ExpenseId = entity.Id, | |
| 69 | + UserId = participants[i], | |
| 70 | + Amount = amount | |
| 71 | + }); | |
| 72 | + } | |
| 73 | + } | |
| 74 | + break; | |
| 75 | + } | |
| 76 | + case ESplitMethod.ExactAmounts: | |
| 77 | + { | |
| 78 | + if (participants.Length > 0 && amounts.Length == participants.Length) | |
| 79 | + { | |
| 80 | + for (var i = 0; i < participants.Length; i++) | |
| 81 | + { | |
| 82 | + splitRepo.Add(new ExpenseSplit | |
| 83 | + { | |
| 84 | + Id = Guid.NewGuid(), | |
| 85 | + ExpenseId = entity.Id, | |
| 86 | + UserId = participants[i], | |
| 87 | + Amount = amounts[i] | |
| 88 | + }); | |
| 89 | + } | |
| 90 | + } | |
| 91 | + break; | |
| 92 | + } | |
| 93 | + case ESplitMethod.Percentages: | |
| 94 | + { | |
| 95 | + if (participants.Length > 0 && percentages.Length == participants.Length) | |
| 96 | + { | |
| 97 | + for (var i = 0; i < participants.Length; i++) | |
| 98 | + { | |
| 99 | + var amount = Math.Round(entity.Amount * percentages[i] / 100, 2); | |
| 100 | + splitRepo.Add(new ExpenseSplit | |
| 101 | + { | |
| 102 | + Id = Guid.NewGuid(), | |
| 103 | + ExpenseId = entity.Id, | |
| 104 | + UserId = participants[i], | |
| 105 | + Amount = amount, | |
| 106 | + Percentage = percentages[i] | |
| 107 | + }); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + break; | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + await _uow.SaveChangesAsync(); | |
| 115 | + | |
| 116 | + return ExpenseBllDtoFactory.Create(entity); | |
| 117 | + } | |
| 118 | + | |
| 119 | + public async Task DeleteExpenseWithSplitsAsync(Guid expenseId) | |
| 120 | + { | |
| 121 | + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(expenseId); | |
| 122 | + if (expense == null) return; | |
| 123 | + | |
| 124 | + var splitRepo = _uow.GetRepository<ExpenseSplit>(); | |
| 125 | + | |
| 126 | + if (expense.Splits != null) | |
| 127 | + { | |
| 128 | + foreach (var split in expense.Splits.ToList()) | |
| 129 | + { | |
| 130 | + await splitRepo.RemoveAsync(split.Id); | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + await _uow.Expenses.RemoveAsync(expenseId); | |
| 135 | + await _uow.SaveChangesAsync(); | |
| 136 | + } | |
| 137 | + | |
| 138 | + public async Task<List<ExpenseBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 139 | + { | |
| 140 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 141 | + return new List<ExpenseBllDto>(); | |
| 142 | + var expenses = await _uow.Expenses.GetByTripIdAsync(tripId); | |
| 143 | + return ExpenseBllDtoFactory.CreateList(expenses, includeSplits: true); | |
| 144 | + } | |
| 145 | + | |
| 146 | + public async Task<ExpenseBllDto?> GetByIdAsync(Guid expenseId, Guid userId) | |
| 147 | + { | |
| 148 | + var expense = await _uow.Expenses.GetByIdAsync(expenseId); | |
| 149 | + if (expense == null) return null; | |
| 150 | + if (!await _uow.TripParticipants.IsParticipantAsync(expense.TripId, userId)) return null; | |
| 151 | + return ExpenseBllDtoFactory.Create(expense); | |
| 152 | + } | |
| 153 | + | |
| 154 | + public async Task<ExpenseBllDto?> GetByIdWithDetailsAsync(Guid expenseId, Guid userId) | |
| 155 | + { | |
| 156 | + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(expenseId); | |
| 157 | + if (expense == null) return null; | |
| 158 | + if (!await _uow.TripParticipants.IsParticipantAsync(expense.TripId, userId)) return null; | |
| 159 | + return ExpenseBllDtoFactory.Create(expense, includeSplits: true); | |
| 160 | + } | |
| 161 | + | |
| 162 | + public async Task<ExpenseBllDto?> GetRawByIdAsync(Guid expenseId) | |
| 163 | + { | |
| 164 | + var expense = await _uow.Expenses.GetByIdAsync(expenseId); | |
| 165 | + return expense == null ? null : ExpenseBllDtoFactory.Create(expense); | |
| 166 | + } | |
| 167 | + | |
| 168 | + public async Task<bool> CanEditExpenseAsync(ExpenseBllDto expense, Guid userId) | |
| 169 | + { | |
| 170 | + if (expense.PaidByUserId == userId) return true; | |
| 171 | + return await _uow.TripParticipants.IsOrganizerAsync(expense.TripId, userId); | |
| 172 | + } | |
| 173 | + | |
| 174 | + public async Task<(bool success, string? errorCode)> UpdateExpenseWithSplitsFromDtoAsync( | |
| 175 | + Guid id, | |
| 176 | + decimal amount, | |
| 177 | + string? description, | |
| 178 | + DateTime expenseDate, | |
| 179 | + ESplitMethod splitMethod, | |
| 180 | + Guid? budgetCategoryId, | |
| 181 | + Guid? currencyId, | |
| 182 | + List<(Guid UserId, decimal Amount, decimal? Percentage)> splits, | |
| 183 | + Guid userId) | |
| 184 | + { | |
| 185 | + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(id); | |
| 186 | + if (expense == null) return (false, "notfound"); | |
| 187 | + | |
| 188 | + if (!await CanEditExpenseAsync(ExpenseBllDtoFactory.Create(expense), userId)) | |
| 189 | + return (false, "forbidden"); | |
| 190 | + | |
| 191 | + var trip = await _uow.Trips.GetByIdAsync(expense.TripId); | |
| 192 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 193 | + return (false, "badstatus"); | |
| 194 | + | |
| 195 | + expense.BudgetCategoryId = budgetCategoryId; | |
| 196 | + expense.CurrencyId = currencyId; | |
| 197 | + expense.Amount = amount; | |
| 198 | + expense.Description = description; | |
| 199 | + expense.ExpenseDate = expenseDate; | |
| 200 | + expense.SplitMethod = splitMethod; | |
| 201 | + | |
| 202 | + _uow.Expenses.Update(expense); | |
| 203 | + | |
| 204 | + var splitRepo = _uow.GetRepository<ExpenseSplit>(); | |
| 205 | + if (expense.Splits != null) | |
| 206 | + { | |
| 207 | + foreach (var split in expense.Splits.ToList()) | |
| 208 | + { | |
| 209 | + await splitRepo.RemoveAsync(split.Id); | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + foreach (var s in splits) | |
| 214 | + { | |
| 215 | + splitRepo.Add(new ExpenseSplit | |
| 216 | + { | |
| 217 | + ExpenseId = expense.Id, | |
| 218 | + UserId = s.UserId, | |
| 219 | + Amount = s.Amount, | |
| 220 | + Percentage = s.Percentage | |
| 221 | + }); | |
| 222 | + } | |
| 223 | + | |
| 224 | + await _uow.SaveChangesAsync(); | |
| 225 | + return (true, null); | |
| 226 | + } | |
| 227 | + | |
| 228 | + public async Task<(bool success, string? errorCode)> UpdateExpenseAsync(Guid id, ExpenseBllDto incoming, Guid userId) | |
| 229 | + { | |
| 230 | + var existing = await _uow.Expenses.GetByIdAsync(id); | |
| 231 | + if (existing == null) return (false, "notfound"); | |
| 232 | + | |
| 233 | + if (!await CanEditExpenseAsync(ExpenseBllDtoFactory.Create(existing), userId)) | |
| 234 | + return (false, "forbidden"); | |
| 235 | + | |
| 236 | + var trip = await _uow.Trips.GetByIdAsync(existing.TripId); | |
| 237 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 238 | + return (false, "badstatus"); | |
| 239 | + | |
| 240 | + existing.Amount = incoming.Amount; | |
| 241 | + existing.Description = incoming.Description; | |
| 242 | + existing.ExpenseDate = incoming.ExpenseDate; | |
| 243 | + existing.SplitMethod = incoming.SplitMethod; | |
| 244 | + existing.BudgetCategoryId = incoming.BudgetCategoryId; | |
| 245 | + existing.CurrencyId = incoming.CurrencyId; | |
| 246 | + existing.PaidByUserId = incoming.PaidByUserId; | |
| 247 | + | |
| 248 | + _uow.Expenses.Update(existing); | |
| 249 | + | |
| 250 | + if (incoming.SplitMethod == ESplitMethod.EqualAll) | |
| 251 | + { | |
| 252 | + var splitRepo = _uow.GetRepository<ExpenseSplit>(); | |
| 253 | + var expenseWithSplits = await _uow.Expenses.GetByIdWithDetailsAsync(id); | |
| 254 | + if (expenseWithSplits?.Splits != null) | |
| 255 | + { | |
| 256 | + foreach (var oldSplit in expenseWithSplits.Splits.ToList()) | |
| 257 | + { | |
| 258 | + await splitRepo.RemoveAsync(oldSplit.Id); | |
| 259 | + } | |
| 260 | + } | |
| 261 | + | |
| 262 | + var participants = (await _uow.TripParticipants.GetByTripIdAsync(existing.TripId)).ToList(); | |
| 263 | + | |
| 264 | + var count = participants.Count; | |
| 265 | + if (count > 0) | |
| 266 | + { | |
| 267 | + var baseAmount = Math.Floor(incoming.Amount / count * 100) / 100; | |
| 268 | + var remainder = incoming.Amount - baseAmount * count; | |
| 269 | + | |
| 270 | + for (var i = 0; i < participants.Count; i++) | |
| 271 | + { | |
| 272 | + var amountPortion = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0); | |
| 273 | + splitRepo.Add(new ExpenseSplit | |
| 274 | + { | |
| 275 | + Id = Guid.NewGuid(), | |
| 276 | + ExpenseId = id, | |
| 277 | + UserId = participants[i].UserId, | |
| 278 | + Amount = amountPortion | |
| 279 | + }); | |
| 280 | + } | |
| 281 | + } | |
| 282 | + } | |
| 283 | + | |
| 284 | + await _uow.SaveChangesAsync(); | |
| 285 | + return (true, null); | |
| 286 | + } | |
| 287 | + | |
| 288 | + public async Task<List<SplitPresetBllDto>> GetSplitPresetsByTripAsync(Guid tripId, Guid userId) | |
| 289 | + { | |
| 290 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 291 | + return new List<SplitPresetBllDto>(); | |
| 292 | + var presets = await _uow.SplitPresets.GetByTripIdAsync(tripId); | |
| 293 | + return SplitPresetBllDtoFactory.CreateList(presets, includeMembers: true); | |
| 294 | + } | |
| 295 | + | |
| 296 | + public async Task<bool> SavePresetAsync(Guid tripId, string presetName, ESplitMethod splitMethod, | |
| 297 | + Guid[] selectedParticipants, decimal[] splitPercentages, Guid userId) | |
| 298 | + { | |
| 299 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return false; | |
| 300 | + | |
| 301 | + if (string.IsNullOrWhiteSpace(presetName) || selectedParticipants.Length == 0) | |
| 302 | + return false; | |
| 303 | + | |
| 304 | + var preset = new SplitPreset | |
| 305 | + { | |
| 306 | + TripId = tripId, | |
| 307 | + Name = presetName, | |
| 308 | + SplitMethod = splitMethod, | |
| 309 | + CreatedById = userId | |
| 310 | + }; | |
| 311 | + _uow.SplitPresets.Add(preset); | |
| 312 | + | |
| 313 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 314 | + for (var i = 0; i < selectedParticipants.Length; i++) | |
| 315 | + { | |
| 316 | + memberRepo.Add(new SplitPresetMember | |
| 317 | + { | |
| 318 | + SplitPresetId = preset.Id, | |
| 319 | + UserId = selectedParticipants[i], | |
| 320 | + Percentage = splitPercentages.Length > i ? splitPercentages[i] : null | |
| 321 | + }); | |
| 322 | + } | |
| 323 | + | |
| 324 | + await _uow.SaveChangesAsync(); | |
| 325 | + return true; | |
| 326 | + } | |
| 327 | + | |
| 328 | + public async Task<bool> DeletePresetAsync(Guid presetId, Guid tripId, Guid userId) | |
| 329 | + { | |
| 330 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return false; | |
| 331 | + | |
| 332 | + var presets = (await _uow.SplitPresets.GetByTripIdAsync(tripId)).ToList(); | |
| 333 | + var preset = presets.FirstOrDefault(p => p.Id == presetId); | |
| 334 | + if (preset == null) return false; | |
| 335 | + | |
| 336 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 337 | + if (preset.Members != null) | |
| 338 | + { | |
| 339 | + foreach (var member in preset.Members.ToList()) | |
| 340 | + { | |
| 341 | + await memberRepo.RemoveAsync(member.Id); | |
| 342 | + } | |
| 343 | + } | |
| 344 | + await _uow.SplitPresets.RemoveAsync(presetId); | |
| 345 | + await _uow.SaveChangesAsync(); | |
| 346 | + return true; | |
| 347 | + } | |
| 348 | +} |
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 +44 −0
| @@ -0,0 +1,44 @@ | ||
| 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 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 7 | + | |
| 8 | +namespace SplitApp.WebApp.Application.Services; | |
| 9 | + | |
| 10 | +public interface IExpenseService | |
| 11 | +{ | |
| 12 | + Task<ExpenseBllDto> CreateExpenseWithSplitsAsync(ExpenseBllDto expense, Guid[] participants, decimal[] amounts, decimal[] percentages); | |
| 13 | + Task DeleteExpenseWithSplitsAsync(Guid expenseId); | |
| 14 | + | |
| 15 | + // Queries (IDOR-aware) | |
| 16 | + Task<List<ExpenseBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only | |
| 17 | + Task<ExpenseBllDto?> GetByIdAsync(Guid expenseId, Guid userId); // participant-only | |
| 18 | + Task<ExpenseBllDto?> GetByIdWithDetailsAsync(Guid expenseId, Guid userId); // participant-only | |
| 19 | + Task<ExpenseBllDto?> GetRawByIdAsync(Guid expenseId); // no IDOR, raw entity | |
| 20 | + | |
| 21 | + // Edit authorization | |
| 22 | + Task<bool> CanEditExpenseAsync(ExpenseBllDto expense, Guid userId); | |
| 23 | + | |
| 24 | + // API-level full update (re-populates splits from explicit DTO data) | |
| 25 | + Task<(bool success, string? errorCode)> UpdateExpenseWithSplitsFromDtoAsync( | |
| 26 | + Guid id, | |
| 27 | + decimal amount, | |
| 28 | + string? description, | |
| 29 | + DateTime expenseDate, | |
| 30 | + ESplitMethod splitMethod, | |
| 31 | + Guid? budgetCategoryId, | |
| 32 | + Guid? currencyId, | |
| 33 | + List<(Guid UserId, decimal Amount, decimal? Percentage)> splits, | |
| 34 | + Guid userId); | |
| 35 | + | |
| 36 | + // MVC-level partial update (re-splits for EqualAll only) | |
| 37 | + Task<(bool success, string? errorCode)> UpdateExpenseAsync(Guid id, ExpenseBllDto incoming, Guid userId); | |
| 38 | + | |
| 39 | + // Split presets (used by MVC Expenses controller dropdowns) | |
| 40 | + Task<List<SplitPresetBllDto>> GetSplitPresetsByTripAsync(Guid tripId, Guid userId); | |
| 41 | + Task<bool> SavePresetAsync(Guid tripId, string presetName, ESplitMethod splitMethod, | |
| 42 | + Guid[] selectedParticipants, decimal[] splitPercentages, Guid userId); | |
| 43 | + Task<bool> DeletePresetAsync(Guid presetId, Guid tripId, Guid userId); | |
| 44 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/IInvitationService.cs +30 −0
| @@ -0,0 +1,30 @@ | ||
| 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 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 7 | + | |
| 8 | +namespace SplitApp.WebApp.Application.Services; | |
| 9 | + | |
| 10 | +public interface IInvitationService | |
| 11 | +{ | |
| 12 | + Task<TripInvitationBllDto> CreateInvitationAsync(Guid tripId, Guid userId); | |
| 13 | + Task<bool> AcceptInvitationAsync(string token, Guid userId); | |
| 14 | + | |
| 15 | + // Guarded variant: also checks organizer | |
| 16 | + Task<(TripInvitationBllDto? invitation, string? errorCode)> CreateInvitationGuardedAsync(Guid tripId, Guid userId); | |
| 17 | + | |
| 18 | + // Lookups | |
| 19 | + Task<TripInvitationBllDto?> GetByIdAsync(Guid id); | |
| 20 | + Task<TripInvitationBllDto?> GetByTokenAsync(string token); | |
| 21 | + Task<List<TripInvitationBllDto>> GetPendingByTripIdAsync(Guid tripId, Guid userId); | |
| 22 | + | |
| 23 | + // Accept: full guarded flow (API) | |
| 24 | + Task<(bool success, string? errorCode)> AcceptInvitationGuardedAsync(string token, Guid userId); | |
| 25 | + | |
| 26 | + // Revoke & Decline | |
| 27 | + Task<(bool success, string? errorCode)> RevokeInvitationAsync(Guid invitationId, Guid tripId, Guid userId); | |
| 28 | + Task<(bool success, string? errorCode)> RevokeInvitationByTokenAsync(string token, Guid userId); | |
| 29 | + Task<(bool success, string? errorCode)> DeclineInvitationAsync(string token); | |
| 30 | +} |
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 +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 7 | + | |
| 8 | +namespace SplitApp.WebApp.Application.Services; | |
| 9 | + | |
| 10 | +public interface ISettlementService | |
| 11 | +{ | |
| 12 | + Task<List<BalanceEntry>> CalculateBalancesAsync(Guid tripId); | |
| 13 | + Task<SettlementPlanBllDto?> CalculateSettlementAsync(Guid tripId, Guid userId); | |
| 14 | + List<PreviewPayment> PreviewSettlement(List<BalanceEntry> balances); | |
| 15 | + Task MarkPaidAsync(Guid paymentId, Guid userId); | |
| 16 | + Task ConfirmPaymentAsync(Guid paymentId, Guid userId); | |
| 17 | + | |
| 18 | + // IDOR-protected queries | |
| 19 | + Task<List<BalanceEntry>> GetBalancesAsync(Guid tripId, Guid userId); // participant-only, empty if forbidden | |
| 20 | + Task<SettlementPlanBllDto?> GetLatestPlanAsync(Guid tripId, Guid userId); // participant-only | |
| 21 | + Task<SettlementPlanBllDto?> GetLatestPlanRawAsync(Guid tripId); // no IDOR (used internally after IDOR checked) | |
| 22 | + | |
| 23 | + // Payment lookups | |
| 24 | + Task<SettlementPaymentBllDto?> GetPaymentByIdAsync(Guid paymentId); | |
| 25 | + Task<SettlementPlanBllDto?> GetPlanByIdAsync(Guid planId); | |
| 26 | + | |
| 27 | + // Guarded mark/confirm that also validate trip participation | |
| 28 | + Task<(bool success, string? errorCode)> MarkPaidGuardedAsync(Guid paymentId, Guid userId); | |
| 29 | + Task<(bool success, string? errorCode)> ConfirmPaymentGuardedAsync(Guid paymentId, Guid userId); | |
| 30 | +} | |
| 31 | + | |
| 32 | +public class BalanceEntry | |
| 33 | +{ | |
| 34 | + public Guid UserId { get; set; } | |
| 35 | + public string UserName { get; set; } = default!; | |
| 36 | + public decimal TotalPaid { get; set; } | |
| 37 | + public decimal TotalOwed { get; set; } | |
| 38 | + public decimal NetBalance => TotalPaid - TotalOwed; | |
| 39 | +} | |
| 40 | + | |
| 41 | +public class PreviewPayment | |
| 42 | +{ | |
| 43 | + public string FromUserName { get; set; } = default!; | |
| 44 | + public string ToUserName { get; set; } = default!; | |
| 45 | + public decimal Amount { get; set; } | |
| 46 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/ISplitPresetService.cs +20 −0
| @@ -0,0 +1,20 @@ | ||
| 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 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 7 | + | |
| 8 | +namespace SplitApp.WebApp.Application.Services; | |
| 9 | + | |
| 10 | +public interface ISplitPresetService | |
| 11 | +{ | |
| 12 | + Task<List<SplitPresetBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only | |
| 13 | + Task<SplitPresetBllDto?> GetByIdAsync(Guid id, Guid userId); // participant-only | |
| 14 | + | |
| 15 | + Task<(SplitPresetBllDto? preset, string? errorCode)> CreateAsync(SplitPresetBllDto preset, | |
| 16 | + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId); // participant | |
| 17 | + Task<(bool success, string? errorCode)> UpdateAsync(Guid id, string name, ESplitMethod splitMethod, | |
| 18 | + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId); // participant | |
| 19 | + Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId); // participant | |
| 20 | +} |
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/Identity/IIdentityService.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +namespace SplitApp.WebApp.Application.Services.Identity; | |
| 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/SplitApp.WebApp/Application/Services/Identity/IdentityService.cs +266 −0
| @@ -0,0 +1,266 @@ | ||
| 1 | +using System.IdentityModel.Tokens.Jwt; | |
| 2 | +using System.Security.Claims; | |
| 3 | +using SplitApp.WebApp.Application.Contracts; | |
| 4 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 5 | +using SplitApp.Shared.Kernel.Auth; | |
| 6 | +using Microsoft.AspNetCore.Identity; | |
| 7 | +using Microsoft.Extensions.Configuration; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Application.Services.Identity; | |
| 10 | + | |
| 11 | +public class IdentityService : IIdentityService | |
| 12 | +{ | |
| 13 | + private readonly IAppUnitOfWork _uow; | |
| 14 | + private readonly UserManager<AppUser> _userManager; | |
| 15 | + private readonly IConfiguration _configuration; | |
| 16 | + | |
| 17 | + public IdentityService( | |
| 18 | + IAppUnitOfWork uow, | |
| 19 | + UserManager<AppUser> userManager, | |
| 20 | + IConfiguration configuration) | |
| 21 | + { | |
| 22 | + _uow = uow; | |
| 23 | + _userManager = userManager; | |
| 24 | + _configuration = configuration; | |
| 25 | + } | |
| 26 | + | |
| 27 | + public async Task<IdentityServiceResult> RegisterAsync(RegisterRequest request) | |
| 28 | + { | |
| 29 | + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds); | |
| 30 | + | |
| 31 | + var existing = await _userManager.FindByEmailAsync(request.Email); | |
| 32 | + if (existing != null) | |
| 33 | + { | |
| 34 | + return IdentityServiceResult.Fail( | |
| 35 | + $"User with email {request.Email} is already registered", | |
| 36 | + IdentityServiceErrorKind.BadRequest); | |
| 37 | + } | |
| 38 | + | |
| 39 | + var refreshToken = new AppRefreshToken(); | |
| 40 | + var appUser = new AppUser | |
| 41 | + { | |
| 42 | + Email = request.Email, | |
| 43 | + UserName = request.Email, | |
| 44 | + FirstName = request.FirstName, | |
| 45 | + LastName = request.LastName, | |
| 46 | + RefreshTokens = new List<AppRefreshToken> { refreshToken } | |
| 47 | + }; | |
| 48 | + refreshToken.AppUser = appUser; | |
| 49 | + | |
| 50 | + var createResult = await _userManager.CreateAsync(appUser, request.Password); | |
| 51 | + if (!createResult.Succeeded) | |
| 52 | + { | |
| 53 | + return IdentityServiceResult.Fail( | |
| 54 | + createResult.Errors.First().Description, | |
| 55 | + IdentityServiceErrorKind.BadRequest); | |
| 56 | + } | |
| 57 | + | |
| 58 | + await _userManager.AddToRoleAsync(appUser, "user"); | |
| 59 | + | |
| 60 | + var claimsResult = await _userManager.AddClaimsAsync(appUser, new List<Claim> | |
| 61 | + { | |
| 62 | + new(ClaimTypes.GivenName, appUser.FirstName), | |
| 63 | + new(ClaimTypes.Surname, appUser.LastName) | |
| 64 | + }); | |
| 65 | + if (!claimsResult.Succeeded) | |
| 66 | + { | |
| 67 | + return IdentityServiceResult.Fail( | |
| 68 | + claimsResult.Errors.First().Description, | |
| 69 | + IdentityServiceErrorKind.BadRequest); | |
| 70 | + } | |
| 71 | + | |
| 72 | + var reloaded = await _userManager.FindByEmailAsync(appUser.Email); | |
| 73 | + if (reloaded == null) | |
| 74 | + { | |
| 75 | + return IdentityServiceResult.Fail( | |
| 76 | + $"User with email {request.Email} is not found after registration", | |
| 77 | + IdentityServiceErrorKind.BadRequest); | |
| 78 | + } | |
| 79 | + | |
| 80 | + var jwt = await GenerateJwtAsync(reloaded, expiresInSeconds); | |
| 81 | + | |
| 82 | + return IdentityServiceResult.Ok(new IdentityJwtPayload | |
| 83 | + { | |
| 84 | + Jwt = jwt, | |
| 85 | + RefreshToken = refreshToken.RefreshToken, | |
| 86 | + FirstName = reloaded.FirstName, | |
| 87 | + LastName = reloaded.LastName | |
| 88 | + }); | |
| 89 | + } | |
| 90 | + | |
| 91 | + public async Task<IdentityServiceResult> LoginAsync(LoginRequest request) | |
| 92 | + { | |
| 93 | + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds); | |
| 94 | + | |
| 95 | + var appUser = await _userManager.FindByEmailAsync(request.Email); | |
| 96 | + if (appUser == null) | |
| 97 | + { | |
| 98 | + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound); | |
| 99 | + } | |
| 100 | + | |
| 101 | + var passwordOk = await _userManager.CheckPasswordAsync(appUser, request.Password); | |
| 102 | + if (!passwordOk) | |
| 103 | + { | |
| 104 | + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound); | |
| 105 | + } | |
| 106 | + | |
| 107 | + await _uow.RefreshTokens.RemoveExpiredForUserAsync(appUser.Id); | |
| 108 | + | |
| 109 | + var refreshToken = new AppRefreshToken | |
| 110 | + { | |
| 111 | + AppUserId = appUser.Id | |
| 112 | + }; | |
| 113 | + _uow.RefreshTokens.Add(refreshToken); | |
| 114 | + await _uow.SaveChangesAsync(); | |
| 115 | + | |
| 116 | + var jwt = await GenerateJwtAsync(appUser, expiresInSeconds); | |
| 117 | + | |
| 118 | + return IdentityServiceResult.Ok(new IdentityJwtPayload | |
| 119 | + { | |
| 120 | + Jwt = jwt, | |
| 121 | + RefreshToken = refreshToken.RefreshToken, | |
| 122 | + FirstName = appUser.FirstName, | |
| 123 | + LastName = appUser.LastName | |
| 124 | + }); | |
| 125 | + } | |
| 126 | + | |
| 127 | + public async Task<IdentityServiceResult> RefreshTokenAsync(RefreshRequest request) | |
| 128 | + { | |
| 129 | + var expiresInSeconds = ResolveExpiresInSeconds(request.ExpiresInSeconds); | |
| 130 | + | |
| 131 | + JwtSecurityToken? jwt; | |
| 132 | + try | |
| 133 | + { | |
| 134 | + jwt = new JwtSecurityTokenHandler().ReadJwtToken(request.Jwt); | |
| 135 | + } | |
| 136 | + catch (Exception) | |
| 137 | + { | |
| 138 | + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest); | |
| 139 | + } | |
| 140 | + | |
| 141 | + if (jwt == null) | |
| 142 | + { | |
| 143 | + return IdentityServiceResult.Fail("No token", IdentityServiceErrorKind.BadRequest); | |
| 144 | + } | |
| 145 | + | |
| 146 | + if (!IdentityHelpers.ValidateJWT( | |
| 147 | + request.Jwt, | |
| 148 | + _configuration.GetValue<string>("JWT:Key")!, | |
| 149 | + _configuration.GetValue<string>("JWT:Issuer")!, | |
| 150 | + _configuration.GetValue<string>("JWT:Audience")!)) | |
| 151 | + { | |
| 152 | + return IdentityServiceResult.Fail("JWT validation fail", IdentityServiceErrorKind.BadRequest); | |
| 153 | + } | |
| 154 | + | |
| 155 | + var userEmail = jwt.Claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value; | |
| 156 | + if (userEmail == null) | |
| 157 | + { | |
| 158 | + return IdentityServiceResult.Fail("No email in jwt", IdentityServiceErrorKind.BadRequest); | |
| 159 | + } | |
| 160 | + | |
| 161 | + var appUser = await _userManager.FindByEmailAsync(userEmail); | |
| 162 | + if (appUser == null) | |
| 163 | + { | |
| 164 | + return IdentityServiceResult.Fail($"User with email {userEmail} not found", IdentityServiceErrorKind.NotFound); | |
| 165 | + } | |
| 166 | + | |
| 167 | + var matchingTokens = (await _uow.RefreshTokens | |
| 168 | + .GetUserActiveTokensAsync(appUser.Id, request.RefreshToken)).ToList(); | |
| 169 | + | |
| 170 | + if (matchingTokens.Count == 0) | |
| 171 | + { | |
| 172 | + return IdentityServiceResult.Fail( | |
| 173 | + "RefreshTokens collection is null or empty - 0", | |
| 174 | + IdentityServiceErrorKind.NotFound); | |
| 175 | + } | |
| 176 | + | |
| 177 | + if (matchingTokens.Count != 1) | |
| 178 | + { | |
| 179 | + return IdentityServiceResult.Fail( | |
| 180 | + "More than one valid refresh token found", | |
| 181 | + IdentityServiceErrorKind.NotFound); | |
| 182 | + } | |
| 183 | + | |
| 184 | + var refreshToken = matchingTokens.First(); | |
| 185 | + if (refreshToken.RefreshToken == request.RefreshToken) | |
| 186 | + { | |
| 187 | + refreshToken.PreviousRefreshToken = refreshToken.RefreshToken; | |
| 188 | + refreshToken.PreviousExpirationDT = DateTime.UtcNow.AddMinutes(1); | |
| 189 | + refreshToken.RefreshToken = Guid.NewGuid().ToString(); | |
| 190 | + refreshToken.ExpirationDT = DateTime.UtcNow.AddDays(7); | |
| 191 | + _uow.RefreshTokens.Update(refreshToken); | |
| 192 | + await _uow.SaveChangesAsync(); | |
| 193 | + } | |
| 194 | + | |
| 195 | + var newJwt = await GenerateJwtAsync(appUser, expiresInSeconds); | |
| 196 | + | |
| 197 | + return IdentityServiceResult.Ok(new IdentityJwtPayload | |
| 198 | + { | |
| 199 | + Jwt = newJwt, | |
| 200 | + RefreshToken = refreshToken.RefreshToken, | |
| 201 | + FirstName = appUser.FirstName, | |
| 202 | + LastName = appUser.LastName | |
| 203 | + }); | |
| 204 | + } | |
| 205 | + | |
| 206 | + public async Task<IdentityServiceResult> LogoutAsync(LogoutRequest request) | |
| 207 | + { | |
| 208 | + var appUser = await _uow.Users.GetByIdAsync(request.UserId); | |
| 209 | + if (appUser == null) | |
| 210 | + { | |
| 211 | + return IdentityServiceResult.Fail("User/Password problem", IdentityServiceErrorKind.NotFound); | |
| 212 | + } | |
| 213 | + | |
| 214 | + var tokens = (await _uow.RefreshTokens | |
| 215 | + .GetUserTokensByValueAsync(request.UserId, request.RefreshToken)).ToList(); | |
| 216 | + | |
| 217 | + foreach (var token in tokens) | |
| 218 | + { | |
| 219 | + _uow.RefreshTokens.Remove(token); | |
| 220 | + } | |
| 221 | + | |
| 222 | + var deleteCount = await _uow.SaveChangesAsync(); | |
| 223 | + return IdentityServiceResult.Logout(deleteCount); | |
| 224 | + } | |
| 225 | + | |
| 226 | + private int ResolveExpiresInSeconds(int requested) | |
| 227 | + { | |
| 228 | + if (requested <= 0) requested = int.MaxValue; | |
| 229 | + var configured = _configuration.GetValue<int>("JWT:ExpiresInSeconds"); | |
| 230 | + return requested < configured ? requested : configured; | |
| 231 | + } | |
| 232 | + | |
| 233 | + private async Task<string> GenerateJwtAsync(AppUser user, int expiresInSeconds) | |
| 234 | + { | |
| 235 | + var claims = new List<Claim> | |
| 236 | + { | |
| 237 | + new(ClaimTypes.NameIdentifier, user.Id.ToString()), | |
| 238 | + new(ClaimTypes.Email, user.Email ?? ""), | |
| 239 | + new(ClaimTypes.Name, user.UserName ?? user.Email ?? ""), | |
| 240 | + new(ClaimTypes.GivenName, user.FirstName), | |
| 241 | + new(ClaimTypes.Surname, user.LastName) | |
| 242 | + }; | |
| 243 | + | |
| 244 | + var userClaims = await _userManager.GetClaimsAsync(user); | |
| 245 | + foreach (var c in userClaims) | |
| 246 | + { | |
| 247 | + if (!claims.Any(existing => existing.Type == c.Type && existing.Value == c.Value)) | |
| 248 | + { | |
| 249 | + claims.Add(c); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + | |
| 253 | + var roles = await _userManager.GetRolesAsync(user); | |
| 254 | + foreach (var role in roles) | |
| 255 | + { | |
| 256 | + claims.Add(new Claim(ClaimTypes.Role, role)); | |
| 257 | + } | |
| 258 | + | |
| 259 | + return IdentityHelpers.GenerateJwt( | |
| 260 | + claims, | |
| 261 | + _configuration.GetValue<string>("JWT:Key")!, | |
| 262 | + _configuration.GetValue<string>("JWT:Issuer")!, | |
| 263 | + _configuration.GetValue<string>("JWT:Audience")!, | |
| 264 | + expiresInSeconds); | |
| 265 | + } | |
| 266 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/Identity/IdentityServiceResult.cs +63 −0
| @@ -0,0 +1,63 @@ | ||
| 1 | +namespace SplitApp.WebApp.Application.Services.Identity; | |
| 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/SplitApp.WebApp/Application/Services/InvitationService.cs +193 −0
| @@ -0,0 +1,193 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 9 | +using SplitApp.WebApp.Application.Contracts; | |
| 10 | + | |
| 11 | +namespace SplitApp.WebApp.Application.Services; | |
| 12 | + | |
| 13 | +public class InvitationService : IInvitationService | |
| 14 | +{ | |
| 15 | + private readonly IAppUnitOfWork _uow; | |
| 16 | + | |
| 17 | + public InvitationService(IAppUnitOfWork uow) | |
| 18 | + { | |
| 19 | + _uow = uow; | |
| 20 | + } | |
| 21 | + | |
| 22 | + public async Task<TripInvitationBllDto> CreateInvitationAsync(Guid tripId, Guid userId) | |
| 23 | + { | |
| 24 | + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) | |
| 25 | + .Replace("+", "-").Replace("/", "_").TrimEnd('='); | |
| 26 | + | |
| 27 | + var invitation = new TripInvitation | |
| 28 | + { | |
| 29 | + Id = Guid.NewGuid(), | |
| 30 | + TripId = tripId, | |
| 31 | + InvitedByUserId = userId, | |
| 32 | + Token = token, | |
| 33 | + Status = EInvitationStatus.Pending, | |
| 34 | + ExpiresAt = DateTime.UtcNow.AddDays(7) | |
| 35 | + }; | |
| 36 | + | |
| 37 | + _uow.TripInvitations.Add(invitation); | |
| 38 | + await _uow.SaveChangesAsync(); | |
| 39 | + | |
| 40 | + return InvitationBllDtoFactory.Create(invitation); | |
| 41 | + } | |
| 42 | + | |
| 43 | + public async Task<bool> AcceptInvitationAsync(string token, Guid userId) | |
| 44 | + { | |
| 45 | + var invitation = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 46 | + if (invitation == null) return false; | |
| 47 | + | |
| 48 | + if (invitation.Status != EInvitationStatus.Pending || invitation.ExpiresAt < DateTime.UtcNow) | |
| 49 | + return false; | |
| 50 | + | |
| 51 | + // Check if already a participant | |
| 52 | + var participants = (await _uow.TripParticipants.GetByTripIdAsync(invitation.TripId)).ToList(); | |
| 53 | + var existingParticipant = participants.FirstOrDefault(tp => tp.UserId == userId); | |
| 54 | + | |
| 55 | + var allParticipants = await _uow.TripParticipants.GetAllAsync(); | |
| 56 | + var inactiveParticipant = allParticipants | |
| 57 | + .FirstOrDefault(tp => tp.TripId == invitation.TripId && tp.UserId == userId && !tp.IsActive); | |
| 58 | + | |
| 59 | + if (existingParticipant != null) | |
| 60 | + { | |
| 61 | + // Already an active participant | |
| 62 | + } | |
| 63 | + else if (inactiveParticipant != null) | |
| 64 | + { | |
| 65 | + inactiveParticipant.IsActive = true; | |
| 66 | + inactiveParticipant.LeftAt = null; | |
| 67 | + _uow.TripParticipants.Update(inactiveParticipant); | |
| 68 | + } | |
| 69 | + else | |
| 70 | + { | |
| 71 | + var participant = new TripParticipant | |
| 72 | + { | |
| 73 | + Id = Guid.NewGuid(), | |
| 74 | + TripId = invitation.TripId, | |
| 75 | + UserId = userId, | |
| 76 | + Role = EParticipantRole.Participant, | |
| 77 | + JoinedAt = DateTime.UtcNow, | |
| 78 | + IsActive = true | |
| 79 | + }; | |
| 80 | + _uow.TripParticipants.Add(participant); | |
| 81 | + } | |
| 82 | + | |
| 83 | + invitation.Status = EInvitationStatus.Accepted; | |
| 84 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 85 | + _uow.TripInvitations.Update(invitation); | |
| 86 | + | |
| 87 | + await _uow.SaveChangesAsync(); | |
| 88 | + | |
| 89 | + return true; | |
| 90 | + } | |
| 91 | + | |
| 92 | + public async Task<(TripInvitationBllDto? invitation, string? errorCode)> CreateInvitationGuardedAsync(Guid tripId, Guid userId) | |
| 93 | + { | |
| 94 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) | |
| 95 | + return (null, "forbidden"); | |
| 96 | + var invitation = await CreateInvitationAsync(tripId, userId); | |
| 97 | + return (invitation, null); | |
| 98 | + } | |
| 99 | + | |
| 100 | + public async Task<TripInvitationBllDto?> GetByIdAsync(Guid id) | |
| 101 | + { | |
| 102 | + var entity = await _uow.TripInvitations.GetByIdAsync(id); | |
| 103 | + return entity == null ? null : InvitationBllDtoFactory.Create(entity); | |
| 104 | + } | |
| 105 | + | |
| 106 | + public async Task<TripInvitationBllDto?> GetByTokenAsync(string token) | |
| 107 | + { | |
| 108 | + var entity = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 109 | + return entity == null ? null : InvitationBllDtoFactory.Create(entity); | |
| 110 | + } | |
| 111 | + | |
| 112 | + public async Task<List<TripInvitationBllDto>> GetPendingByTripIdAsync(Guid tripId, Guid userId) | |
| 113 | + { | |
| 114 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 115 | + return new List<TripInvitationBllDto>(); | |
| 116 | + var invitations = await _uow.TripInvitations.GetPendingByTripIdAsync(tripId); | |
| 117 | + return InvitationBllDtoFactory.CreateList(invitations); | |
| 118 | + } | |
| 119 | + | |
| 120 | + public async Task<(bool success, string? errorCode)> AcceptInvitationGuardedAsync(string token, Guid userId) | |
| 121 | + { | |
| 122 | + var invitation = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 123 | + if (invitation == null) return (false, "notfound"); | |
| 124 | + | |
| 125 | + if (invitation.Status != EInvitationStatus.Pending) | |
| 126 | + return (false, "not-pending"); | |
| 127 | + | |
| 128 | + if (invitation.ExpiresAt < DateTime.UtcNow) | |
| 129 | + { | |
| 130 | + invitation.Status = EInvitationStatus.Expired; | |
| 131 | + _uow.TripInvitations.Update(invitation); | |
| 132 | + await _uow.SaveChangesAsync(); | |
| 133 | + return (false, "expired"); | |
| 134 | + } | |
| 135 | + | |
| 136 | + if (await _uow.TripParticipants.IsParticipantAsync(invitation.TripId, userId)) | |
| 137 | + return (false, "already-participant"); | |
| 138 | + | |
| 139 | + var accepted = await AcceptInvitationAsync(token, userId); | |
| 140 | + if (!accepted) return (false, "failed"); | |
| 141 | + | |
| 142 | + return (true, null); | |
| 143 | + } | |
| 144 | + | |
| 145 | + public async Task<(bool success, string? errorCode)> RevokeInvitationAsync(Guid invitationId, Guid tripId, Guid userId) | |
| 146 | + { | |
| 147 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) | |
| 148 | + return (false, "forbidden"); | |
| 149 | + | |
| 150 | + var invitation = await _uow.TripInvitations.GetByIdAsync(invitationId); | |
| 151 | + if (invitation == null || invitation.TripId != tripId) return (false, "notfound"); | |
| 152 | + | |
| 153 | + if (invitation.Status == EInvitationStatus.Pending) | |
| 154 | + { | |
| 155 | + invitation.Status = EInvitationStatus.Revoked; | |
| 156 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 157 | + _uow.TripInvitations.Update(invitation); | |
| 158 | + await _uow.SaveChangesAsync(); | |
| 159 | + } | |
| 160 | + | |
| 161 | + return (true, null); | |
| 162 | + } | |
| 163 | + | |
| 164 | + public async Task<(bool success, string? errorCode)> RevokeInvitationByTokenAsync(string token, Guid userId) | |
| 165 | + { | |
| 166 | + var invitation = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 167 | + if (invitation == null) return (false, "notfound"); | |
| 168 | + | |
| 169 | + if (!await _uow.TripParticipants.IsOrganizerAsync(invitation.TripId, userId)) | |
| 170 | + return (false, "forbidden"); | |
| 171 | + | |
| 172 | + invitation.Status = EInvitationStatus.Revoked; | |
| 173 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 174 | + _uow.TripInvitations.Update(invitation); | |
| 175 | + await _uow.SaveChangesAsync(); | |
| 176 | + return (true, null); | |
| 177 | + } | |
| 178 | + | |
| 179 | + public async Task<(bool success, string? errorCode)> DeclineInvitationAsync(string token) | |
| 180 | + { | |
| 181 | + var invitation = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 182 | + if (invitation == null) return (false, "notfound"); | |
| 183 | + | |
| 184 | + if (invitation.Status != EInvitationStatus.Pending) | |
| 185 | + return (false, "not-pending"); | |
| 186 | + | |
| 187 | + invitation.Status = EInvitationStatus.Declined; | |
| 188 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 189 | + _uow.TripInvitations.Update(invitation); | |
| 190 | + await _uow.SaveChangesAsync(); | |
| 191 | + return (true, null); | |
| 192 | + } | |
| 193 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/PollService.cs +207 −0
| @@ -0,0 +1,207 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | + | |
| 10 | +namespace SplitApp.WebApp.Application.Services; | |
| 11 | + | |
| 12 | +public class PollService : IPollService | |
| 13 | +{ | |
| 14 | + private readonly IAppUnitOfWork _uow; | |
| 15 | + | |
| 16 | + public PollService(IAppUnitOfWork uow) | |
| 17 | + { | |
| 18 | + _uow = uow; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public async Task<TripPollBllDto> CreatePollWithOptionsAsync(TripPollBllDto poll, List<string> optionTexts) | |
| 22 | + { | |
| 23 | + var entity = PollBllDtoFactory.ToEntity(poll); | |
| 24 | + entity.Id = Guid.NewGuid(); | |
| 25 | + _uow.TripPolls.Add(entity); | |
| 26 | + | |
| 27 | + var optionRepo = _uow.GetRepository<TripPollOption>(); | |
| 28 | + var order = 0; | |
| 29 | + | |
| 30 | + foreach (var text in optionTexts.Where(t => !string.IsNullOrWhiteSpace(t))) | |
| 31 | + { | |
| 32 | + optionRepo.Add(new TripPollOption | |
| 33 | + { | |
| 34 | + Id = Guid.NewGuid(), | |
| 35 | + PollId = entity.Id, | |
| 36 | + Text = text, | |
| 37 | + DisplayOrder = order++ | |
| 38 | + }); | |
| 39 | + } | |
| 40 | + | |
| 41 | + await _uow.SaveChangesAsync(); | |
| 42 | + | |
| 43 | + return PollBllDtoFactory.Create(entity); | |
| 44 | + } | |
| 45 | + | |
| 46 | + public async Task ToggleVoteAsync(Guid pollId, Guid optionId, Guid userId) | |
| 47 | + { | |
| 48 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 49 | + if (poll == null) return; | |
| 50 | + | |
| 51 | + if (poll.ClosedAt != null) return; | |
| 52 | + | |
| 53 | + var option = poll.Options?.FirstOrDefault(o => o.Id == optionId); | |
| 54 | + if (option == null) return; | |
| 55 | + | |
| 56 | + var voteRepo = _uow.GetRepository<TripPollVote>(); | |
| 57 | + | |
| 58 | + if (!poll.AllowMultipleVotes) | |
| 59 | + { | |
| 60 | + if (poll.Options != null) | |
| 61 | + { | |
| 62 | + foreach (var opt in poll.Options) | |
| 63 | + { | |
| 64 | + if (opt.Votes != null) | |
| 65 | + { | |
| 66 | + foreach (var vote in opt.Votes.Where(v => v.UserId == userId).ToList()) | |
| 67 | + { | |
| 68 | + await voteRepo.RemoveAsync(vote.Id); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + var existingVote = option.Votes?.FirstOrDefault(v => v.UserId == userId); | |
| 76 | + | |
| 77 | + if (existingVote != null) | |
| 78 | + { | |
| 79 | + await voteRepo.RemoveAsync(existingVote.Id); | |
| 80 | + } | |
| 81 | + else | |
| 82 | + { | |
| 83 | + voteRepo.Add(new TripPollVote | |
| 84 | + { | |
| 85 | + Id = Guid.NewGuid(), | |
| 86 | + PollOptionId = optionId, | |
| 87 | + UserId = userId | |
| 88 | + }); | |
| 89 | + } | |
| 90 | + | |
| 91 | + await _uow.SaveChangesAsync(); | |
| 92 | + } | |
| 93 | + | |
| 94 | + public async Task<List<TripPollBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 95 | + { | |
| 96 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 97 | + return new List<TripPollBllDto>(); | |
| 98 | + var polls = await _uow.TripPolls.GetByTripIdAsync(tripId); | |
| 99 | + return PollBllDtoFactory.CreateList(polls, includeOptions: true); | |
| 100 | + } | |
| 101 | + | |
| 102 | + public async Task<TripPollBllDto?> GetByIdAsync(Guid pollId, Guid userId) | |
| 103 | + { | |
| 104 | + var poll = await _uow.TripPolls.GetByIdAsync(pollId); | |
| 105 | + if (poll == null) return null; | |
| 106 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) return null; | |
| 107 | + return PollBllDtoFactory.Create(poll); | |
| 108 | + } | |
| 109 | + | |
| 110 | + public async Task<TripPollBllDto?> GetByIdWithDetailsAsync(Guid pollId, Guid userId) | |
| 111 | + { | |
| 112 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 113 | + if (poll == null) return null; | |
| 114 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) return null; | |
| 115 | + return PollBllDtoFactory.Create(poll, includeOptions: true); | |
| 116 | + } | |
| 117 | + | |
| 118 | + public async Task<(TripPollBllDto? poll, string? errorCode)> CreatePollGuardedAsync(TripPollBllDto poll, List<string> optionTexts, Guid userId) | |
| 119 | + { | |
| 120 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) | |
| 121 | + return (null, "forbidden"); | |
| 122 | + var created = await CreatePollWithOptionsAsync(poll, optionTexts); | |
| 123 | + return (created, null); | |
| 124 | + } | |
| 125 | + | |
| 126 | + public async Task<(bool success, string? errorCode)> CastVoteAsync(Guid pollId, Guid optionId, Guid userId) | |
| 127 | + { | |
| 128 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 129 | + if (poll == null) return (false, "notfound"); | |
| 130 | + | |
| 131 | + if (poll.ClosedAt != null) return (false, "closed"); | |
| 132 | + | |
| 133 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) | |
| 134 | + return (false, "forbidden"); | |
| 135 | + | |
| 136 | + var option = poll.Options?.FirstOrDefault(o => o.Id == optionId); | |
| 137 | + if (option == null) return (false, "invalid-option"); | |
| 138 | + | |
| 139 | + await ToggleVoteAsync(pollId, optionId, userId); | |
| 140 | + return (true, null); | |
| 141 | + } | |
| 142 | + | |
| 143 | + public async Task<(bool success, string? errorCode)> ClosePollAsync(Guid pollId, Guid userId, bool organizerAllowed) | |
| 144 | + { | |
| 145 | + var poll = await _uow.TripPolls.GetByIdAsync(pollId); | |
| 146 | + if (poll == null) return (false, "notfound"); | |
| 147 | + | |
| 148 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) | |
| 149 | + return (false, "forbidden"); | |
| 150 | + | |
| 151 | + var isCreator = poll.CreatedByUserId == userId; | |
| 152 | + var isOrganizer = organizerAllowed && await _uow.TripParticipants.IsOrganizerAsync(poll.TripId, userId); | |
| 153 | + | |
| 154 | + if (!isCreator && !isOrganizer) return (false, "forbidden"); | |
| 155 | + | |
| 156 | + poll.ClosedAt = DateTime.UtcNow; | |
| 157 | + _uow.TripPolls.Update(poll); | |
| 158 | + await _uow.SaveChangesAsync(); | |
| 159 | + return (true, null); | |
| 160 | + } | |
| 161 | + | |
| 162 | + public async Task<(bool success, string? errorCode)> DeletePollGuardedAsync(Guid pollId, Guid userId) | |
| 163 | + { | |
| 164 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 165 | + if (poll == null) return (false, "notfound"); | |
| 166 | + | |
| 167 | + var isCreator = poll.CreatedByUserId == userId; | |
| 168 | + var isOrganizer = await _uow.TripParticipants.IsOrganizerAsync(poll.TripId, userId); | |
| 169 | + | |
| 170 | + if (!isCreator && !isOrganizer) return (false, "forbidden"); | |
| 171 | + | |
| 172 | + await DeletePollCascadeAsync(pollId); | |
| 173 | + return (true, null); | |
| 174 | + } | |
| 175 | + | |
| 176 | + public async Task DeletePollCascadeAsync(Guid pollId) | |
| 177 | + { | |
| 178 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 179 | + if (poll == null) return; | |
| 180 | + | |
| 181 | + var voteRepo = _uow.GetRepository<TripPollVote>(); | |
| 182 | + var optionRepo = _uow.GetRepository<TripPollOption>(); | |
| 183 | + | |
| 184 | + if (poll.Options != null) | |
| 185 | + { | |
| 186 | + foreach (var option in poll.Options) | |
| 187 | + { | |
| 188 | + if (option.Votes != null) | |
| 189 | + { | |
| 190 | + foreach (var vote in option.Votes.ToList()) | |
| 191 | + { | |
| 192 | + await voteRepo.RemoveAsync(vote.Id); | |
| 193 | + } | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 197 | + foreach (var option in poll.Options.ToList()) | |
| 198 | + { | |
| 199 | + await optionRepo.RemoveAsync(option.Id); | |
| 200 | + } | |
| 201 | + } | |
| 202 | + | |
| 203 | + await _uow.TripPolls.RemoveAsync(pollId); | |
| 204 | + | |
| 205 | + await _uow.SaveChangesAsync(); | |
| 206 | + } | |
| 207 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SettlementService.cs +340 −0
| @@ -0,0 +1,340 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 9 | +using SplitApp.WebApp.Application.Contracts; | |
| 10 | + | |
| 11 | +namespace SplitApp.WebApp.Application.Services; | |
| 12 | + | |
| 13 | +public class SettlementService : ISettlementService | |
| 14 | +{ | |
| 15 | + private readonly IAppUnitOfWork _uow; | |
| 16 | + | |
| 17 | + public SettlementService(IAppUnitOfWork uow) | |
| 18 | + { | |
| 19 | + _uow = uow; | |
| 20 | + } | |
| 21 | + | |
| 22 | + public async Task<List<BalanceEntry>> CalculateBalancesAsync(Guid tripId) | |
| 23 | + { | |
| 24 | + var trip = await _uow.Trips.GetByIdWithDetailsAsync(tripId); | |
| 25 | + if (trip == null) return new List<BalanceEntry>(); | |
| 26 | + | |
| 27 | + var defaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR"; | |
| 28 | + | |
| 29 | + var participants = (await _uow.TripParticipants.GetByTripIdAsync(tripId)).ToList(); | |
| 30 | + | |
| 31 | + var balances = new Dictionary<Guid, BalanceEntry>(); | |
| 32 | + foreach (var p in participants) | |
| 33 | + { | |
| 34 | + var fullName = p.User != null | |
| 35 | + ? $"{p.User.FirstName} {p.User.LastName}".Trim() | |
| 36 | + : ""; | |
| 37 | + balances[p.UserId] = new BalanceEntry | |
| 38 | + { | |
| 39 | + UserId = p.UserId, | |
| 40 | + UserName = !string.IsNullOrEmpty(fullName) | |
| 41 | + ? fullName | |
| 42 | + : (p.User?.Email ?? "Unknown"), | |
| 43 | + TotalPaid = 0, | |
| 44 | + TotalOwed = 0 | |
| 45 | + }; | |
| 46 | + } | |
| 47 | + | |
| 48 | + var expenses = (await _uow.Expenses.GetByTripIdAsync(tripId)).ToList(); | |
| 49 | + | |
| 50 | + // Need expenses with splits - fetch each with details | |
| 51 | + foreach (var expense in expenses) | |
| 52 | + { | |
| 53 | + var expenseWithSplits = await _uow.Expenses.GetByIdWithDetailsAsync(expense.Id); | |
| 54 | + if (expenseWithSplits == null) continue; | |
| 55 | + | |
| 56 | + var expCurrency = expenseWithSplits.Currency?.Code ?? defaultCurrencyCode; | |
| 57 | + var convertedAmount = CurrencyConverter.Convert(expenseWithSplits.Amount, expCurrency, defaultCurrencyCode); | |
| 58 | + | |
| 59 | + if (balances.ContainsKey(expenseWithSplits.PaidByUserId)) | |
| 60 | + { | |
| 61 | + balances[expenseWithSplits.PaidByUserId].TotalPaid += convertedAmount; | |
| 62 | + } | |
| 63 | + | |
| 64 | + if (expenseWithSplits.Splits != null) | |
| 65 | + { | |
| 66 | + foreach (var split in expenseWithSplits.Splits) | |
| 67 | + { | |
| 68 | + var convertedSplit = CurrencyConverter.Convert(split.Amount, expCurrency, defaultCurrencyCode); | |
| 69 | + if (balances.ContainsKey(split.UserId)) | |
| 70 | + { | |
| 71 | + balances[split.UserId].TotalOwed += convertedSplit; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + return balances.Values.OrderByDescending(b => b.NetBalance).ToList(); | |
| 78 | + } | |
| 79 | + | |
| 80 | + public async Task<SettlementPlanBllDto?> CalculateSettlementAsync(Guid tripId, Guid userId) | |
| 81 | + { | |
| 82 | + var balanceList = await CalculateBalancesAsync(tripId); | |
| 83 | + | |
| 84 | + var creditors = balanceList.Where(b => b.NetBalance > 0.01m) | |
| 85 | + .Select(b => new { b.UserId, Amount = b.NetBalance }) | |
| 86 | + .OrderByDescending(c => c.Amount) | |
| 87 | + .ToList(); | |
| 88 | + | |
| 89 | + var debtors = balanceList.Where(b => b.NetBalance < -0.01m) | |
| 90 | + .Select(b => new { b.UserId, Amount = -b.NetBalance }) | |
| 91 | + .OrderByDescending(d => d.Amount) | |
| 92 | + .ToList(); | |
| 93 | + | |
| 94 | + if (!creditors.Any() || !debtors.Any()) return null; | |
| 95 | + | |
| 96 | + var plan = new SettlementPlan | |
| 97 | + { | |
| 98 | + Id = Guid.NewGuid(), | |
| 99 | + TripId = tripId, | |
| 100 | + CreatedByUserId = userId, | |
| 101 | + TotalAmount = creditors.Sum(c => c.Amount), | |
| 102 | + Status = ESettlementStatus.Pending | |
| 103 | + }; | |
| 104 | + | |
| 105 | + _uow.SettlementPlans.Add(plan); | |
| 106 | + | |
| 107 | + var paymentRepo = _uow.GetRepository<SettlementPayment>(); | |
| 108 | + | |
| 109 | + var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount); | |
| 110 | + var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount); | |
| 111 | + var sortedCreditors = creditBalances.Keys.ToList(); | |
| 112 | + var sortedDebtors = debtBalances.Keys.ToList(); | |
| 113 | + var ci = 0; | |
| 114 | + var di = 0; | |
| 115 | + | |
| 116 | + while (ci < sortedCreditors.Count && di < sortedDebtors.Count) | |
| 117 | + { | |
| 118 | + var creditorId = sortedCreditors[ci]; | |
| 119 | + var debtorId = sortedDebtors[di]; | |
| 120 | + var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]); | |
| 121 | + | |
| 122 | + if (amount > 0.01m) | |
| 123 | + { | |
| 124 | + paymentRepo.Add(new SettlementPayment | |
| 125 | + { | |
| 126 | + Id = Guid.NewGuid(), | |
| 127 | + SettlementPlanId = plan.Id, | |
| 128 | + FromUserId = debtorId, | |
| 129 | + ToUserId = creditorId, | |
| 130 | + Amount = Math.Round(amount, 2), | |
| 131 | + Status = EPaymentStatus.Pending | |
| 132 | + }); | |
| 133 | + } | |
| 134 | + | |
| 135 | + creditBalances[creditorId] -= amount; | |
| 136 | + debtBalances[debtorId] -= amount; | |
| 137 | + if (creditBalances[creditorId] < 0.01m) ci++; | |
| 138 | + if (debtBalances[debtorId] < 0.01m) di++; | |
| 139 | + } | |
| 140 | + | |
| 141 | + await _uow.SaveChangesAsync(); | |
| 142 | + | |
| 143 | + // Return the plan with navigation properties loaded | |
| 144 | + var reloaded = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId); | |
| 145 | + return reloaded == null ? null : SettlementBllDtoFactory.Create(reloaded, includePayments: true); | |
| 146 | + } | |
| 147 | + | |
| 148 | + public List<PreviewPayment> PreviewSettlement(List<BalanceEntry> balanceList) | |
| 149 | + { | |
| 150 | + var result = new List<PreviewPayment>(); | |
| 151 | + | |
| 152 | + var creditors = balanceList.Where(b => b.NetBalance > 0.01m) | |
| 153 | + .Select(b => new { b.UserName, Amount = b.NetBalance }) | |
| 154 | + .OrderByDescending(c => c.Amount).ToList(); | |
| 155 | + | |
| 156 | + var debtors = balanceList.Where(b => b.NetBalance < -0.01m) | |
| 157 | + .Select(b => new { b.UserName, Amount = -b.NetBalance }) | |
| 158 | + .OrderByDescending(d => d.Amount).ToList(); | |
| 159 | + | |
| 160 | + if (!creditors.Any() || !debtors.Any()) return result; | |
| 161 | + | |
| 162 | + var creditBalances = creditors.ToDictionary(c => c.UserName, c => c.Amount); | |
| 163 | + var debtBalances = debtors.ToDictionary(d => d.UserName, d => d.Amount); | |
| 164 | + var sortedCreditors = creditBalances.Keys.ToList(); | |
| 165 | + var sortedDebtors = debtBalances.Keys.ToList(); | |
| 166 | + var ci = 0; | |
| 167 | + var di = 0; | |
| 168 | + | |
| 169 | + while (ci < sortedCreditors.Count && di < sortedDebtors.Count) | |
| 170 | + { | |
| 171 | + var creditor = sortedCreditors[ci]; | |
| 172 | + var debtor = sortedDebtors[di]; | |
| 173 | + var amount = Math.Min(creditBalances[creditor], debtBalances[debtor]); | |
| 174 | + | |
| 175 | + if (amount > 0.01m) | |
| 176 | + { | |
| 177 | + result.Add(new PreviewPayment | |
| 178 | + { | |
| 179 | + FromUserName = debtor, | |
| 180 | + ToUserName = creditor, | |
| 181 | + Amount = Math.Round(amount, 2) | |
| 182 | + }); | |
| 183 | + } | |
| 184 | + | |
| 185 | + creditBalances[creditor] -= amount; | |
| 186 | + debtBalances[debtor] -= amount; | |
| 187 | + if (creditBalances[creditor] < 0.01m) ci++; | |
| 188 | + if (debtBalances[debtor] < 0.01m) di++; | |
| 189 | + } | |
| 190 | + | |
| 191 | + return result; | |
| 192 | + } | |
| 193 | + | |
| 194 | + public async Task MarkPaidAsync(Guid paymentId, Guid userId) | |
| 195 | + { | |
| 196 | + var paymentRepo = _uow.GetRepository<SettlementPayment>(); | |
| 197 | + var payment = await paymentRepo.GetByIdAsync(paymentId); | |
| 198 | + if (payment == null) return; | |
| 199 | + | |
| 200 | + if (payment.FromUserId != userId) return; | |
| 201 | + | |
| 202 | + payment.Status = EPaymentStatus.MarkedPaid; | |
| 203 | + payment.MarkedPaidAt = DateTime.UtcNow; | |
| 204 | + | |
| 205 | + paymentRepo.Update(payment); | |
| 206 | + await _uow.SaveChangesAsync(); | |
| 207 | + } | |
| 208 | + | |
| 209 | + // --- New IDOR-protected helpers --- | |
| 210 | + | |
| 211 | + public async Task<List<BalanceEntry>> GetBalancesAsync(Guid tripId, Guid userId) | |
| 212 | + { | |
| 213 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 214 | + return new List<BalanceEntry>(); | |
| 215 | + return await CalculateBalancesAsync(tripId); | |
| 216 | + } | |
| 217 | + | |
| 218 | + public async Task<SettlementPlanBllDto?> GetLatestPlanAsync(Guid tripId, Guid userId) | |
| 219 | + { | |
| 220 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 221 | + return null; | |
| 222 | + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId); | |
| 223 | + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true); | |
| 224 | + } | |
| 225 | + | |
| 226 | + public async Task<SettlementPlanBllDto?> GetLatestPlanRawAsync(Guid tripId) | |
| 227 | + { | |
| 228 | + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId); | |
| 229 | + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true); | |
| 230 | + } | |
| 231 | + | |
| 232 | + public async Task<SettlementPaymentBllDto?> GetPaymentByIdAsync(Guid paymentId) | |
| 233 | + { | |
| 234 | + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId); | |
| 235 | + return payment == null ? null : SettlementPaymentBllDtoFactory.Create(payment); | |
| 236 | + } | |
| 237 | + | |
| 238 | + public async Task<SettlementPlanBllDto?> GetPlanByIdAsync(Guid planId) | |
| 239 | + { | |
| 240 | + var plan = await _uow.SettlementPlans.GetByIdAsync(planId); | |
| 241 | + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true); | |
| 242 | + } | |
| 243 | + | |
| 244 | + public async Task<(bool success, string? errorCode)> MarkPaidGuardedAsync(Guid paymentId, Guid userId) | |
| 245 | + { | |
| 246 | + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId); | |
| 247 | + if (payment == null) return (false, "notfound"); | |
| 248 | + | |
| 249 | + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId); | |
| 250 | + if (plan == null) return (false, "notfound"); | |
| 251 | + | |
| 252 | + if (!await _uow.TripParticipants.IsParticipantAsync(plan.TripId, userId)) | |
| 253 | + return (false, "forbidden"); | |
| 254 | + | |
| 255 | + if (payment.FromUserId != userId) return (false, "forbidden"); | |
| 256 | + | |
| 257 | + await MarkPaidAsync(paymentId, userId); | |
| 258 | + return (true, null); | |
| 259 | + } | |
| 260 | + | |
| 261 | + public async Task<(bool success, string? errorCode)> ConfirmPaymentGuardedAsync(Guid paymentId, Guid userId) | |
| 262 | + { | |
| 263 | + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId); | |
| 264 | + if (payment == null) return (false, "notfound"); | |
| 265 | + | |
| 266 | + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId); | |
| 267 | + if (plan == null) return (false, "notfound"); | |
| 268 | + | |
| 269 | + if (!await _uow.TripParticipants.IsParticipantAsync(plan.TripId, userId)) | |
| 270 | + return (false, "forbidden"); | |
| 271 | + | |
| 272 | + if (payment.ToUserId != userId) return (false, "forbidden"); | |
| 273 | + | |
| 274 | + await ConfirmPaymentAsync(paymentId, userId); | |
| 275 | + return (true, null); | |
| 276 | + } | |
| 277 | + | |
| 278 | + public async Task ConfirmPaymentAsync(Guid paymentId, Guid userId) | |
| 279 | + { | |
| 280 | + // DAL uses NoTrackingWithIdentityResolution, so every load returns a | |
| 281 | + // detached entity. Mutations only persist via an explicit Update() call. | |
| 282 | + var paymentRepo = _uow.GetRepository<SettlementPayment>(); | |
| 283 | + var payment = await paymentRepo.GetByIdAsync(paymentId); | |
| 284 | + if (payment == null) return; | |
| 285 | + if (payment.ToUserId != userId) return; | |
| 286 | + | |
| 287 | + payment.Status = EPaymentStatus.Confirmed; | |
| 288 | + payment.ConfirmedAt = DateTime.UtcNow; | |
| 289 | + paymentRepo.Update(payment); | |
| 290 | + | |
| 291 | + // Read the plan with its Payments to check whether the plan is now | |
| 292 | + // fully confirmed. This load is read-only — used only for the All() | |
| 293 | + // check below — so we don't Update() it (its FromUser/ToUser includes | |
| 294 | + // would make DbSet.Update cascade into the AppUser graph and corrupt | |
| 295 | + // Identity rows on SaveChanges). | |
| 296 | + var planForCheck = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId); | |
| 297 | + if (planForCheck?.Payments == null) | |
| 298 | + { | |
| 299 | + await _uow.SaveChangesAsync(); | |
| 300 | + return; | |
| 301 | + } | |
| 302 | + | |
| 303 | + // The just-mutated payment is a different instance from the one inside | |
| 304 | + // planForCheck.Payments (no tracking → no identity map across queries). | |
| 305 | + // Treat the current paymentId as already Confirmed when checking. | |
| 306 | + var allConfirmed = planForCheck.Payments.All(p => | |
| 307 | + p.Id == paymentId || p.Status == EPaymentStatus.Confirmed); | |
| 308 | + | |
| 309 | + // Update plan + trip via the base repo (no Includes) so Update() only | |
| 310 | + // touches the plan/trip rows themselves. | |
| 311 | + var planRepo = _uow.GetRepository<SettlementPlan>(); | |
| 312 | + var plan = await planRepo.GetByIdAsync(payment.SettlementPlanId); | |
| 313 | + if (plan == null) | |
| 314 | + { | |
| 315 | + await _uow.SaveChangesAsync(); | |
| 316 | + return; | |
| 317 | + } | |
| 318 | + | |
| 319 | + if (allConfirmed) | |
| 320 | + { | |
| 321 | + plan.Status = ESettlementStatus.Completed; | |
| 322 | + plan.CompletedAt = DateTime.UtcNow; | |
| 323 | + | |
| 324 | + var tripRepo = _uow.GetRepository<Trip>(); | |
| 325 | + var trip = await tripRepo.GetByIdAsync(plan.TripId); | |
| 326 | + if (trip != null && trip.Status == ETripStatus.Finalizing) | |
| 327 | + { | |
| 328 | + trip.Status = ETripStatus.Settled; | |
| 329 | + tripRepo.Update(trip); | |
| 330 | + } | |
| 331 | + } | |
| 332 | + else | |
| 333 | + { | |
| 334 | + plan.Status = ESettlementStatus.InProgress; | |
| 335 | + } | |
| 336 | + planRepo.Update(plan); | |
| 337 | + | |
| 338 | + await _uow.SaveChangesAsync(); | |
| 339 | + } | |
| 340 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/SplitPresetService.cs +124 −0
| @@ -0,0 +1,124 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | + | |
| 10 | +namespace SplitApp.WebApp.Application.Services; | |
| 11 | + | |
| 12 | +public class SplitPresetService : ISplitPresetService | |
| 13 | +{ | |
| 14 | + private readonly IAppUnitOfWork _uow; | |
| 15 | + | |
| 16 | + public SplitPresetService(IAppUnitOfWork uow) | |
| 17 | + { | |
| 18 | + _uow = uow; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public async Task<List<SplitPresetBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 22 | + { | |
| 23 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 24 | + return new List<SplitPresetBllDto>(); | |
| 25 | + var presets = await _uow.SplitPresets.GetByTripIdAsync(tripId); | |
| 26 | + return SplitPresetBllDtoFactory.CreateList(presets, includeMembers: true); | |
| 27 | + } | |
| 28 | + | |
| 29 | + public async Task<SplitPresetBllDto?> GetByIdAsync(Guid id, Guid userId) | |
| 30 | + { | |
| 31 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 32 | + if (preset == null) return null; | |
| 33 | + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) return null; | |
| 34 | + return SplitPresetBllDtoFactory.Create(preset, includeMembers: true); | |
| 35 | + } | |
| 36 | + | |
| 37 | + public async Task<(SplitPresetBllDto? preset, string? errorCode)> CreateAsync(SplitPresetBllDto preset, | |
| 38 | + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId) | |
| 39 | + { | |
| 40 | + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) | |
| 41 | + return (null, "forbidden"); | |
| 42 | + | |
| 43 | + var entity = SplitPresetBllDtoFactory.ToEntity(preset); | |
| 44 | + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid(); | |
| 45 | + entity.CreatedById = userId; | |
| 46 | + _uow.SplitPresets.Add(entity); | |
| 47 | + | |
| 48 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 49 | + foreach (var m in members) | |
| 50 | + { | |
| 51 | + memberRepo.Add(new SplitPresetMember | |
| 52 | + { | |
| 53 | + SplitPresetId = entity.Id, | |
| 54 | + UserId = m.UserId, | |
| 55 | + ShareWeight = m.ShareWeight, | |
| 56 | + Percentage = m.Percentage | |
| 57 | + }); | |
| 58 | + } | |
| 59 | + | |
| 60 | + await _uow.SaveChangesAsync(); | |
| 61 | + | |
| 62 | + var reloaded = await _uow.SplitPresets.GetByIdAsync(entity.Id); | |
| 63 | + return (reloaded == null ? null : SplitPresetBllDtoFactory.Create(reloaded, includeMembers: true), null); | |
| 64 | + } | |
| 65 | + | |
| 66 | + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, string name, ESplitMethod splitMethod, | |
| 67 | + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId) | |
| 68 | + { | |
| 69 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 70 | + if (preset == null) return (false, "notfound"); | |
| 71 | + | |
| 72 | + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) | |
| 73 | + return (false, "forbidden"); | |
| 74 | + | |
| 75 | + preset.Name = name; | |
| 76 | + preset.SplitMethod = splitMethod; | |
| 77 | + _uow.SplitPresets.Update(preset); | |
| 78 | + | |
| 79 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 80 | + if (preset.Members != null) | |
| 81 | + { | |
| 82 | + foreach (var member in preset.Members.ToList()) | |
| 83 | + { | |
| 84 | + await memberRepo.RemoveAsync(member.Id); | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + foreach (var m in members) | |
| 89 | + { | |
| 90 | + memberRepo.Add(new SplitPresetMember | |
| 91 | + { | |
| 92 | + SplitPresetId = preset.Id, | |
| 93 | + UserId = m.UserId, | |
| 94 | + ShareWeight = m.ShareWeight, | |
| 95 | + Percentage = m.Percentage | |
| 96 | + }); | |
| 97 | + } | |
| 98 | + | |
| 99 | + await _uow.SaveChangesAsync(); | |
| 100 | + return (true, null); | |
| 101 | + } | |
| 102 | + | |
| 103 | + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId) | |
| 104 | + { | |
| 105 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 106 | + if (preset == null) return (false, "notfound"); | |
| 107 | + | |
| 108 | + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) | |
| 109 | + return (false, "forbidden"); | |
| 110 | + | |
| 111 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 112 | + if (preset.Members != null) | |
| 113 | + { | |
| 114 | + foreach (var member in preset.Members.ToList()) | |
| 115 | + { | |
| 116 | + await memberRepo.RemoveAsync(member.Id); | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + await _uow.SplitPresets.RemoveAsync(id); | |
| 121 | + await _uow.SaveChangesAsync(); | |
| 122 | + return (true, null); | |
| 123 | + } | |
| 124 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/TripService.cs +240 −0
| @@ -0,0 +1,240 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | + | |
| 10 | +namespace SplitApp.WebApp.Application.Services; | |
| 11 | + | |
| 12 | +public class TripService : ITripService | |
| 13 | +{ | |
| 14 | + private readonly IAppUnitOfWork _uow; | |
| 15 | + private readonly ISettlementService _settlementService; | |
| 16 | + | |
| 17 | + public TripService(IAppUnitOfWork uow, ISettlementService settlementService) | |
| 18 | + { | |
| 19 | + _uow = uow; | |
| 20 | + _settlementService = settlementService; | |
| 21 | + } | |
| 22 | + | |
| 23 | + public async Task<TripBllDto> CreateTripAsync(TripBllDto trip, Guid userId) | |
| 24 | + { | |
| 25 | + var entity = TripBllDtoFactory.ToEntity(trip); | |
| 26 | + entity.Id = Guid.NewGuid(); | |
| 27 | + entity.CreatedById = userId; | |
| 28 | + entity.Status = ETripStatus.Active; | |
| 29 | + | |
| 30 | + _uow.Trips.Add(entity); | |
| 31 | + | |
| 32 | + var participant = new TripParticipant | |
| 33 | + { | |
| 34 | + Id = Guid.NewGuid(), | |
| 35 | + TripId = entity.Id, | |
| 36 | + UserId = userId, | |
| 37 | + Role = EParticipantRole.Organizer, | |
| 38 | + JoinedAt = DateTime.UtcNow, | |
| 39 | + IsActive = true | |
| 40 | + }; | |
| 41 | + | |
| 42 | + _uow.TripParticipants.Add(participant); | |
| 43 | + | |
| 44 | + await _uow.SaveChangesAsync(); | |
| 45 | + | |
| 46 | + return TripBllDtoFactory.Create(entity); | |
| 47 | + } | |
| 48 | + | |
| 49 | + public async Task<List<TripBllDto>> GetUserTripsAsync(Guid userId) | |
| 50 | + { | |
| 51 | + var trips = await _uow.Trips.GetUserTripsAsync(userId); | |
| 52 | + return TripBllDtoFactory.CreateList(trips, includeParticipants: true); | |
| 53 | + } | |
| 54 | + | |
| 55 | + public async Task<TripBllDto?> GetByIdAsync(Guid tripId, Guid userId) | |
| 56 | + { | |
| 57 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return null; | |
| 58 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 59 | + return trip == null ? null : TripBllDtoFactory.Create(trip); | |
| 60 | + } | |
| 61 | + | |
| 62 | + public async Task<TripBllDto?> GetByIdWithDetailsAsync(Guid tripId, Guid userId) | |
| 63 | + { | |
| 64 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return null; | |
| 65 | + var trip = await _uow.Trips.GetByIdWithDetailsAsync(tripId); | |
| 66 | + return trip == null ? null : TripBllDtoFactory.Create(trip, includeParticipants: true, includeExpenses: true); | |
| 67 | + } | |
| 68 | + | |
| 69 | + public async Task<TripBllDto?> GetByIdForOrganizerAsync(Guid tripId, Guid userId) | |
| 70 | + { | |
| 71 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) return null; | |
| 72 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 73 | + return trip == null ? null : TripBllDtoFactory.Create(trip); | |
| 74 | + } | |
| 75 | + | |
| 76 | + public async Task<TripBllDto?> GetRawByIdAsync(Guid tripId) | |
| 77 | + { | |
| 78 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 79 | + return trip == null ? null : TripBllDtoFactory.Create(trip); | |
| 80 | + } | |
| 81 | + | |
| 82 | + public Task<bool> IsParticipantAsync(Guid tripId, Guid userId) | |
| 83 | + => _uow.TripParticipants.IsParticipantAsync(tripId, userId); | |
| 84 | + | |
| 85 | + public Task<bool> IsOrganizerAsync(Guid tripId, Guid userId) | |
| 86 | + => _uow.TripParticipants.IsOrganizerAsync(tripId, userId); | |
| 87 | + | |
| 88 | + public async Task<TripBllDto?> UpdateAsync(TripBllDto trip, Guid userId) | |
| 89 | + { | |
| 90 | + if (!await _uow.TripParticipants.IsOrganizerAsync(trip.Id, userId)) return null; | |
| 91 | + | |
| 92 | + var existing = await _uow.Trips.GetByIdAsync(trip.Id); | |
| 93 | + if (existing == null) return null; | |
| 94 | + | |
| 95 | + existing.Name = trip.Name; | |
| 96 | + existing.Description = trip.Description; | |
| 97 | + existing.Destination = trip.Destination; | |
| 98 | + existing.StartDate = trip.StartDate; | |
| 99 | + existing.EndDate = trip.EndDate; | |
| 100 | + existing.DefaultCurrencyId = trip.DefaultCurrencyId; | |
| 101 | + existing.Status = trip.Status; | |
| 102 | + | |
| 103 | + _uow.Trips.Update(existing); | |
| 104 | + await _uow.SaveChangesAsync(); | |
| 105 | + return TripBllDtoFactory.Create(existing); | |
| 106 | + } | |
| 107 | + | |
| 108 | + public async Task<bool> DeleteAsync(Guid tripId, Guid userId) | |
| 109 | + { | |
| 110 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) return false; | |
| 111 | + | |
| 112 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 113 | + if (trip == null) return false; | |
| 114 | + | |
| 115 | + await _uow.Trips.RemoveAsync(tripId); | |
| 116 | + await _uow.SaveChangesAsync(); | |
| 117 | + return true; | |
| 118 | + } | |
| 119 | + | |
| 120 | + public async Task<List<TripParticipantBllDto>> GetParticipantsAsync(Guid tripId, Guid userId) | |
| 121 | + { | |
| 122 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 123 | + return new List<TripParticipantBllDto>(); | |
| 124 | + var participants = await _uow.TripParticipants.GetByTripIdAsync(tripId); | |
| 125 | + return TripParticipantBllDtoFactory.CreateList(participants); | |
| 126 | + } | |
| 127 | + | |
| 128 | + public Task<List<TripParticipantBllDto>> GetParticipantsForIndexAsync(Guid tripId, Guid userId) | |
| 129 | + => GetParticipantsAsync(tripId, userId); | |
| 130 | + | |
| 131 | + public async Task<TripParticipantBllDto?> GetParticipantByIdAsync(Guid participantId) | |
| 132 | + { | |
| 133 | + var participant = await _uow.TripParticipants.GetByIdAsync(participantId); | |
| 134 | + return participant == null ? null : TripParticipantBllDtoFactory.Create(participant); | |
| 135 | + } | |
| 136 | + | |
| 137 | + public async Task<(bool success, string? errorCode)> RemoveParticipantAsync(Guid tripId, Guid participantUserId, Guid currentUserId) | |
| 138 | + { | |
| 139 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, currentUserId)) | |
| 140 | + return (false, "forbidden"); | |
| 141 | + | |
| 142 | + var participants = (await _uow.TripParticipants.GetByTripIdAsync(tripId)).ToList(); | |
| 143 | + var participant = participants.FirstOrDefault(tp => tp.UserId == participantUserId); | |
| 144 | + if (participant == null) return (false, "notfound"); | |
| 145 | + | |
| 146 | + if (participant.Role == EParticipantRole.Organizer) | |
| 147 | + return (false, "organizer"); | |
| 148 | + if (participant.UserId == currentUserId) | |
| 149 | + return (false, "self"); | |
| 150 | + | |
| 151 | + participant.IsActive = false; | |
| 152 | + participant.LeftAt = DateTime.UtcNow; | |
| 153 | + _uow.TripParticipants.Update(participant); | |
| 154 | + await _uow.SaveChangesAsync(); | |
| 155 | + | |
| 156 | + return (true, null); | |
| 157 | + } | |
| 158 | + | |
| 159 | + public async Task<bool> RemoveParticipantByIdAsync(Guid tripId, Guid participantId, Guid currentUserId) | |
| 160 | + { | |
| 161 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, currentUserId)) | |
| 162 | + return false; | |
| 163 | + | |
| 164 | + var participant = await _uow.TripParticipants.GetByIdAsync(participantId); | |
| 165 | + if (participant == null || participant.TripId != tripId) return false; | |
| 166 | + | |
| 167 | + // Cannot remove yourself | |
| 168 | + if (participant.UserId == currentUserId) return false; | |
| 169 | + | |
| 170 | + participant.IsActive = false; | |
| 171 | + participant.LeftAt = DateTime.UtcNow; | |
| 172 | + _uow.TripParticipants.Update(participant); | |
| 173 | + await _uow.SaveChangesAsync(); | |
| 174 | + | |
| 175 | + return true; | |
| 176 | + } | |
| 177 | + | |
| 178 | + public async Task<(bool success, string? errorCode)> FinalizeTripAsync(Guid tripId, Guid userId) | |
| 179 | + { | |
| 180 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) | |
| 181 | + return (false, "forbidden"); | |
| 182 | + | |
| 183 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 184 | + if (trip == null) return (false, "notfound"); | |
| 185 | + if (trip.Status != ETripStatus.Active) return (false, "badstatus"); | |
| 186 | + | |
| 187 | + trip.Status = ETripStatus.Finalizing; | |
| 188 | + _uow.Trips.Update(trip); | |
| 189 | + await _uow.SaveChangesAsync(); | |
| 190 | + | |
| 191 | + // Auto-create settlement plan | |
| 192 | + var createdPlan = await _settlementService.CalculateSettlementAsync(tripId, userId); | |
| 193 | + | |
| 194 | + // If nobody owes anyone, skip straight to Settled. | |
| 195 | + if (createdPlan == null) | |
| 196 | + { | |
| 197 | + trip.Status = ETripStatus.Settled; | |
| 198 | + await _uow.SaveChangesAsync(); | |
| 199 | + } | |
| 200 | + | |
| 201 | + return (true, null); | |
| 202 | + } | |
| 203 | + | |
| 204 | + public async Task<(bool success, string? errorCode)> ReopenTripAsync(Guid tripId, Guid userId) | |
| 205 | + { | |
| 206 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) | |
| 207 | + return (false, "forbidden"); | |
| 208 | + | |
| 209 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 210 | + if (trip == null) return (false, "notfound"); | |
| 211 | + if (trip.Status != ETripStatus.Finalizing && trip.Status != ETripStatus.Settled) | |
| 212 | + return (false, "badstatus"); | |
| 213 | + | |
| 214 | + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId); | |
| 215 | + if (plan?.Payments != null && plan.Payments.Any(p => p.Status == EPaymentStatus.Confirmed)) | |
| 216 | + return (false, "payments-confirmed"); | |
| 217 | + | |
| 218 | + if (plan != null) | |
| 219 | + { | |
| 220 | + await _uow.SettlementPlans.DeletePlanWithPaymentsAsync(plan.Id); | |
| 221 | + } | |
| 222 | + | |
| 223 | + var tripToUpdate = await _uow.Trips.GetByIdAsync(tripId); | |
| 224 | + if (tripToUpdate == null) return (false, "notfound"); | |
| 225 | + tripToUpdate.Status = ETripStatus.Active; | |
| 226 | + _uow.Trips.Update(tripToUpdate); | |
| 227 | + await _uow.SaveChangesAsync(); | |
| 228 | + | |
| 229 | + return (true, null); | |
| 230 | + } | |
| 231 | + | |
| 232 | + public async Task<List<CurrencyBllDto>> GetAllCurrenciesAsync() | |
| 233 | + { | |
| 234 | + var currencies = await _uow.GetRepository<Currency>().GetAllAsync(); | |
| 235 | + return currencies | |
| 236 | + .OrderBy(c => c.Code) | |
| 237 | + .Select(CurrencyBllDtoFactory.Create) | |
| 238 | + .ToList(); | |
| 239 | + } | |
| 240 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Application/Services/WishlistService.cs +149 −0
| @@ -0,0 +1,149 @@ | ||
| 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.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.WebApp.Application.Contracts; | |
| 9 | + | |
| 10 | +namespace SplitApp.WebApp.Application.Services; | |
| 11 | + | |
| 12 | +public class WishlistService : IWishlistService | |
| 13 | +{ | |
| 14 | + private readonly IAppUnitOfWork _uow; | |
| 15 | + | |
| 16 | + public WishlistService(IAppUnitOfWork uow) | |
| 17 | + { | |
| 18 | + _uow = uow; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public async Task<List<TripWishlistItemBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 22 | + { | |
| 23 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 24 | + return new List<TripWishlistItemBllDto>(); | |
| 25 | + var items = await _uow.TripWishlistItems.GetByTripIdAsync(tripId); | |
| 26 | + return WishlistBllDtoFactory.CreateList(items); | |
| 27 | + } | |
| 28 | + | |
| 29 | + public async Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id, Guid userId) | |
| 30 | + { | |
| 31 | + var item = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 32 | + if (item == null) return null; | |
| 33 | + if (!await _uow.TripParticipants.IsParticipantAsync(item.TripId, userId)) return null; | |
| 34 | + return WishlistBllDtoFactory.Create(item); | |
| 35 | + } | |
| 36 | + | |
| 37 | + public async Task<TripWishlistItemBllDto?> GetByIdRawAsync(Guid id) | |
| 38 | + { | |
| 39 | + var item = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 40 | + return item == null ? null : WishlistBllDtoFactory.Create(item); | |
| 41 | + } | |
| 42 | + | |
| 43 | + public async Task<(TripWishlistItemBllDto? item, string? errorCode)> CreateAsync(TripWishlistItemBllDto item, Guid userId) | |
| 44 | + { | |
| 45 | + if (!await _uow.TripParticipants.IsParticipantAsync(item.TripId, userId)) | |
| 46 | + return (null, "forbidden"); | |
| 47 | + | |
| 48 | + var entity = WishlistBllDtoFactory.ToEntity(item); | |
| 49 | + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid(); | |
| 50 | + entity.AddedByUserId = userId; | |
| 51 | + _uow.TripWishlistItems.Add(entity); | |
| 52 | + await _uow.SaveChangesAsync(); | |
| 53 | + | |
| 54 | + var reloaded = await _uow.TripWishlistItems.GetByIdAsync(entity.Id); | |
| 55 | + return (reloaded == null ? null : WishlistBllDtoFactory.Create(reloaded), null); | |
| 56 | + } | |
| 57 | + | |
| 58 | + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, TripWishlistItemBllDto incoming, Guid userId) | |
| 59 | + { | |
| 60 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 61 | + if (existing == null) return (false, "notfound"); | |
| 62 | + | |
| 63 | + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId)) | |
| 64 | + return (false, "forbidden"); | |
| 65 | + | |
| 66 | + if (existing.AddedByUserId != userId) return (false, "forbidden"); | |
| 67 | + | |
| 68 | + existing.Title = incoming.Title; | |
| 69 | + existing.Description = incoming.Description; | |
| 70 | + existing.Category = incoming.Category; | |
| 71 | + existing.Priority = incoming.Priority; | |
| 72 | + existing.EstimatedCost = incoming.EstimatedCost; | |
| 73 | + existing.Url = incoming.Url; | |
| 74 | + existing.Location = incoming.Location; | |
| 75 | + | |
| 76 | + _uow.TripWishlistItems.Update(existing); | |
| 77 | + await _uow.SaveChangesAsync(); | |
| 78 | + return (true, null); | |
| 79 | + } | |
| 80 | + | |
| 81 | + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId) | |
| 82 | + { | |
| 83 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 84 | + if (existing == null) return (false, "notfound"); | |
| 85 | + | |
| 86 | + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId)) | |
| 87 | + return (false, "forbidden"); | |
| 88 | + | |
| 89 | + if (existing.AddedByUserId != userId) return (false, "forbidden"); | |
| 90 | + | |
| 91 | + var voteRepo = _uow.GetRepository<TripWishlistVote>(); | |
| 92 | + var allVotes = (await voteRepo.GetAllAsync()).Where(v => v.WishlistItemId == id).ToList(); | |
| 93 | + foreach (var vote in allVotes) | |
| 94 | + { | |
| 95 | + await voteRepo.RemoveAsync(vote.Id); | |
| 96 | + } | |
| 97 | + | |
| 98 | + await _uow.TripWishlistItems.RemoveAsync(id); | |
| 99 | + await _uow.SaveChangesAsync(); | |
| 100 | + return (true, null); | |
| 101 | + } | |
| 102 | + | |
| 103 | + public async Task<(bool success, string? errorCode)> ToggleVoteAsync(Guid id, Guid userId) | |
| 104 | + { | |
| 105 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 106 | + if (existing == null) return (false, "notfound"); | |
| 107 | + | |
| 108 | + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId)) | |
| 109 | + return (false, "forbidden"); | |
| 110 | + | |
| 111 | + var voteRepo = _uow.GetRepository<TripWishlistVote>(); | |
| 112 | + var allVotes = (await voteRepo.GetAllAsync()).ToList(); | |
| 113 | + var existingVote = allVotes.FirstOrDefault(v => v.WishlistItemId == id && v.UserId == userId); | |
| 114 | + | |
| 115 | + if (existingVote != null) | |
| 116 | + { | |
| 117 | + await voteRepo.RemoveAsync(existingVote.Id); | |
| 118 | + } | |
| 119 | + else | |
| 120 | + { | |
| 121 | + voteRepo.Add(new TripWishlistVote | |
| 122 | + { | |
| 123 | + Id = Guid.NewGuid(), | |
| 124 | + WishlistItemId = id, | |
| 125 | + UserId = userId, | |
| 126 | + IsInterested = true | |
| 127 | + }); | |
| 128 | + } | |
| 129 | + | |
| 130 | + await _uow.SaveChangesAsync(); | |
| 131 | + return (true, null); | |
| 132 | + } | |
| 133 | + | |
| 134 | + public async Task<(bool success, string? errorCode)> ToggleCompleteAsync(Guid id, Guid userId) | |
| 135 | + { | |
| 136 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 137 | + if (existing == null) return (false, "notfound"); | |
| 138 | + | |
| 139 | + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId)) | |
| 140 | + return (false, "forbidden"); | |
| 141 | + | |
| 142 | + existing.IsCompleted = !existing.IsCompleted; | |
| 143 | + existing.CompletedAt = existing.IsCompleted ? DateTime.UtcNow : null; | |
| 144 | + | |
| 145 | + _uow.TripWishlistItems.Update(existing); | |
| 146 | + await _uow.SaveChangesAsync(); | |
| 147 | + return (true, null); | |
| 148 | + } | |
| 149 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +147 −0
| @@ -0,0 +1,147 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 11 | +using Microsoft.EntityFrameworkCore; | |
| 12 | +using Microsoft.Extensions.Localization; | |
| 13 | +using SplitApp.WebApp.Areas.Admin.Models; | |
| 14 | + | |
| 15 | +namespace SplitApp.WebApp.Areas.Admin.Controllers | |
| 16 | +{ | |
| 17 | + [Area("Admin")] | |
| 18 | + [Authorize(Roles = "admin")] | |
| 19 | + public class BudgetCategoriesController : Controller | |
| 20 | + { | |
| 21 | + private readonly IBudgetCategoryAdminService _service; | |
| 22 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 23 | + | |
| 24 | + public BudgetCategoriesController(IBudgetCategoryAdminService service, | |
| 25 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 26 | + { | |
| 27 | + _service = service; | |
| 28 | + _l = l; | |
| 29 | + } | |
| 30 | + | |
| 31 | + public async Task<IActionResult> Index(Guid? tripId, string? search) | |
| 32 | + { | |
| 33 | + var vm = new AdminBudgetCategoryIndexViewModel | |
| 34 | + { | |
| 35 | + Title = _l["Budget Categories"].Value, | |
| 36 | + Items = await _service.GetAllAsync(tripId, search), | |
| 37 | + Trips = await _service.GetAllTripsAsync(), | |
| 38 | + CurrentTripId = tripId, | |
| 39 | + CurrentSearch = search | |
| 40 | + }; | |
| 41 | + return View(vm); | |
| 42 | + } | |
| 43 | + | |
| 44 | + public async Task<IActionResult> Details(Guid? id) | |
| 45 | + { | |
| 46 | + if (id == null) return NotFound(); | |
| 47 | + var entity = await _service.GetByIdAsync(id.Value); | |
| 48 | + if (entity == null) return NotFound(); | |
| 49 | + | |
| 50 | + return View(new AdminDetailsViewModel<BudgetCategoryBllDto> | |
| 51 | + { | |
| 52 | + Title = _l["Budget Category details"].Value, | |
| 53 | + Item = entity | |
| 54 | + }); | |
| 55 | + } | |
| 56 | + | |
| 57 | + public async Task<IActionResult> Create() | |
| 58 | + { | |
| 59 | + var vm = new AdminBudgetCategoryFormViewModel | |
| 60 | + { | |
| 61 | + Title = _l["New budget category"].Value, | |
| 62 | + TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name") | |
| 63 | + }; | |
| 64 | + return View(vm); | |
| 65 | + } | |
| 66 | + | |
| 67 | + [HttpPost] | |
| 68 | + [ValidateAntiForgeryToken] | |
| 69 | + public async Task<IActionResult> Create(AdminBudgetCategoryFormViewModel vm, string? nameEn, string? nameEt) | |
| 70 | + { | |
| 71 | + ModelState.Remove("BudgetCategory.Name"); | |
| 72 | + | |
| 73 | + if (ModelState.IsValid) | |
| 74 | + { | |
| 75 | + await _service.CreateAsync(vm.BudgetCategory, nameEn, nameEt); | |
| 76 | + return RedirectToAction(nameof(Index)); | |
| 77 | + } | |
| 78 | + | |
| 79 | + vm.Title = _l["New budget category"].Value; | |
| 80 | + vm.TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", vm.BudgetCategory.TripId); | |
| 81 | + return View(vm); | |
| 82 | + } | |
| 83 | + | |
| 84 | + public async Task<IActionResult> Edit(Guid? id) | |
| 85 | + { | |
| 86 | + if (id == null) return NotFound(); | |
| 87 | + var entity = await _service.GetByIdAsync(id.Value); | |
| 88 | + if (entity == null) return NotFound(); | |
| 89 | + | |
| 90 | + var vm = new AdminBudgetCategoryFormViewModel | |
| 91 | + { | |
| 92 | + Title = _l["Edit budget category"].Value, | |
| 93 | + BudgetCategory = entity, | |
| 94 | + TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", entity.TripId) | |
| 95 | + }; | |
| 96 | + return View(vm); | |
| 97 | + } | |
| 98 | + | |
| 99 | + [HttpPost] | |
| 100 | + [ValidateAntiForgeryToken] | |
| 101 | + public async Task<IActionResult> Edit(Guid id, AdminBudgetCategoryFormViewModel vm, string? nameEn, string? nameEt) | |
| 102 | + { | |
| 103 | + if (id != vm.BudgetCategory.Id) return NotFound(); | |
| 104 | + | |
| 105 | + ModelState.Remove("BudgetCategory.Name"); | |
| 106 | + | |
| 107 | + if (ModelState.IsValid) | |
| 108 | + { | |
| 109 | + try | |
| 110 | + { | |
| 111 | + await _service.UpdateAsync(vm.BudgetCategory, nameEn, nameEt); | |
| 112 | + } | |
| 113 | + catch (DbUpdateConcurrencyException) | |
| 114 | + { | |
| 115 | + if (!await _service.ExistsAsync(vm.BudgetCategory.Id)) return NotFound(); | |
| 116 | + throw; | |
| 117 | + } | |
| 118 | + return RedirectToAction(nameof(Index)); | |
| 119 | + } | |
| 120 | + | |
| 121 | + vm.Title = _l["Edit budget category"].Value; | |
| 122 | + vm.TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", vm.BudgetCategory.TripId); | |
| 123 | + return View(vm); | |
| 124 | + } | |
| 125 | + | |
| 126 | + public async Task<IActionResult> Delete(Guid? id) | |
| 127 | + { | |
| 128 | + if (id == null) return NotFound(); | |
| 129 | + var entity = await _service.GetByIdAsync(id.Value); | |
| 130 | + if (entity == null) return NotFound(); | |
| 131 | + | |
| 132 | + return View(new AdminDeleteViewModel<BudgetCategoryBllDto> | |
| 133 | + { | |
| 134 | + Title = _l["Delete budget category"].Value, | |
| 135 | + Item = entity | |
| 136 | + }); | |
| 137 | + } | |
| 138 | + | |
| 139 | + [HttpPost, ActionName("Delete")] | |
| 140 | + [ValidateAntiForgeryToken] | |
| 141 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 142 | + { | |
| 143 | + await _service.DeleteAsync(id); | |
| 144 | + return RedirectToAction(nameof(Index)); | |
| 145 | + } | |
| 146 | + } | |
| 147 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/CurrenciesController.cs +134 −0
| @@ -0,0 +1,134 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 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 CurrenciesController : Controller | |
| 19 | + { | |
| 20 | + private readonly ICurrencyAdminService _service; | |
| 21 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 22 | + | |
| 23 | + public CurrenciesController(ICurrencyAdminService service, | |
| 24 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 25 | + { | |
| 26 | + _service = service; | |
| 27 | + _l = l; | |
| 28 | + } | |
| 29 | + | |
| 30 | + public async Task<IActionResult> Index(string? search) | |
| 31 | + { | |
| 32 | + return View(new AdminCurrencyIndexViewModel | |
| 33 | + { | |
| 34 | + Title = _l["Currencies"].Value, | |
| 35 | + Items = await _service.GetAllAsync(search), | |
| 36 | + CurrentSearch = search | |
| 37 | + }); | |
| 38 | + } | |
| 39 | + | |
| 40 | + public async Task<IActionResult> Details(Guid? id) | |
| 41 | + { | |
| 42 | + if (id == null) return NotFound(); | |
| 43 | + var currency = await _service.GetByIdAsync(id.Value); | |
| 44 | + if (currency == null) return NotFound(); | |
| 45 | + | |
| 46 | + return View(new AdminDetailsViewModel<CurrencyBllDto> | |
| 47 | + { | |
| 48 | + Title = _l["Currency details"].Value, | |
| 49 | + Item = currency | |
| 50 | + }); | |
| 51 | + } | |
| 52 | + | |
| 53 | + public IActionResult Create() | |
| 54 | + { | |
| 55 | + return View(new AdminCurrencyFormViewModel { Title = _l["New currency"].Value }); | |
| 56 | + } | |
| 57 | + | |
| 58 | + [HttpPost] | |
| 59 | + [ValidateAntiForgeryToken] | |
| 60 | + public async Task<IActionResult> Create(AdminCurrencyFormViewModel vm, string? nameEn, string? nameEt) | |
| 61 | + { | |
| 62 | + ModelState.Remove("Currency.Name"); | |
| 63 | + | |
| 64 | + if (ModelState.IsValid) | |
| 65 | + { | |
| 66 | + await _service.CreateAsync(vm.Currency, nameEn, nameEt); | |
| 67 | + return RedirectToAction(nameof(Index)); | |
| 68 | + } | |
| 69 | + | |
| 70 | + vm.Title = _l["New currency"].Value; | |
| 71 | + return View(vm); | |
| 72 | + } | |
| 73 | + | |
| 74 | + public async Task<IActionResult> Edit(Guid? id) | |
| 75 | + { | |
| 76 | + if (id == null) return NotFound(); | |
| 77 | + var currency = await _service.GetByIdAsync(id.Value); | |
| 78 | + if (currency == null) return NotFound(); | |
| 79 | + | |
| 80 | + return View(new AdminCurrencyFormViewModel | |
| 81 | + { | |
| 82 | + Title = _l["Edit currency"].Value, | |
| 83 | + Currency = currency | |
| 84 | + }); | |
| 85 | + } | |
| 86 | + | |
| 87 | + [HttpPost] | |
| 88 | + [ValidateAntiForgeryToken] | |
| 89 | + public async Task<IActionResult> Edit(Guid id, AdminCurrencyFormViewModel vm, string? nameEn, string? nameEt) | |
| 90 | + { | |
| 91 | + if (id != vm.Currency.Id) return NotFound(); | |
| 92 | + | |
| 93 | + ModelState.Remove("Currency.Name"); | |
| 94 | + | |
| 95 | + if (ModelState.IsValid) | |
| 96 | + { | |
| 97 | + try | |
| 98 | + { | |
| 99 | + await _service.UpdateAsync(vm.Currency, nameEn, nameEt); | |
| 100 | + } | |
| 101 | + catch (DbUpdateConcurrencyException) | |
| 102 | + { | |
| 103 | + if (!await _service.ExistsAsync(vm.Currency.Id)) return NotFound(); | |
| 104 | + throw; | |
| 105 | + } | |
| 106 | + return RedirectToAction(nameof(Index)); | |
| 107 | + } | |
| 108 | + | |
| 109 | + vm.Title = _l["Edit currency"].Value; | |
| 110 | + return View(vm); | |
| 111 | + } | |
| 112 | + | |
| 113 | + public async Task<IActionResult> Delete(Guid? id) | |
| 114 | + { | |
| 115 | + if (id == null) return NotFound(); | |
| 116 | + var currency = await _service.GetByIdAsync(id.Value); | |
| 117 | + if (currency == null) return NotFound(); | |
| 118 | + | |
| 119 | + return View(new AdminDeleteViewModel<CurrencyBllDto> | |
| 120 | + { | |
| 121 | + Title = _l["Delete currency"].Value, | |
| 122 | + Item = currency | |
| 123 | + }); | |
| 124 | + } | |
| 125 | + | |
| 126 | + [HttpPost, ActionName("Delete")] | |
| 127 | + [ValidateAntiForgeryToken] | |
| 128 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 129 | + { | |
| 130 | + await _service.DeleteAsync(id); | |
| 131 | + return RedirectToAction(nameof(Index)); | |
| 132 | + } | |
| 133 | + } | |
| 134 | +} |
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 +149 −0
| @@ -0,0 +1,149 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 11 | +using Microsoft.EntityFrameworkCore; | |
| 12 | +using Microsoft.Extensions.Localization; | |
| 13 | +using SplitApp.WebApp.Areas.Admin.Models; | |
| 14 | + | |
| 15 | +namespace SplitApp.WebApp.Areas.Admin.Controllers | |
| 16 | +{ | |
| 17 | + [Area("Admin")] | |
| 18 | + [Authorize(Roles = "admin")] | |
| 19 | + public class ExpensesController : Controller | |
| 20 | + { | |
| 21 | + private readonly IExpenseAdminService _service; | |
| 22 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 23 | + | |
| 24 | + public ExpensesController(IExpenseAdminService service, IStringLocalizer<App.Resources.Views.Shared> l) | |
| 25 | + { | |
| 26 | + _service = service; | |
| 27 | + _l = l; | |
| 28 | + } | |
| 29 | + | |
| 30 | + private async Task PopulateSelectListsAsync(AdminExpenseFormViewModel vm) | |
| 31 | + { | |
| 32 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Expense.TripId); | |
| 33 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Expense.PaidByUserId); | |
| 34 | + vm.CurrencyList = new SelectList(await _service.GetCurrenciesAsync(), "Id", "Code", vm.Expense.CurrencyId); | |
| 35 | + vm.BudgetCategoryList = new SelectList(await _service.GetBudgetCategoriesAsync(), "Id", "Name", vm.Expense.BudgetCategoryId); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task<IActionResult> Index(Guid? tripId, string? search) | |
| 39 | + { | |
| 40 | + var expenses = await _service.GetAllAsync(tripId, search); | |
| 41 | + var trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(); | |
| 42 | + | |
| 43 | + return View(new AdminExpenseIndexViewModel | |
| 44 | + { | |
| 45 | + Title = _l["Expenses"].Value, | |
| 46 | + Items = expenses.OrderByDescending(e => e.ExpenseDate).ToList(), | |
| 47 | + Trips = trips, | |
| 48 | + CurrentTripId = tripId, | |
| 49 | + CurrentSearch = search | |
| 50 | + }); | |
| 51 | + } | |
| 52 | + | |
| 53 | + public async Task<IActionResult> Details(Guid? id) | |
| 54 | + { | |
| 55 | + if (id == null) return NotFound(); | |
| 56 | + var expense = await _service.GetByIdAsync(id.Value); | |
| 57 | + if (expense == null) return NotFound(); | |
| 58 | + | |
| 59 | + return View(new AdminDetailsViewModel<ExpenseBllDto> | |
| 60 | + { | |
| 61 | + Title = _l["Expense details"].Value, | |
| 62 | + Item = expense | |
| 63 | + }); | |
| 64 | + } | |
| 65 | + | |
| 66 | + public async Task<IActionResult> Create() | |
| 67 | + { | |
| 68 | + var vm = new AdminExpenseFormViewModel { Title = _l["New expense"].Value }; | |
| 69 | + await PopulateSelectListsAsync(vm); | |
| 70 | + return View(vm); | |
| 71 | + } | |
| 72 | + | |
| 73 | + [HttpPost] | |
| 74 | + [ValidateAntiForgeryToken] | |
| 75 | + public async Task<IActionResult> Create(AdminExpenseFormViewModel vm) | |
| 76 | + { | |
| 77 | + if (ModelState.IsValid) | |
| 78 | + { | |
| 79 | + await _service.CreateAsync(vm.Expense); | |
| 80 | + return RedirectToAction(nameof(Index)); | |
| 81 | + } | |
| 82 | + | |
| 83 | + vm.Title = _l["New expense"].Value; | |
| 84 | + await PopulateSelectListsAsync(vm); | |
| 85 | + return View(vm); | |
| 86 | + } | |
| 87 | + | |
| 88 | + public async Task<IActionResult> Edit(Guid? id) | |
| 89 | + { | |
| 90 | + if (id == null) return NotFound(); | |
| 91 | + var expense = await _service.GetByIdAsync(id.Value); | |
| 92 | + if (expense == null) return NotFound(); | |
| 93 | + | |
| 94 | + var vm = new AdminExpenseFormViewModel | |
| 95 | + { | |
| 96 | + Title = _l["Edit expense"].Value, | |
| 97 | + Expense = expense | |
| 98 | + }; | |
| 99 | + await PopulateSelectListsAsync(vm); | |
| 100 | + return View(vm); | |
| 101 | + } | |
| 102 | + | |
| 103 | + [HttpPost] | |
| 104 | + [ValidateAntiForgeryToken] | |
| 105 | + public async Task<IActionResult> Edit(Guid id, AdminExpenseFormViewModel vm) | |
| 106 | + { | |
| 107 | + if (id != vm.Expense.Id) return NotFound(); | |
| 108 | + | |
| 109 | + if (ModelState.IsValid) | |
| 110 | + { | |
| 111 | + try | |
| 112 | + { | |
| 113 | + await _service.UpdateAsync(vm.Expense); | |
| 114 | + } | |
| 115 | + catch (DbUpdateConcurrencyException) | |
| 116 | + { | |
| 117 | + if (!await _service.ExistsAsync(vm.Expense.Id)) return NotFound(); | |
| 118 | + throw; | |
| 119 | + } | |
| 120 | + return RedirectToAction(nameof(Index)); | |
| 121 | + } | |
| 122 | + | |
| 123 | + vm.Title = _l["Edit expense"].Value; | |
| 124 | + await PopulateSelectListsAsync(vm); | |
| 125 | + return View(vm); | |
| 126 | + } | |
| 127 | + | |
| 128 | + public async Task<IActionResult> Delete(Guid? id) | |
| 129 | + { | |
| 130 | + if (id == null) return NotFound(); | |
| 131 | + var expense = await _service.GetByIdAsync(id.Value); | |
| 132 | + if (expense == null) return NotFound(); | |
| 133 | + | |
| 134 | + return View(new AdminDeleteViewModel<ExpenseBllDto> | |
| 135 | + { | |
| 136 | + Title = _l["Delete expense"].Value, | |
| 137 | + Item = expense | |
| 138 | + }); | |
| 139 | + } | |
| 140 | + | |
| 141 | + [HttpPost, ActionName("Delete")] | |
| 142 | + [ValidateAntiForgeryToken] | |
| 143 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 144 | + { | |
| 145 | + await _service.DeleteAsync(id); | |
| 146 | + return RedirectToAction(nameof(Index)); | |
| 147 | + } | |
| 148 | + } | |
| 149 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/InvitationsController.cs +158 −0
| @@ -0,0 +1,158 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 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 InvitationsController : Controller | |
| 18 | + { | |
| 19 | + private readonly IInvitationAdminService _service; | |
| 20 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 21 | + | |
| 22 | + public InvitationsController(IInvitationAdminService service, | |
| 23 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 24 | + { | |
| 25 | + _service = service; | |
| 26 | + _l = l; | |
| 27 | + } | |
| 28 | + | |
| 29 | + // GET: Admin/Invitations | |
| 30 | + public async Task<IActionResult> Index(string? search) | |
| 31 | + { | |
| 32 | + var invitations = await _service.GetAllAsync(search); | |
| 33 | + | |
| 34 | + var vm = new AdminInvitationIndexViewModel | |
| 35 | + { | |
| 36 | + Title = _l["Invitations"].Value, | |
| 37 | + Items = invitations.OrderByDescending(i => i.Id).ToList(), | |
| 38 | + CurrentSearch = search | |
| 39 | + }; | |
| 40 | + return View(vm); | |
| 41 | + } | |
| 42 | + | |
| 43 | + // GET: Admin/Invitations/Details/5 | |
| 44 | + public async Task<IActionResult> Details(Guid? id) | |
| 45 | + { | |
| 46 | + if (id == null) | |
| 47 | + { | |
| 48 | + return NotFound(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + var tripInvitation = await _service.GetByIdAsync(id.Value); | |
| 52 | + if (tripInvitation == null) | |
| 53 | + { | |
| 54 | + return NotFound(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + return View(new AdminDetailsViewModel<TripInvitationBllDto> | |
| 58 | + { | |
| 59 | + Title = _l["Invitation details"].Value, | |
| 60 | + Item = tripInvitation | |
| 61 | + }); | |
| 62 | + } | |
| 63 | + | |
| 64 | + public async Task<IActionResult> Create() | |
| 65 | + { | |
| 66 | + return View(new AdminInvitationFormViewModel | |
| 67 | + { | |
| 68 | + Title = _l["New invitation"].Value, | |
| 69 | + Invitation = new TripInvitationBllDto | |
| 70 | + { | |
| 71 | + ExpiresAt = DateTime.UtcNow.AddDays(7), | |
| 72 | + Status = EInvitationStatus.Pending | |
| 73 | + }, | |
| 74 | + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 75 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 76 | + }); | |
| 77 | + } | |
| 78 | + | |
| 79 | + [HttpPost] | |
| 80 | + [ValidateAntiForgeryToken] | |
| 81 | + public async Task<IActionResult> Create(AdminInvitationFormViewModel vm) | |
| 82 | + { | |
| 83 | + if (ModelState.IsValid) | |
| 84 | + { | |
| 85 | + await _service.CreateAsync(vm.Invitation); | |
| 86 | + return RedirectToAction(nameof(Index)); | |
| 87 | + } | |
| 88 | + | |
| 89 | + vm.Title = _l["New invitation"].Value; | |
| 90 | + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Invitation.TripId); | |
| 91 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Invitation.InvitedByUserId); | |
| 92 | + return View(vm); | |
| 93 | + } | |
| 94 | + | |
| 95 | + public async Task<IActionResult> Edit(Guid? id) | |
| 96 | + { | |
| 97 | + if (id == null) return NotFound(); | |
| 98 | + var invitation = await _service.GetByIdAsync(id.Value); | |
| 99 | + if (invitation == null) return NotFound(); | |
| 100 | + | |
| 101 | + return View(new AdminInvitationFormViewModel | |
| 102 | + { | |
| 103 | + Title = _l["Edit invitation"].Value, | |
| 104 | + Invitation = invitation, | |
| 105 | + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", invitation.TripId), | |
| 106 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", invitation.InvitedByUserId) | |
| 107 | + }); | |
| 108 | + } | |
| 109 | + | |
| 110 | + [HttpPost] | |
| 111 | + [ValidateAntiForgeryToken] | |
| 112 | + public async Task<IActionResult> Edit(Guid id, AdminInvitationFormViewModel vm) | |
| 113 | + { | |
| 114 | + if (id != vm.Invitation.Id) return NotFound(); | |
| 115 | + | |
| 116 | + if (ModelState.IsValid) | |
| 117 | + { | |
| 118 | + await _service.UpdateAsync(vm.Invitation); | |
| 119 | + return RedirectToAction(nameof(Index)); | |
| 120 | + } | |
| 121 | + | |
| 122 | + vm.Title = _l["Edit invitation"].Value; | |
| 123 | + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Invitation.TripId); | |
| 124 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Invitation.InvitedByUserId); | |
| 125 | + return View(vm); | |
| 126 | + } | |
| 127 | + | |
| 128 | + // GET: Admin/Invitations/Delete/5 | |
| 129 | + public async Task<IActionResult> Delete(Guid? id) | |
| 130 | + { | |
| 131 | + if (id == null) | |
| 132 | + { | |
| 133 | + return NotFound(); | |
| 134 | + } | |
| 135 | + | |
| 136 | + var tripInvitation = await _service.GetByIdAsync(id.Value); | |
| 137 | + if (tripInvitation == null) | |
| 138 | + { | |
| 139 | + return NotFound(); | |
| 140 | + } | |
| 141 | + | |
| 142 | + return View(new AdminDeleteViewModel<TripInvitationBllDto> | |
| 143 | + { | |
| 144 | + Title = _l["Delete invitation"].Value, | |
| 145 | + Item = tripInvitation | |
| 146 | + }); | |
| 147 | + } | |
| 148 | + | |
| 149 | + // POST: Admin/Invitations/Delete/5 | |
| 150 | + [HttpPost, ActionName("Delete")] | |
| 151 | + [ValidateAntiForgeryToken] | |
| 152 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 153 | + { | |
| 154 | + await _service.DeleteAsync(id); | |
| 155 | + return RedirectToAction(nameof(Index)); | |
| 156 | + } | |
| 157 | + } | |
| 158 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/PollsController.cs +129 −0
| @@ -0,0 +1,129 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 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 PollsController : Controller | |
| 19 | +{ | |
| 20 | + private readonly IPollAdminService _service; | |
| 21 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 22 | + | |
| 23 | + public PollsController(IPollAdminService 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 polls = await _service.GetAllAsync(search); | |
| 32 | + | |
| 33 | + var vm = new AdminPollIndexViewModel | |
| 34 | + { | |
| 35 | + Title = _l["Polls"].Value, | |
| 36 | + Items = polls.OrderByDescending(p => p.Id).ToList(), | |
| 37 | + CurrentSearch = search | |
| 38 | + }; | |
| 39 | + return View(vm); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public async Task<IActionResult> Details(Guid id) | |
| 43 | + { | |
| 44 | + var poll = await _service.GetByIdAsync(id); | |
| 45 | + if (poll == null) return NotFound(); | |
| 46 | + | |
| 47 | + return View(new AdminDetailsViewModel<TripPollBllDto> | |
| 48 | + { | |
| 49 | + Title = _l["Poll details"].Value, | |
| 50 | + Item = poll | |
| 51 | + }); | |
| 52 | + } | |
| 53 | + | |
| 54 | + public async Task<IActionResult> Create() | |
| 55 | + { | |
| 56 | + var vm = new AdminPollFormViewModel | |
| 57 | + { | |
| 58 | + Title = _l["New poll"].Value, | |
| 59 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 60 | + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name") | |
| 61 | + }; | |
| 62 | + return View(vm); | |
| 63 | + } | |
| 64 | + | |
| 65 | + [HttpPost] | |
| 66 | + [ValidateAntiForgeryToken] | |
| 67 | + public async Task<IActionResult> Create(AdminPollFormViewModel vm) | |
| 68 | + { | |
| 69 | + if (ModelState.IsValid) | |
| 70 | + { | |
| 71 | + await _service.CreateAsync(vm.Poll); | |
| 72 | + return RedirectToAction(nameof(Index)); | |
| 73 | + } | |
| 74 | + vm.Title = _l["New poll"].Value; | |
| 75 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"); | |
| 76 | + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name"); | |
| 77 | + return View(vm); | |
| 78 | + } | |
| 79 | + | |
| 80 | + public async Task<IActionResult> Edit(Guid id) | |
| 81 | + { | |
| 82 | + var poll = await _service.GetByIdAsync(id); | |
| 83 | + if (poll == null) return NotFound(); | |
| 84 | + var vm = new AdminPollFormViewModel | |
| 85 | + { | |
| 86 | + Title = _l["Edit poll"].Value, | |
| 87 | + Poll = poll, | |
| 88 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 89 | + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name") | |
| 90 | + }; | |
| 91 | + return View(vm); | |
| 92 | + } | |
| 93 | + | |
| 94 | + [HttpPost] | |
| 95 | + [ValidateAntiForgeryToken] | |
| 96 | + public async Task<IActionResult> Edit(Guid id, AdminPollFormViewModel vm) | |
| 97 | + { | |
| 98 | + if (id != vm.Poll.Id) return NotFound(); | |
| 99 | + if (ModelState.IsValid) | |
| 100 | + { | |
| 101 | + await _service.UpdateAsync(vm.Poll); | |
| 102 | + return RedirectToAction(nameof(Index)); | |
| 103 | + } | |
| 104 | + vm.Title = _l["Edit poll"].Value; | |
| 105 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"); | |
| 106 | + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name"); | |
| 107 | + return View(vm); | |
| 108 | + } | |
| 109 | + | |
| 110 | + public async Task<IActionResult> Delete(Guid id) | |
| 111 | + { | |
| 112 | + var poll = await _service.GetByIdAsync(id); | |
| 113 | + if (poll == null) return NotFound(); | |
| 114 | + | |
| 115 | + return View(new AdminDeleteViewModel<TripPollBllDto> | |
| 116 | + { | |
| 117 | + Title = _l["Delete poll"].Value, | |
| 118 | + Item = poll | |
| 119 | + }); | |
| 120 | + } | |
| 121 | + | |
| 122 | + [HttpPost, ActionName("Delete")] | |
| 123 | + [ValidateAntiForgeryToken] | |
| 124 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 125 | + { | |
| 126 | + await _service.DeleteAsync(id); | |
| 127 | + return RedirectToAction(nameof(Index)); | |
| 128 | + } | |
| 129 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +153 −0
| @@ -0,0 +1,153 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 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 SettlementPaymentsController : Controller | |
| 18 | + { | |
| 19 | + private readonly ISettlementPaymentAdminService _service; | |
| 20 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 21 | + | |
| 22 | + public SettlementPaymentsController(ISettlementPaymentAdminService service, | |
| 23 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 24 | + { | |
| 25 | + _service = service; | |
| 26 | + _l = l; | |
| 27 | + } | |
| 28 | + | |
| 29 | + // GET: Admin/SettlementPayments | |
| 30 | + public async Task<IActionResult> Index(string? search) | |
| 31 | + { | |
| 32 | + var payments = await _service.GetAllAsync(search); | |
| 33 | + | |
| 34 | + var vm = new AdminSettlementPaymentIndexViewModel | |
| 35 | + { | |
| 36 | + Title = _l["Settlement payments"].Value, | |
| 37 | + Items = payments.OrderByDescending(s => s.Id).ToList(), | |
| 38 | + CurrentSearch = search | |
| 39 | + }; | |
| 40 | + return View(vm); | |
| 41 | + } | |
| 42 | + | |
| 43 | + // GET: Admin/SettlementPayments/Details/5 | |
| 44 | + public async Task<IActionResult> Details(Guid? id) | |
| 45 | + { | |
| 46 | + if (id == null) | |
| 47 | + { | |
| 48 | + return NotFound(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + var settlementPayment = await _service.GetByIdAsync(id.Value); | |
| 52 | + if (settlementPayment == null) | |
| 53 | + { | |
| 54 | + return NotFound(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + return View(new AdminDetailsViewModel<SettlementPaymentBllDto> | |
| 58 | + { | |
| 59 | + Title = _l["Settlement payment details"].Value, | |
| 60 | + Item = settlementPayment | |
| 61 | + }); | |
| 62 | + } | |
| 63 | + | |
| 64 | + public async Task<IActionResult> Create() | |
| 65 | + { | |
| 66 | + return View(new AdminSettlementPaymentFormViewModel | |
| 67 | + { | |
| 68 | + Title = _l["New settlement payment"].Value, | |
| 69 | + SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id"), | |
| 70 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 71 | + }); | |
| 72 | + } | |
| 73 | + | |
| 74 | + [HttpPost] | |
| 75 | + [ValidateAntiForgeryToken] | |
| 76 | + public async Task<IActionResult> Create(AdminSettlementPaymentFormViewModel vm) | |
| 77 | + { | |
| 78 | + if (ModelState.IsValid) | |
| 79 | + { | |
| 80 | + await _service.CreateAsync(vm.Payment); | |
| 81 | + return RedirectToAction(nameof(Index)); | |
| 82 | + } | |
| 83 | + | |
| 84 | + vm.Title = _l["New settlement payment"].Value; | |
| 85 | + vm.SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", vm.Payment.SettlementPlanId); | |
| 86 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Payment.FromUserId); | |
| 87 | + return View(vm); | |
| 88 | + } | |
| 89 | + | |
| 90 | + public async Task<IActionResult> Edit(Guid? id) | |
| 91 | + { | |
| 92 | + if (id == null) return NotFound(); | |
| 93 | + var payment = await _service.GetByIdAsync(id.Value); | |
| 94 | + if (payment == null) return NotFound(); | |
| 95 | + | |
| 96 | + return View(new AdminSettlementPaymentFormViewModel | |
| 97 | + { | |
| 98 | + Title = _l["Edit settlement payment"].Value, | |
| 99 | + Payment = payment, | |
| 100 | + SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", payment.SettlementPlanId), | |
| 101 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", payment.FromUserId) | |
| 102 | + }); | |
| 103 | + } | |
| 104 | + | |
| 105 | + [HttpPost] | |
| 106 | + [ValidateAntiForgeryToken] | |
| 107 | + public async Task<IActionResult> Edit(Guid id, AdminSettlementPaymentFormViewModel vm) | |
| 108 | + { | |
| 109 | + if (id != vm.Payment.Id) return NotFound(); | |
| 110 | + | |
| 111 | + if (ModelState.IsValid) | |
| 112 | + { | |
| 113 | + await _service.UpdateAsync(vm.Payment); | |
| 114 | + return RedirectToAction(nameof(Index)); | |
| 115 | + } | |
| 116 | + | |
| 117 | + vm.Title = _l["Edit settlement payment"].Value; | |
| 118 | + vm.SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", vm.Payment.SettlementPlanId); | |
| 119 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Payment.FromUserId); | |
| 120 | + return View(vm); | |
| 121 | + } | |
| 122 | + | |
| 123 | + // GET: Admin/SettlementPayments/Delete/5 | |
| 124 | + public async Task<IActionResult> Delete(Guid? id) | |
| 125 | + { | |
| 126 | + if (id == null) | |
| 127 | + { | |
| 128 | + return NotFound(); | |
| 129 | + } | |
| 130 | + | |
| 131 | + var settlementPayment = await _service.GetByIdAsync(id.Value); | |
| 132 | + if (settlementPayment == null) | |
| 133 | + { | |
| 134 | + return NotFound(); | |
| 135 | + } | |
| 136 | + | |
| 137 | + return View(new AdminDeleteViewModel<SettlementPaymentBllDto> | |
| 138 | + { | |
| 139 | + Title = _l["Delete settlement payment"].Value, | |
| 140 | + Item = settlementPayment | |
| 141 | + }); | |
| 142 | + } | |
| 143 | + | |
| 144 | + // POST: Admin/SettlementPayments/Delete/5 | |
| 145 | + [HttpPost, ActionName("Delete")] | |
| 146 | + [ValidateAntiForgeryToken] | |
| 147 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 148 | + { | |
| 149 | + await _service.DeleteAsync(id); | |
| 150 | + return RedirectToAction(nameof(Index)); | |
| 151 | + } | |
| 152 | + } | |
| 153 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/SettlementPlansController.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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 11 | +using Microsoft.EntityFrameworkCore; | |
| 12 | +using Microsoft.Extensions.Localization; | |
| 13 | +using SplitApp.WebApp.Areas.Admin.Models; | |
| 14 | + | |
| 15 | +namespace SplitApp.WebApp.Areas.Admin.Controllers | |
| 16 | +{ | |
| 17 | + [Area("Admin")] | |
| 18 | + [Authorize(Roles = "admin")] | |
| 19 | + public class SettlementPlansController : Controller | |
| 20 | + { | |
| 21 | + private readonly ISettlementPlanAdminService _service; | |
| 22 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 23 | + | |
| 24 | + public SettlementPlansController(ISettlementPlanAdminService service, | |
| 25 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 26 | + { | |
| 27 | + _service = service; | |
| 28 | + _l = l; | |
| 29 | + } | |
| 30 | + | |
| 31 | + // GET: Admin/SettlementPlans | |
| 32 | + public async Task<IActionResult> Index(Guid? tripId) | |
| 33 | + { | |
| 34 | + var plans = await _service.GetAllAsync(tripId); | |
| 35 | + | |
| 36 | + var vm = new AdminSettlementPlanIndexViewModel | |
| 37 | + { | |
| 38 | + Title = _l["Settlement plans"].Value, | |
| 39 | + Items = plans.OrderByDescending(s => s.Id).ToList(), | |
| 40 | + Trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(), | |
| 41 | + CurrentTripId = tripId | |
| 42 | + }; | |
| 43 | + return View(vm); | |
| 44 | + } | |
| 45 | + | |
| 46 | + // GET: Admin/SettlementPlans/Details/5 | |
| 47 | + public async Task<IActionResult> Details(Guid? id) | |
| 48 | + { | |
| 49 | + if (id == null) | |
| 50 | + { | |
| 51 | + return NotFound(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + var settlementPlan = await _service.GetByIdAsync(id.Value); | |
| 55 | + if (settlementPlan == null) | |
| 56 | + { | |
| 57 | + return NotFound(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + return View(new AdminDetailsViewModel<SettlementPlanBllDto> | |
| 61 | + { | |
| 62 | + Title = _l["Settlement plan details"].Value, | |
| 63 | + Item = settlementPlan | |
| 64 | + }); | |
| 65 | + } | |
| 66 | + | |
| 67 | + // GET: Admin/SettlementPlans/Create | |
| 68 | + public async Task<IActionResult> Create() | |
| 69 | + { | |
| 70 | + var vm = new AdminSettlementPlanFormViewModel | |
| 71 | + { | |
| 72 | + Title = _l["New settlement plan"].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/SettlementPlans/Create | |
| 80 | + [HttpPost] | |
| 81 | + [ValidateAntiForgeryToken] | |
| 82 | + public async Task<IActionResult> Create(AdminSettlementPlanFormViewModel vm) | |
| 83 | + { | |
| 84 | + if (ModelState.IsValid) | |
| 85 | + { | |
| 86 | + await _service.CreateAsync(vm.SettlementPlan); | |
| 87 | + return RedirectToAction(nameof(Index)); | |
| 88 | + } | |
| 89 | + | |
| 90 | + vm.Title = _l["New settlement plan"].Value; | |
| 91 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SettlementPlan.TripId); | |
| 92 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SettlementPlan.CreatedByUserId); | |
| 93 | + return View(vm); | |
| 94 | + } | |
| 95 | + | |
| 96 | + // GET: Admin/SettlementPlans/Edit/5 | |
| 97 | + public async Task<IActionResult> Edit(Guid? id) | |
| 98 | + { | |
| 99 | + if (id == null) | |
| 100 | + { | |
| 101 | + return NotFound(); | |
| 102 | + } | |
| 103 | + | |
| 104 | + var settlementPlan = await _service.GetByIdAsync(id.Value); | |
| 105 | + if (settlementPlan == null) | |
| 106 | + { | |
| 107 | + return NotFound(); | |
| 108 | + } | |
| 109 | + | |
| 110 | + var vm = new AdminSettlementPlanFormViewModel | |
| 111 | + { | |
| 112 | + Title = _l["Edit settlement plan"].Value, | |
| 113 | + SettlementPlan = settlementPlan, | |
| 114 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", settlementPlan.TripId), | |
| 115 | + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", settlementPlan.CreatedByUserId) | |
| 116 | + }; | |
| 117 | + return View(vm); | |
| 118 | + } | |
| 119 | + | |
| 120 | + // POST: Admin/SettlementPlans/Edit/5 | |
| 121 | + [HttpPost] | |
| 122 | + [ValidateAntiForgeryToken] | |
| 123 | + public async Task<IActionResult> Edit(Guid id, AdminSettlementPlanFormViewModel vm) | |
| 124 | + { | |
| 125 | + if (id != vm.SettlementPlan.Id) | |
| 126 | + { | |
| 127 | + return NotFound(); | |
| 128 | + } | |
| 129 | + | |
| 130 | + if (ModelState.IsValid) | |
| 131 | + { | |
| 132 | + try | |
| 133 | + { | |
| 134 | + await _service.UpdateAsync(vm.SettlementPlan); | |
| 135 | + } | |
| 136 | + catch (DbUpdateConcurrencyException) | |
| 137 | + { | |
| 138 | + if (!await _service.ExistsAsync(vm.SettlementPlan.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 settlement plan"].Value; | |
| 152 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SettlementPlan.TripId); | |
| 153 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SettlementPlan.CreatedByUserId); | |
| 154 | + return View(vm); | |
| 155 | + } | |
| 156 | + | |
| 157 | + // GET: Admin/SettlementPlans/Delete/5 | |
| 158 | + public async Task<IActionResult> Delete(Guid? id) | |
| 159 | + { | |
| 160 | + if (id == null) | |
| 161 | + { | |
| 162 | + return NotFound(); | |
| 163 | + } | |
| 164 | + | |
| 165 | + var settlementPlan = await _service.GetByIdAsync(id.Value); | |
| 166 | + if (settlementPlan == null) | |
| 167 | + { | |
| 168 | + return NotFound(); | |
| 169 | + } | |
| 170 | + | |
| 171 | + return View(new AdminDeleteViewModel<SettlementPlanBllDto> | |
| 172 | + { | |
| 173 | + Title = _l["Delete settlement plan"].Value, | |
| 174 | + Item = settlementPlan | |
| 175 | + }); | |
| 176 | + } | |
| 177 | + | |
| 178 | + // POST: Admin/SettlementPlans/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/SplitPresetsController.cs +115 −0
| @@ -0,0 +1,115 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 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 SplitPresetsController : Controller | |
| 18 | +{ | |
| 19 | + private readonly ISplitPresetAdminService _service; | |
| 20 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 21 | + | |
| 22 | + public SplitPresetsController(ISplitPresetAdminService 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 | + var presets = await _service.GetAllAsync(search); | |
| 32 | + | |
| 33 | + var vm = new AdminSplitPresetIndexViewModel | |
| 34 | + { | |
| 35 | + Title = _l["Split presets"].Value, | |
| 36 | + Items = presets.OrderByDescending(s => s.Id).ToList(), | |
| 37 | + CurrentSearch = search | |
| 38 | + }; | |
| 39 | + return View(vm); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public async Task<IActionResult> Details(Guid? id) | |
| 43 | + { | |
| 44 | + if (id == null) | |
| 45 | + { | |
| 46 | + return NotFound(); | |
| 47 | + } | |
| 48 | + | |
| 49 | + var splitPreset = await _service.GetByIdAsync(id.Value); | |
| 50 | + if (splitPreset == null) | |
| 51 | + { | |
| 52 | + return NotFound(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + return View(new AdminDetailsViewModel<SplitPresetBllDto> | |
| 56 | + { | |
| 57 | + Title = _l["Split preset details"].Value, | |
| 58 | + Item = splitPreset | |
| 59 | + }); | |
| 60 | + } | |
| 61 | + | |
| 62 | + public async Task<IActionResult> Create() | |
| 63 | + { | |
| 64 | + return View(new AdminSplitPresetFormViewModel | |
| 65 | + { | |
| 66 | + Title = _l["New split preset"].Value, | |
| 67 | + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 68 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 69 | + }); | |
| 70 | + } | |
| 71 | + | |
| 72 | + [HttpPost] | |
| 73 | + [ValidateAntiForgeryToken] | |
| 74 | + public async Task<IActionResult> Create(AdminSplitPresetFormViewModel vm) | |
| 75 | + { | |
| 76 | + if (ModelState.IsValid) | |
| 77 | + { | |
| 78 | + await _service.CreateAsync(vm.SplitPreset); | |
| 79 | + return RedirectToAction(nameof(Index)); | |
| 80 | + } | |
| 81 | + | |
| 82 | + vm.Title = _l["New split preset"].Value; | |
| 83 | + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SplitPreset.TripId); | |
| 84 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SplitPreset.CreatedById); | |
| 85 | + return View(vm); | |
| 86 | + } | |
| 87 | + | |
| 88 | + public async Task<IActionResult> Delete(Guid? id) | |
| 89 | + { | |
| 90 | + if (id == null) | |
| 91 | + { | |
| 92 | + return NotFound(); | |
| 93 | + } | |
| 94 | + | |
| 95 | + var splitPreset = await _service.GetByIdAsync(id.Value); | |
| 96 | + if (splitPreset == null) | |
| 97 | + { | |
| 98 | + return NotFound(); | |
| 99 | + } | |
| 100 | + | |
| 101 | + return View(new AdminDeleteViewModel<SplitPresetBllDto> | |
| 102 | + { | |
| 103 | + Title = _l["Delete split preset"].Value, | |
| 104 | + Item = splitPreset | |
| 105 | + }); | |
| 106 | + } | |
| 107 | + | |
| 108 | + [HttpPost, ActionName("Delete")] | |
| 109 | + [ValidateAntiForgeryToken] | |
| 110 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 111 | + { | |
| 112 | + await _service.DeleteAsync(id); | |
| 113 | + return RedirectToAction(nameof(Index)); | |
| 114 | + } | |
| 115 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +188 −0
| @@ -0,0 +1,188 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 11 | +using Microsoft.EntityFrameworkCore; | |
| 12 | +using Microsoft.Extensions.Localization; | |
| 13 | +using SplitApp.WebApp.Areas.Admin.Models; | |
| 14 | + | |
| 15 | +namespace SplitApp.WebApp.Areas.Admin.Controllers | |
| 16 | +{ | |
| 17 | + [Area("Admin")] | |
| 18 | + [Authorize(Roles = "admin")] | |
| 19 | + public class TripParticipantsController : Controller | |
| 20 | + { | |
| 21 | + private readonly ITripParticipantAdminService _service; | |
| 22 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 23 | + | |
| 24 | + public TripParticipantsController(ITripParticipantAdminService service, | |
| 25 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 26 | + { | |
| 27 | + _service = service; | |
| 28 | + _l = l; | |
| 29 | + } | |
| 30 | + | |
| 31 | + // GET: Admin/TripParticipants | |
| 32 | + public async Task<IActionResult> Index(Guid? tripId, string? search) | |
| 33 | + { | |
| 34 | + var participants = await _service.GetAllAsync(tripId, search); | |
| 35 | + | |
| 36 | + var vm = new AdminTripParticipantIndexViewModel | |
| 37 | + { | |
| 38 | + Title = _l["Trip participants"].Value, | |
| 39 | + Items = participants.OrderByDescending(tp => tp.JoinedAt).ToList(), | |
| 40 | + Trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(), | |
| 41 | + CurrentTripId = tripId, | |
| 42 | + CurrentSearch = search | |
| 43 | + }; | |
| 44 | + return View(vm); | |
| 45 | + } | |
| 46 | + | |
| 47 | + // GET: Admin/TripParticipants/Details/5 | |
| 48 | + public async Task<IActionResult> Details(Guid? id) | |
| 49 | + { | |
| 50 | + if (id == null) | |
| 51 | + { | |
| 52 | + return NotFound(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + var tripParticipant = await _service.GetByIdAsync(id.Value); | |
| 56 | + if (tripParticipant == null) | |
| 57 | + { | |
| 58 | + return NotFound(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + return View(new AdminDetailsViewModel<TripParticipantBllDto> | |
| 62 | + { | |
| 63 | + Title = _l["Trip participant details"].Value, | |
| 64 | + Item = tripParticipant | |
| 65 | + }); | |
| 66 | + } | |
| 67 | + | |
| 68 | + // GET: Admin/TripParticipants/Create | |
| 69 | + public async Task<IActionResult> Create() | |
| 70 | + { | |
| 71 | + var vm = new AdminTripParticipantFormViewModel | |
| 72 | + { | |
| 73 | + Title = _l["New trip participant"].Value, | |
| 74 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 75 | + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 76 | + }; | |
| 77 | + return View(vm); | |
| 78 | + } | |
| 79 | + | |
| 80 | + // POST: Admin/TripParticipants/Create | |
| 81 | + [HttpPost] | |
| 82 | + [ValidateAntiForgeryToken] | |
| 83 | + public async Task<IActionResult> Create(AdminTripParticipantFormViewModel vm) | |
| 84 | + { | |
| 85 | + if (ModelState.IsValid) | |
| 86 | + { | |
| 87 | + await _service.CreateAsync(vm.TripParticipant); | |
| 88 | + return RedirectToAction(nameof(Index)); | |
| 89 | + } | |
| 90 | + | |
| 91 | + vm.Title = _l["New trip participant"].Value; | |
| 92 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.TripParticipant.TripId); | |
| 93 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.TripParticipant.UserId); | |
| 94 | + return View(vm); | |
| 95 | + } | |
| 96 | + | |
| 97 | + // GET: Admin/TripParticipants/Edit/5 | |
| 98 | + public async Task<IActionResult> Edit(Guid? id) | |
| 99 | + { | |
| 100 | + if (id == null) | |
| 101 | + { | |
| 102 | + return NotFound(); | |
| 103 | + } | |
| 104 | + | |
| 105 | + var tripParticipant = await _service.GetByIdAsync(id.Value); | |
| 106 | + if (tripParticipant == null) | |
| 107 | + { | |
| 108 | + return NotFound(); | |
| 109 | + } | |
| 110 | + | |
| 111 | + var vm = new AdminTripParticipantFormViewModel | |
| 112 | + { | |
| 113 | + Title = _l["Edit trip participant"].Value, | |
| 114 | + TripParticipant = tripParticipant, | |
| 115 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", tripParticipant.TripId), | |
| 116 | + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", tripParticipant.UserId) | |
| 117 | + }; | |
| 118 | + return View(vm); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // POST: Admin/TripParticipants/Edit/5 | |
| 122 | + [HttpPost] | |
| 123 | + [ValidateAntiForgeryToken] | |
| 124 | + public async Task<IActionResult> Edit(Guid id, AdminTripParticipantFormViewModel vm) | |
| 125 | + { | |
| 126 | + if (id != vm.TripParticipant.Id) | |
| 127 | + { | |
| 128 | + return NotFound(); | |
| 129 | + } | |
| 130 | + | |
| 131 | + if (ModelState.IsValid) | |
| 132 | + { | |
| 133 | + try | |
| 134 | + { | |
| 135 | + await _service.UpdateAsync(vm.TripParticipant); | |
| 136 | + } | |
| 137 | + catch (DbUpdateConcurrencyException) | |
| 138 | + { | |
| 139 | + if (!await _service.ExistsAsync(vm.TripParticipant.Id)) | |
| 140 | + { | |
| 141 | + return NotFound(); | |
| 142 | + } | |
| 143 | + else | |
| 144 | + { | |
| 145 | + throw; | |
| 146 | + } | |
| 147 | + } | |
| 148 | + | |
| 149 | + return RedirectToAction(nameof(Index)); | |
| 150 | + } | |
| 151 | + | |
| 152 | + vm.Title = _l["Edit trip participant"].Value; | |
| 153 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.TripParticipant.TripId); | |
| 154 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.TripParticipant.UserId); | |
| 155 | + return View(vm); | |
| 156 | + } | |
| 157 | + | |
| 158 | + // GET: Admin/TripParticipants/Delete/5 | |
| 159 | + public async Task<IActionResult> Delete(Guid? id) | |
| 160 | + { | |
| 161 | + if (id == null) | |
| 162 | + { | |
| 163 | + return NotFound(); | |
| 164 | + } | |
| 165 | + | |
| 166 | + var tripParticipant = await _service.GetByIdAsync(id.Value); | |
| 167 | + if (tripParticipant == null) | |
| 168 | + { | |
| 169 | + return NotFound(); | |
| 170 | + } | |
| 171 | + | |
| 172 | + return View(new AdminDeleteViewModel<TripParticipantBllDto> | |
| 173 | + { | |
| 174 | + Title = _l["Delete trip participant"].Value, | |
| 175 | + Item = tripParticipant | |
| 176 | + }); | |
| 177 | + } | |
| 178 | + | |
| 179 | + // POST: Admin/TripParticipants/Delete/5 | |
| 180 | + [HttpPost, ActionName("Delete")] | |
| 181 | + [ValidateAntiForgeryToken] | |
| 182 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 183 | + { | |
| 184 | + await _service.DeleteAsync(id); | |
| 185 | + return RedirectToAction(nameof(Index)); | |
| 186 | + } | |
| 187 | + } | |
| 188 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/TripsController.cs +140 −0
| @@ -0,0 +1,140 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 11 | +using Microsoft.EntityFrameworkCore; | |
| 12 | +using Microsoft.Extensions.Localization; | |
| 13 | +using SplitApp.WebApp.Areas.Admin.Models; | |
| 14 | + | |
| 15 | +namespace SplitApp.WebApp.Areas.Admin.Controllers | |
| 16 | +{ | |
| 17 | + [Area("Admin")] | |
| 18 | + [Authorize(Roles = "admin")] | |
| 19 | + public class TripsController : Controller | |
| 20 | + { | |
| 21 | + private readonly ITripAdminService _service; | |
| 22 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 23 | + | |
| 24 | + public TripsController(ITripAdminService service, IStringLocalizer<App.Resources.Views.Shared> l) | |
| 25 | + { | |
| 26 | + _service = service; | |
| 27 | + _l = l; | |
| 28 | + } | |
| 29 | + | |
| 30 | + public async Task<IActionResult> Index(string? search) | |
| 31 | + { | |
| 32 | + var trips = await _service.GetAllAsync(search); | |
| 33 | + | |
| 34 | + return View(new AdminTripIndexViewModel | |
| 35 | + { | |
| 36 | + Title = _l["Trips"].Value, | |
| 37 | + Items = trips.OrderByDescending(t => t.CreatedAt).ToList(), | |
| 38 | + CurrentSearch = search | |
| 39 | + }); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public async Task<IActionResult> Details(Guid? id) | |
| 43 | + { | |
| 44 | + if (id == null) return NotFound(); | |
| 45 | + var trip = await _service.GetByIdAsync(id.Value); | |
| 46 | + if (trip == null) return NotFound(); | |
| 47 | + | |
| 48 | + return View(new AdminDetailsViewModel<TripBllDto> | |
| 49 | + { | |
| 50 | + Title = _l["Trip details"].Value, | |
| 51 | + Item = trip | |
| 52 | + }); | |
| 53 | + } | |
| 54 | + | |
| 55 | + public async Task<IActionResult> Create() | |
| 56 | + { | |
| 57 | + return View(new AdminTripFormViewModel | |
| 58 | + { | |
| 59 | + Title = _l["New trip"].Value, | |
| 60 | + CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code") | |
| 61 | + }); | |
| 62 | + } | |
| 63 | + | |
| 64 | + [HttpPost] | |
| 65 | + [ValidateAntiForgeryToken] | |
| 66 | + public async Task<IActionResult> Create(AdminTripFormViewModel vm) | |
| 67 | + { | |
| 68 | + if (ModelState.IsValid) | |
| 69 | + { | |
| 70 | + await _service.CreateAsync(vm.Trip); | |
| 71 | + return RedirectToAction(nameof(Index)); | |
| 72 | + } | |
| 73 | + | |
| 74 | + vm.Title = _l["New trip"].Value; | |
| 75 | + vm.CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", vm.Trip.DefaultCurrencyId); | |
| 76 | + return View(vm); | |
| 77 | + } | |
| 78 | + | |
| 79 | + public async Task<IActionResult> Edit(Guid? id) | |
| 80 | + { | |
| 81 | + if (id == null) return NotFound(); | |
| 82 | + var trip = await _service.GetByIdAsync(id.Value); | |
| 83 | + if (trip == null) return NotFound(); | |
| 84 | + | |
| 85 | + return View(new AdminTripFormViewModel | |
| 86 | + { | |
| 87 | + Title = _l["Edit trip"].Value, | |
| 88 | + Trip = trip, | |
| 89 | + CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", trip.DefaultCurrencyId) | |
| 90 | + }); | |
| 91 | + } | |
| 92 | + | |
| 93 | + [HttpPost] | |
| 94 | + [ValidateAntiForgeryToken] | |
| 95 | + public async Task<IActionResult> Edit(Guid id, AdminTripFormViewModel vm) | |
| 96 | + { | |
| 97 | + if (id != vm.Trip.Id) return NotFound(); | |
| 98 | + | |
| 99 | + if (ModelState.IsValid) | |
| 100 | + { | |
| 101 | + try | |
| 102 | + { | |
| 103 | + await _service.UpdateAsync(vm.Trip); | |
| 104 | + } | |
| 105 | + catch (DbUpdateConcurrencyException) | |
| 106 | + { | |
| 107 | + if (!await _service.ExistsAsync(vm.Trip.Id)) return NotFound(); | |
| 108 | + throw; | |
| 109 | + } | |
| 110 | + | |
| 111 | + return RedirectToAction(nameof(Index)); | |
| 112 | + } | |
| 113 | + | |
| 114 | + vm.Title = _l["Edit trip"].Value; | |
| 115 | + vm.CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", vm.Trip.DefaultCurrencyId); | |
| 116 | + return View(vm); | |
| 117 | + } | |
| 118 | + | |
| 119 | + public async Task<IActionResult> Delete(Guid? id) | |
| 120 | + { | |
| 121 | + if (id == null) return NotFound(); | |
| 122 | + var trip = await _service.GetByIdAsync(id.Value); | |
| 123 | + if (trip == null) return NotFound(); | |
| 124 | + | |
| 125 | + return View(new AdminDeleteViewModel<TripBllDto> | |
| 126 | + { | |
| 127 | + Title = _l["Delete trip"].Value, | |
| 128 | + Item = trip | |
| 129 | + }); | |
| 130 | + } | |
| 131 | + | |
| 132 | + [HttpPost, ActionName("Delete")] | |
| 133 | + [ValidateAntiForgeryToken] | |
| 134 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 135 | + { | |
| 136 | + await _service.DeleteAsync(id); | |
| 137 | + return RedirectToAction(nameof(Index)); | |
| 138 | + } | |
| 139 | + } | |
| 140 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/UsersController.cs +197 −0
| @@ -0,0 +1,197 @@ | ||
| 1 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 2 | +using Microsoft.AspNetCore.Authorization; | |
| 3 | +using Microsoft.AspNetCore.Identity; | |
| 4 | +using Microsoft.AspNetCore.Mvc; | |
| 5 | +using Microsoft.EntityFrameworkCore; | |
| 6 | +using Microsoft.Extensions.Localization; | |
| 7 | +using SplitApp.WebApp.Areas.Admin.Models; | |
| 8 | + | |
| 9 | +namespace SplitApp.WebApp.Areas.Admin.Controllers; | |
| 10 | + | |
| 11 | +[Area("Admin")] | |
| 12 | +[Authorize(Roles = "admin")] | |
| 13 | +public class UsersController : Controller | |
| 14 | +{ | |
| 15 | + private readonly UserManager<AppUser> _userManager; | |
| 16 | + private readonly RoleManager<AppRole> _roleManager; | |
| 17 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 18 | + | |
| 19 | + public UsersController( | |
| 20 | + UserManager<AppUser> userManager, | |
| 21 | + RoleManager<AppRole> roleManager, | |
| 22 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 23 | + { | |
| 24 | + _userManager = userManager; | |
| 25 | + _roleManager = roleManager; | |
| 26 | + _l = l; | |
| 27 | + } | |
| 28 | + | |
| 29 | + public async Task<IActionResult> Index() | |
| 30 | + { | |
| 31 | + var users = await _userManager.Users.OrderBy(u => u.Email).ToListAsync(); | |
| 32 | + var userList = new List<AdminUserViewModel>(); | |
| 33 | + | |
| 34 | + foreach (var user in users) | |
| 35 | + { | |
| 36 | + var roles = await _userManager.GetRolesAsync(user); | |
| 37 | + userList.Add(new AdminUserViewModel | |
| 38 | + { | |
| 39 | + Id = user.Id, | |
| 40 | + Email = user.Email!, | |
| 41 | + FirstName = user.FirstName, | |
| 42 | + LastName = user.LastName, | |
| 43 | + Roles = roles.ToList() | |
| 44 | + }); | |
| 45 | + } | |
| 46 | + | |
| 47 | + var vm = new AdminUserIndexViewModel | |
| 48 | + { | |
| 49 | + Title = _l["Users"].Value, | |
| 50 | + Users = userList | |
| 51 | + }; | |
| 52 | + return View(vm); | |
| 53 | + } | |
| 54 | + | |
| 55 | + public async Task<IActionResult> EditRoles(Guid id) | |
| 56 | + { | |
| 57 | + var user = await _userManager.FindByIdAsync(id.ToString()); | |
| 58 | + if (user == null) return NotFound(); | |
| 59 | + | |
| 60 | + var userRoles = await _userManager.GetRolesAsync(user); | |
| 61 | + var allRoles = await _roleManager.Roles.OrderBy(r => r.Name).ToListAsync(); | |
| 62 | + | |
| 63 | + var vm = new AdminEditRolesViewModel | |
| 64 | + { | |
| 65 | + Title = _l["Edit user roles"].Value, | |
| 66 | + UserEmail = user.Email!, | |
| 67 | + UserName = $"{user.FirstName} {user.LastName}", | |
| 68 | + Roles = allRoles.Select(r => new RoleAssignmentViewModel | |
| 69 | + { | |
| 70 | + RoleName = r.Name!, | |
| 71 | + IsAssigned = userRoles.Contains(r.Name!) | |
| 72 | + }).ToList() | |
| 73 | + }; | |
| 74 | + return View(vm); | |
| 75 | + } | |
| 76 | + | |
| 77 | + [HttpPost] | |
| 78 | + [ValidateAntiForgeryToken] | |
| 79 | + public async Task<IActionResult> EditRoles(Guid id, AdminEditRolesViewModel vm) | |
| 80 | + { | |
| 81 | + var user = await _userManager.FindByIdAsync(id.ToString()); | |
| 82 | + if (user == null) return NotFound(); | |
| 83 | + | |
| 84 | + var currentRoles = await _userManager.GetRolesAsync(user); | |
| 85 | + | |
| 86 | + foreach (var role in vm.Roles) | |
| 87 | + { | |
| 88 | + if (role.IsAssigned && !currentRoles.Contains(role.RoleName)) | |
| 89 | + { | |
| 90 | + await _userManager.AddToRoleAsync(user, role.RoleName); | |
| 91 | + } | |
| 92 | + else if (!role.IsAssigned && currentRoles.Contains(role.RoleName)) | |
| 93 | + { | |
| 94 | + await _userManager.RemoveFromRoleAsync(user, role.RoleName); | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 98 | + return RedirectToAction(nameof(Index)); | |
| 99 | + } | |
| 100 | + | |
| 101 | + public async Task<IActionResult> Details(Guid id) | |
| 102 | + { | |
| 103 | + var user = await _userManager.FindByIdAsync(id.ToString()); | |
| 104 | + if (user == null) return NotFound(); | |
| 105 | + | |
| 106 | + var roles = await _userManager.GetRolesAsync(user); | |
| 107 | + var vm = new AdminUserDetailsViewModel | |
| 108 | + { | |
| 109 | + Title = _l["User details"].Value, | |
| 110 | + Id = user.Id, | |
| 111 | + Email = user.Email!, | |
| 112 | + FirstName = user.FirstName, | |
| 113 | + LastName = user.LastName, | |
| 114 | + Roles = roles.ToList() | |
| 115 | + }; | |
| 116 | + return View(vm); | |
| 117 | + } | |
| 118 | + | |
| 119 | + public async Task<IActionResult> Edit(Guid id) | |
| 120 | + { | |
| 121 | + var user = await _userManager.FindByIdAsync(id.ToString()); | |
| 122 | + if (user == null) return NotFound(); | |
| 123 | + | |
| 124 | + var vm = new AdminUserEditViewModel | |
| 125 | + { | |
| 126 | + Title = _l["Edit user"].Value, | |
| 127 | + Id = user.Id, | |
| 128 | + Email = user.Email!, | |
| 129 | + FirstName = user.FirstName, | |
| 130 | + LastName = user.LastName | |
| 131 | + }; | |
| 132 | + return View(vm); | |
| 133 | + } | |
| 134 | + | |
| 135 | + [HttpPost] | |
| 136 | + [ValidateAntiForgeryToken] | |
| 137 | + public async Task<IActionResult> Edit(Guid id, AdminUserEditViewModel vm) | |
| 138 | + { | |
| 139 | + if (id != vm.Id) return NotFound(); | |
| 140 | + | |
| 141 | + if (!ModelState.IsValid) | |
| 142 | + { | |
| 143 | + vm.Title = _l["Edit user"].Value; | |
| 144 | + return View(vm); | |
| 145 | + } | |
| 146 | + | |
| 147 | + var user = await _userManager.FindByIdAsync(id.ToString()); | |
| 148 | + if (user == null) return NotFound(); | |
| 149 | + | |
| 150 | + user.FirstName = vm.FirstName; | |
| 151 | + user.LastName = vm.LastName; | |
| 152 | + | |
| 153 | + var result = await _userManager.UpdateAsync(user); | |
| 154 | + if (!result.Succeeded) | |
| 155 | + { | |
| 156 | + foreach (var err in result.Errors) | |
| 157 | + ModelState.AddModelError(string.Empty, err.Description); | |
| 158 | + vm.Title = _l["Edit user"].Value; | |
| 159 | + return View(vm); | |
| 160 | + } | |
| 161 | + | |
| 162 | + return RedirectToAction(nameof(Index)); | |
| 163 | + } | |
| 164 | + | |
| 165 | + public async Task<IActionResult> Delete(Guid id) | |
| 166 | + { | |
| 167 | + var user = await _userManager.FindByIdAsync(id.ToString()); | |
| 168 | + if (user == null) return NotFound(); | |
| 169 | + | |
| 170 | + var roles = await _userManager.GetRolesAsync(user); | |
| 171 | + var vm = new AdminUserDetailsViewModel | |
| 172 | + { | |
| 173 | + Title = _l["Delete user"].Value, | |
| 174 | + Id = user.Id, | |
| 175 | + Email = user.Email!, | |
| 176 | + FirstName = user.FirstName, | |
| 177 | + LastName = user.LastName, | |
| 178 | + Roles = roles.ToList() | |
| 179 | + }; | |
| 180 | + return View(vm); | |
| 181 | + } | |
| 182 | + | |
| 183 | + [HttpPost, ActionName("Delete")] | |
| 184 | + [ValidateAntiForgeryToken] | |
| 185 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 186 | + { | |
| 187 | + var user = await _userManager.FindByIdAsync(id.ToString()); | |
| 188 | + if (user == null) return NotFound(); | |
| 189 | + | |
| 190 | + var result = await _userManager.DeleteAsync(user); | |
| 191 | + if (!result.Succeeded) | |
| 192 | + { | |
| 193 | + TempData["Error"] = string.Join("; ", result.Errors.Select(e => e.Description)); | |
| 194 | + } | |
| 195 | + return RedirectToAction(nameof(Index)); | |
| 196 | + } | |
| 197 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Admin/Controllers/WishlistController.cs +129 −0
| @@ -0,0 +1,129 @@ | ||
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 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 WishlistController : Controller | |
| 19 | +{ | |
| 20 | + private readonly IWishlistAdminService _service; | |
| 21 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 22 | + | |
| 23 | + public WishlistController(IWishlistAdminService 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 items = await _service.GetAllAsync(search); | |
| 32 | + | |
| 33 | + var vm = new AdminWishlistIndexViewModel | |
| 34 | + { | |
| 35 | + Title = _l["Wishlist"].Value, | |
| 36 | + Items = items.OrderByDescending(w => w.Id).ToList(), | |
| 37 | + CurrentSearch = search | |
| 38 | + }; | |
| 39 | + return View(vm); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public async Task<IActionResult> Details(Guid id) | |
| 43 | + { | |
| 44 | + var item = await _service.GetByIdAsync(id); | |
| 45 | + if (item == null) return NotFound(); | |
| 46 | + | |
| 47 | + return View(new AdminDetailsViewModel<TripWishlistItemBllDto> | |
| 48 | + { | |
| 49 | + Title = _l["Wishlist item details"].Value, | |
| 50 | + Item = item | |
| 51 | + }); | |
| 52 | + } | |
| 53 | + | |
| 54 | + public async Task<IActionResult> Create() | |
| 55 | + { | |
| 56 | + var vm = new AdminWishlistFormViewModel | |
| 57 | + { | |
| 58 | + Title = _l["New wishlist item"].Value, | |
| 59 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 60 | + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name") | |
| 61 | + }; | |
| 62 | + return View(vm); | |
| 63 | + } | |
| 64 | + | |
| 65 | + [HttpPost] | |
| 66 | + [ValidateAntiForgeryToken] | |
| 67 | + public async Task<IActionResult> Create(AdminWishlistFormViewModel vm) | |
| 68 | + { | |
| 69 | + if (ModelState.IsValid) | |
| 70 | + { | |
| 71 | + await _service.CreateAsync(vm.Item); | |
| 72 | + return RedirectToAction(nameof(Index)); | |
| 73 | + } | |
| 74 | + vm.Title = _l["New wishlist item"].Value; | |
| 75 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"); | |
| 76 | + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name"); | |
| 77 | + return View(vm); | |
| 78 | + } | |
| 79 | + | |
| 80 | + public async Task<IActionResult> Edit(Guid id) | |
| 81 | + { | |
| 82 | + var item = await _service.GetByIdAsync(id); | |
| 83 | + if (item == null) return NotFound(); | |
| 84 | + var vm = new AdminWishlistFormViewModel | |
| 85 | + { | |
| 86 | + Title = _l["Edit wishlist item"].Value, | |
| 87 | + Item = item, | |
| 88 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 89 | + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name") | |
| 90 | + }; | |
| 91 | + return View(vm); | |
| 92 | + } | |
| 93 | + | |
| 94 | + [HttpPost] | |
| 95 | + [ValidateAntiForgeryToken] | |
| 96 | + public async Task<IActionResult> Edit(Guid id, AdminWishlistFormViewModel vm) | |
| 97 | + { | |
| 98 | + if (id != vm.Item.Id) return NotFound(); | |
| 99 | + if (ModelState.IsValid) | |
| 100 | + { | |
| 101 | + await _service.UpdateAsync(vm.Item); | |
| 102 | + return RedirectToAction(nameof(Index)); | |
| 103 | + } | |
| 104 | + vm.Title = _l["Edit wishlist item"].Value; | |
| 105 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"); | |
| 106 | + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name"); | |
| 107 | + return View(vm); | |
| 108 | + } | |
| 109 | + | |
| 110 | + public async Task<IActionResult> Delete(Guid id) | |
| 111 | + { | |
| 112 | + var item = await _service.GetByIdAsync(id); | |
| 113 | + if (item == null) return NotFound(); | |
| 114 | + | |
| 115 | + return View(new AdminDeleteViewModel<TripWishlistItemBllDto> | |
| 116 | + { | |
| 117 | + Title = _l["Delete wishlist item"].Value, | |
| 118 | + Item = item | |
| 119 | + }); | |
| 120 | + } | |
| 121 | + | |
| 122 | + [HttpPost, ActionName("Delete")] | |
| 123 | + [ValidateAntiForgeryToken] | |
| 124 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 125 | + { | |
| 126 | + await _service.DeleteAsync(id); | |
| 127 | + return RedirectToAction(nameof(Index)); | |
| 128 | + } | |
| 129 | +} |
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 +126 −0
| @@ -0,0 +1,126 @@ | ||
| 1 | +@using Microsoft.AspNetCore.Identity | |
| 2 | +@inject SignInManager<AppUser> _signInManager | |
| 3 | +@{ | |
| 4 | + var pageTitle = (Model as ITitledViewModel)?.Title; | |
| 5 | + if (string.IsNullOrWhiteSpace(pageTitle)) pageTitle = "Admin"; | |
| 6 | + var ctx = ViewContext.RouteData.Values; | |
| 7 | + var currentController = (ctx["controller"] as string ?? "").ToLowerInvariant(); | |
| 8 | + bool IsActive(string name) => currentController == name.ToLowerInvariant(); | |
| 9 | +} | |
| 10 | + | |
| 11 | +<!DOCTYPE html> | |
| 12 | +<html lang="@Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName"> | |
| 13 | +<head> | |
| 14 | + <meta charset="utf-8" /> | |
| 15 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 16 | + <meta name="theme-color" content="#e8604c" /> | |
| 17 | + <title>@pageTitle - SplitApp Admin</title> | |
| 18 | + | |
| 19 | + <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 20 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 21 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| 22 | + | |
| 23 | + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" /> | |
| 24 | + <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> | |
| 25 | + <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> | |
| 26 | + <link rel="stylesheet" href="~/css/splitapp-design.css" asp-append-version="true" /> | |
| 27 | + <link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" /> | |
| 28 | +</head> | |
| 29 | +<body class="admin-body"> | |
| 30 | + <div id="sa-toast-container" class="sa-toast-container"></div> | |
| 31 | + <div id="sa-tempdata-messages" style="display:none" | |
| 32 | + data-success="@TempData["Success"]" | |
| 33 | + data-error="@TempData["Error"]" | |
| 34 | + data-warning="@TempData["Warning"]"></div> | |
| 35 | + | |
| 36 | + <div class="admin-shell"> | |
| 37 | + <aside class="admin-sidebar"> | |
| 38 | + <div class="admin-sidebar-brand"> | |
| 39 | + <i class="bi bi-airplane-fill"></i> | |
| 40 | + <span>SplitApp</span> | |
| 41 | + <small class="admin-sidebar-brand-sub">Admin</small> | |
| 42 | + </div> | |
| 43 | + <nav class="admin-sidebar-nav"> | |
| 44 | + <a class="admin-nav-link @(IsActive("Dashboard") ? "active" : "")" asp-area="Admin" asp-controller="Dashboard" asp-action="Index"> | |
| 45 | + <i class="bi bi-speedometer2"></i><span>@Localizer["Dashboard"]</span> | |
| 46 | + </a> | |
| 47 | + <div class="admin-nav-section">@Localizer["Core"]</div> | |
| 48 | + <a class="admin-nav-link @(IsActive("Trips") ? "active" : "")" asp-area="Admin" asp-controller="Trips" asp-action="Index"> | |
| 49 | + <i class="bi bi-suitcase-lg"></i><span>@Localizer["Trips"]</span> | |
| 50 | + </a> | |
| 51 | + <a class="admin-nav-link @(IsActive("Expenses") ? "active" : "")" asp-area="Admin" asp-controller="Expenses" asp-action="Index"> | |
| 52 | + <i class="bi bi-cash-coin"></i><span>@Localizer["Expenses"]</span> | |
| 53 | + </a> | |
| 54 | + <a class="admin-nav-link @(IsActive("BudgetCategories") ? "active" : "")" asp-area="Admin" asp-controller="BudgetCategories" asp-action="Index"> | |
| 55 | + <i class="bi bi-tags"></i><span>@Localizer["Budget Categories"]</span> | |
| 56 | + </a> | |
| 57 | + <a class="admin-nav-link @(IsActive("Currencies") ? "active" : "")" asp-area="Admin" asp-controller="Currencies" asp-action="Index"> | |
| 58 | + <i class="bi bi-currency-exchange"></i><span>@Localizer["Currencies"]</span> | |
| 59 | + </a> | |
| 60 | + <div class="admin-nav-section">@Localizer["Activity"]</div> | |
| 61 | + <a class="admin-nav-link @(IsActive("Polls") ? "active" : "")" asp-area="Admin" asp-controller="Polls" asp-action="Index"> | |
| 62 | + <i class="bi bi-bar-chart"></i><span>@Localizer["Polls"]</span> | |
| 63 | + </a> | |
| 64 | + <a class="admin-nav-link @(IsActive("Wishlist") ? "active" : "")" asp-area="Admin" asp-controller="Wishlist" asp-action="Index"> | |
| 65 | + <i class="bi bi-stars"></i><span>@Localizer["Wishlist"]</span> | |
| 66 | + </a> | |
| 67 | + <a class="admin-nav-link @(IsActive("Invitations") ? "active" : "")" asp-area="Admin" asp-controller="Invitations" asp-action="Index"> | |
| 68 | + <i class="bi bi-envelope"></i><span>@Localizer["Invitations"]</span> | |
| 69 | + </a> | |
| 70 | + <div class="admin-nav-section">@Localizer["Settlements"]</div> | |
| 71 | + <a class="admin-nav-link @(IsActive("SettlementPlans") ? "active" : "")" asp-area="Admin" asp-controller="SettlementPlans" asp-action="Index"> | |
| 72 | + <i class="bi bi-diagram-3"></i><span>@Localizer["Settlement Plans"]</span> | |
| 73 | + </a> | |
| 74 | + <a class="admin-nav-link @(IsActive("SettlementPayments") ? "active" : "")" asp-area="Admin" asp-controller="SettlementPayments" asp-action="Index"> | |
| 75 | + <i class="bi bi-credit-card-2-front"></i><span>@Localizer["Settlement Payments"]</span> | |
| 76 | + </a> | |
| 77 | + <a class="admin-nav-link @(IsActive("SplitPresets") ? "active" : "")" asp-area="Admin" asp-controller="SplitPresets" asp-action="Index"> | |
| 78 | + <i class="bi bi-pie-chart"></i><span>@Localizer["Split Presets"]</span> | |
| 79 | + </a> | |
| 80 | + <a class="admin-nav-link @(IsActive("TripParticipants") ? "active" : "")" asp-area="Admin" asp-controller="TripParticipants" asp-action="Index"> | |
| 81 | + <i class="bi bi-people"></i><span>@Localizer["Trip Participants"]</span> | |
| 82 | + </a> | |
| 83 | + <div class="admin-nav-section">@Localizer["System"]</div> | |
| 84 | + <a class="admin-nav-link @(IsActive("Users") ? "active" : "")" asp-area="Admin" asp-controller="Users" asp-action="Index"> | |
| 85 | + <i class="bi bi-person-gear"></i><span>@Localizer["Users"]</span> | |
| 86 | + </a> | |
| 87 | + </nav> | |
| 88 | + <div class="admin-sidebar-footer"> | |
| 89 | + <a asp-area="" asp-controller="Home" asp-action="Index" class="admin-back-link"> | |
| 90 | + <i class="bi bi-arrow-left"></i>@Localizer["Back to site"] | |
| 91 | + </a> | |
| 92 | + </div> | |
| 93 | + </aside> | |
| 94 | + | |
| 95 | + <div class="admin-main"> | |
| 96 | + <header class="admin-topbar"> | |
| 97 | + <button class="admin-topbar-toggle d-md-none" type="button" onclick="document.body.classList.toggle('admin-sidebar-open')" aria-label="Toggle sidebar"> | |
| 98 | + <i class="bi bi-list"></i> | |
| 99 | + </button> | |
| 100 | + <div class="admin-topbar-title"> | |
| 101 | + <i class="bi bi-shield-lock"></i> @pageTitle | |
| 102 | + </div> | |
| 103 | + <div class="admin-topbar-actions"> | |
| 104 | + <partial name="_LanguageSelection" /> | |
| 105 | + <partial name="_LoginPartial" /> | |
| 106 | + </div> | |
| 107 | + </header> | |
| 108 | + | |
| 109 | + <main role="main" class="admin-content sa-animate-fade-in"> | |
| 110 | + @RenderBody() | |
| 111 | + </main> | |
| 112 | + | |
| 113 | + <footer class="admin-footer"> | |
| 114 | + <span><i class="bi bi-airplane-fill me-1"></i> SplitApp Admin © 2026 TalTech</span> | |
| 115 | + <span>@Thread.CurrentThread.CurrentUICulture.Name</span> | |
| 116 | + </footer> | |
| 117 | + </div> | |
| 118 | + </div> | |
| 119 | + | |
| 120 | + <script src="~/lib/jquery/dist/jquery.min.js"></script> | |
| 121 | + <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> | |
| 122 | + <script src="~/js/splitapp.js" asp-append-version="true"></script> | |
| 123 | + <script src="~/js/site.js" asp-append-version="true"></script> | |
| 124 | + @await RenderSectionAsync("Scripts", required: false) | |
| 125 | +</body> | |
| 126 | +</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 +16 −0
| @@ -0,0 +1,16 @@ | ||
| 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.Modules.Users.Domain.Entities | |
| 11 | +@using SplitApp.Shared.Kernel.Localization | |
| 12 | +@using Microsoft.Extensions.Localization | |
| 13 | +@using Microsoft.AspNetCore.Mvc.Localization | |
| 14 | +@using Microsoft.AspNetCore.Mvc.Rendering | |
| 15 | +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers | |
| 16 | +@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/Areas/Identity/Pages/Account/Register.cshtml +59 −0
| @@ -0,0 +1,59 @@ | ||
| 1 | +@page | |
| 2 | +@model RegisterModel | |
| 3 | +@{ | |
| 4 | + ViewData["Title"] = "Register"; | |
| 5 | +} | |
| 6 | + | |
| 7 | +<div class="row justify-content-center"> | |
| 8 | + <div class="col-md-5"> | |
| 9 | + <div class="card shadow-sm mt-4"> | |
| 10 | + <div class="card-body p-4"> | |
| 11 | + <h2 class="text-center mb-3">@ViewData["Title"]</h2> | |
| 12 | + <form id="registerForm" asp-route-returnUrl="@Model.ReturnUrl" method="post"> | |
| 13 | + <div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div> | |
| 14 | + | |
| 15 | + <div class="row mb-3"> | |
| 16 | + <div class="col-6"> | |
| 17 | + <label asp-for="Input.FirstName" class="form-label"></label> | |
| 18 | + <input asp-for="Input.FirstName" class="form-control" autocomplete="given-name" placeholder="First name" /> | |
| 19 | + <span asp-validation-for="Input.FirstName" class="text-danger"></span> | |
| 20 | + </div> | |
| 21 | + <div class="col-6"> | |
| 22 | + <label asp-for="Input.LastName" class="form-label"></label> | |
| 23 | + <input asp-for="Input.LastName" class="form-control" autocomplete="family-name" placeholder="Last name" /> | |
| 24 | + <span asp-validation-for="Input.LastName" class="text-danger"></span> | |
| 25 | + </div> | |
| 26 | + </div> | |
| 27 | + | |
| 28 | + <div class="mb-3"> | |
| 29 | + <label asp-for="Input.Email" class="form-label"></label> | |
| 30 | + <input asp-for="Input.Email" class="form-control" autocomplete="email" aria-required="true" placeholder="name@example.com" /> | |
| 31 | + <span asp-validation-for="Input.Email" class="text-danger"></span> | |
| 32 | + </div> | |
| 33 | + | |
| 34 | + <div class="mb-3"> | |
| 35 | + <label asp-for="Input.Password" class="form-label"></label> | |
| 36 | + <input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Password" /> | |
| 37 | + <span asp-validation-for="Input.Password" class="text-danger"></span> | |
| 38 | + </div> | |
| 39 | + | |
| 40 | + <div class="mb-3"> | |
| 41 | + <label asp-for="Input.ConfirmPassword" class="form-label"></label> | |
| 42 | + <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Confirm password" /> | |
| 43 | + <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span> | |
| 44 | + </div> | |
| 45 | + | |
| 46 | + <button id="registerSubmit" type="submit" class="btn btn-primary w-100 py-2">Register</button> | |
| 47 | + </form> | |
| 48 | + | |
| 49 | + <div class="text-center mt-3"> | |
| 50 | + <p class="mb-0">Already have an account? <a asp-page="./Login" asp-route-returnUrl="@Model.ReturnUrl">Log in</a></p> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + </div> | |
| 54 | + </div> | |
| 55 | +</div> | |
| 56 | + | |
| 57 | +@section Scripts { | |
| 58 | + <partial name="_ValidationScriptsPartial" /> | |
| 59 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/Account/Register.cshtml.cs +93 −0
| @@ -0,0 +1,93 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 3 | +using Microsoft.AspNetCore.Identity; | |
| 4 | +using Microsoft.AspNetCore.Mvc; | |
| 5 | +using Microsoft.AspNetCore.Mvc.RazorPages; | |
| 6 | + | |
| 7 | +namespace SplitApp.WebApp.Areas.Identity.Pages.Account; | |
| 8 | + | |
| 9 | +public class RegisterModel : PageModel | |
| 10 | +{ | |
| 11 | + private readonly SignInManager<AppUser> _signInManager; | |
| 12 | + private readonly UserManager<AppUser> _userManager; | |
| 13 | + | |
| 14 | + public RegisterModel( | |
| 15 | + UserManager<AppUser> userManager, | |
| 16 | + SignInManager<AppUser> signInManager) | |
| 17 | + { | |
| 18 | + _userManager = userManager; | |
| 19 | + _signInManager = signInManager; | |
| 20 | + } | |
| 21 | + | |
| 22 | + [BindProperty] | |
| 23 | + public InputModel Input { get; set; } = default!; | |
| 24 | + | |
| 25 | + public string? ReturnUrl { get; set; } | |
| 26 | + | |
| 27 | + public class InputModel | |
| 28 | + { | |
| 29 | + [Required] | |
| 30 | + [StringLength(128)] | |
| 31 | + [Display(Name = "First Name")] | |
| 32 | + public string FirstName { get; set; } = default!; | |
| 33 | + | |
| 34 | + [Required] | |
| 35 | + [StringLength(128)] | |
| 36 | + [Display(Name = "Last Name")] | |
| 37 | + public string LastName { get; set; } = default!; | |
| 38 | + | |
| 39 | + [Required] | |
| 40 | + [EmailAddress] | |
| 41 | + [Display(Name = "Email")] | |
| 42 | + public string Email { get; set; } = default!; | |
| 43 | + | |
| 44 | + [Required] | |
| 45 | + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] | |
| 46 | + [DataType(DataType.Password)] | |
| 47 | + [Display(Name = "Password")] | |
| 48 | + public string Password { get; set; } = default!; | |
| 49 | + | |
| 50 | + [DataType(DataType.Password)] | |
| 51 | + [Display(Name = "Confirm password")] | |
| 52 | + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] | |
| 53 | + public string ConfirmPassword { get; set; } = default!; | |
| 54 | + } | |
| 55 | + | |
| 56 | + public void OnGet(string? returnUrl = null) | |
| 57 | + { | |
| 58 | + ReturnUrl = returnUrl; | |
| 59 | + } | |
| 60 | + | |
| 61 | + public async Task<IActionResult> OnPostAsync(string? returnUrl = null) | |
| 62 | + { | |
| 63 | + returnUrl ??= Url.Content("~/"); | |
| 64 | + | |
| 65 | + if (ModelState.IsValid) | |
| 66 | + { | |
| 67 | + var user = new AppUser | |
| 68 | + { | |
| 69 | + UserName = Input.Email, | |
| 70 | + Email = Input.Email, | |
| 71 | + FirstName = Input.FirstName, | |
| 72 | + LastName = Input.LastName, | |
| 73 | + EmailConfirmed = true | |
| 74 | + }; | |
| 75 | + | |
| 76 | + var result = await _userManager.CreateAsync(user, Input.Password); | |
| 77 | + | |
| 78 | + if (result.Succeeded) | |
| 79 | + { | |
| 80 | + await _userManager.AddToRoleAsync(user, "user"); | |
| 81 | + await _signInManager.SignInAsync(user, isPersistent: false); | |
| 82 | + return LocalRedirect(returnUrl); | |
| 83 | + } | |
| 84 | + | |
| 85 | + foreach (var error in result.Errors) | |
| 86 | + { | |
| 87 | + ModelState.AddModelError(string.Empty, error.Description); | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + return Page(); | |
| 92 | + } | |
| 93 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/_ViewImports.cshtml +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@using Microsoft.AspNetCore.Identity | |
| 2 | +@using SplitApp.Modules.Users.Domain.Entities | |
| 3 | +@using SplitApp.WebApp.Areas.Identity.Pages | |
| 4 | +@using SplitApp.WebApp.Areas.Identity.Pages.Account | |
| 5 | +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers |
added SplitApp.Modular/src/SplitApp.WebApp/Areas/Identity/Pages/_ViewStart.cshtml +3 −0
| @@ -0,0 +1,3 @@ | ||
| 1 | +@{ | |
| 2 | + Layout = "/Views/Shared/_Layout.cshtml"; | |
| 3 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/BudgetController.cs +213 −0
| @@ -0,0 +1,213 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 9 | +using SplitApp.Shared.Kernel.Domain; | |
| 10 | +using SplitApp.Shared.Kernel.Localization; | |
| 11 | +using Microsoft.AspNetCore.Authorization; | |
| 12 | +using Microsoft.AspNetCore.Identity; | |
| 13 | +using Microsoft.AspNetCore.Mvc; | |
| 14 | + | |
| 15 | +namespace SplitApp.WebApp.Controllers; | |
| 16 | + | |
| 17 | +[Authorize] | |
| 18 | +public class BudgetController : Controller | |
| 19 | +{ | |
| 20 | + private readonly ITripService _tripService; | |
| 21 | + private readonly IBudgetCategoryService _budgetCategoryService; | |
| 22 | + private readonly UserManager<AppUser> _userManager; | |
| 23 | + | |
| 24 | + public BudgetController( | |
| 25 | + ITripService tripService, | |
| 26 | + IBudgetCategoryService budgetCategoryService, | |
| 27 | + UserManager<AppUser> userManager) | |
| 28 | + { | |
| 29 | + _tripService = tripService; | |
| 30 | + _budgetCategoryService = budgetCategoryService; | |
| 31 | + _userManager = userManager; | |
| 32 | + } | |
| 33 | + | |
| 34 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 35 | + | |
| 36 | + // GET: Budget?tripId=xxx | |
| 37 | + public async Task<IActionResult> Index(Guid tripId) | |
| 38 | + { | |
| 39 | + var userId = GetUserId(); | |
| 40 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 41 | + | |
| 42 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 43 | + if (trip == null) return NotFound(); | |
| 44 | + | |
| 45 | + var categories = await _budgetCategoryService.GetByTripIdAsync(tripId, userId); | |
| 46 | + | |
| 47 | + var model = categories.Select(c => new BudgetCategoryViewModel | |
| 48 | + { | |
| 49 | + Id = c.Id, | |
| 50 | + Name = c.Name, | |
| 51 | + IconName = c.IconName, | |
| 52 | + PlannedAmount = c.PlannedAmount ?? 0, | |
| 53 | + SpentAmount = c.SpentAmount, | |
| 54 | + DisplayOrder = c.DisplayOrder | |
| 55 | + }).ToList(); | |
| 56 | + | |
| 57 | + ViewData["TripId"] = tripId; | |
| 58 | + ViewData["TripName"] = trip.Name; | |
| 59 | + ViewData["TotalPlanned"] = model.Sum(m => m.PlannedAmount); | |
| 60 | + ViewData["TotalSpent"] = model.Sum(m => m.SpentAmount); | |
| 61 | + ViewData["IsOrganizer"] = await _tripService.IsOrganizerAsync(tripId, userId); | |
| 62 | + | |
| 63 | + return View(model); | |
| 64 | + } | |
| 65 | + | |
| 66 | + // GET: Budget/CreateCategory?tripId=xxx | |
| 67 | + public async Task<IActionResult> CreateCategory(Guid tripId) | |
| 68 | + { | |
| 69 | + var userId = GetUserId(); | |
| 70 | + if (!await _tripService.IsOrganizerAsync(tripId, userId)) return Forbid(); | |
| 71 | + | |
| 72 | + ViewData["TripId"] = tripId; | |
| 73 | + return View(new BudgetCategoryBllDto { TripId = tripId }); | |
| 74 | + } | |
| 75 | + | |
| 76 | + // POST: Budget/CreateCategory | |
| 77 | + [HttpPost] | |
| 78 | + [ValidateAntiForgeryToken] | |
| 79 | + public async Task<IActionResult> CreateCategory(BudgetCategoryBllDto category, string? name) | |
| 80 | + { | |
| 81 | + var userId = GetUserId(); | |
| 82 | + | |
| 83 | + category.Name = new LangStr(name ?? "", "en"); | |
| 84 | + ModelState.Remove(nameof(BudgetCategory.Name)); | |
| 85 | + | |
| 86 | + if (string.IsNullOrWhiteSpace(name)) | |
| 87 | + ModelState.AddModelError(nameof(BudgetCategory.Name), "Name is required."); | |
| 88 | + | |
| 89 | + if (ModelState.IsValid) | |
| 90 | + { | |
| 91 | + var (created, errorCode) = await _budgetCategoryService.CreateAsync(category, userId); | |
| 92 | + if (created == null) | |
| 93 | + { | |
| 94 | + if (errorCode == "forbidden") return Forbid(); | |
| 95 | + return NotFound(); | |
| 96 | + } | |
| 97 | + return RedirectToAction(nameof(Index), new { tripId = category.TripId }); | |
| 98 | + } | |
| 99 | + | |
| 100 | + ViewData["TripId"] = category.TripId; | |
| 101 | + return View(category); | |
| 102 | + } | |
| 103 | + | |
| 104 | + // GET: Budget/EditCategory/5 | |
| 105 | + public async Task<IActionResult> EditCategory(Guid id) | |
| 106 | + { | |
| 107 | + var userId = GetUserId(); | |
| 108 | + | |
| 109 | + var category = await _budgetCategoryService.GetByIdAsync(id); | |
| 110 | + if (category == null) return NotFound(); | |
| 111 | + | |
| 112 | + if (!await _tripService.IsOrganizerAsync(category.TripId, userId)) return Forbid(); | |
| 113 | + | |
| 114 | + ViewData["TripId"] = category.TripId; | |
| 115 | + return View(category); | |
| 116 | + } | |
| 117 | + | |
| 118 | + // POST: Budget/EditCategory/5 | |
| 119 | + [HttpPost] | |
| 120 | + [ValidateAntiForgeryToken] | |
| 121 | + public async Task<IActionResult> EditCategory(Guid id, BudgetCategoryBllDto category, string? name) | |
| 122 | + { | |
| 123 | + if (id != category.Id) return NotFound(); | |
| 124 | + | |
| 125 | + var userId = GetUserId(); | |
| 126 | + | |
| 127 | + var existing = await _budgetCategoryService.GetByIdAsync(id); | |
| 128 | + if (existing == null) return NotFound(); | |
| 129 | + | |
| 130 | + if (!await _tripService.IsOrganizerAsync(existing.TripId, userId)) return Forbid(); | |
| 131 | + | |
| 132 | + category.Name = new LangStr(name ?? "", "en"); | |
| 133 | + ModelState.Remove(nameof(BudgetCategory.Name)); | |
| 134 | + | |
| 135 | + if (string.IsNullOrWhiteSpace(name)) | |
| 136 | + ModelState.AddModelError(nameof(BudgetCategory.Name), "Name is required."); | |
| 137 | + | |
| 138 | + if (ModelState.IsValid) | |
| 139 | + { | |
| 140 | + var (ok, errorCode) = await _budgetCategoryService.UpdateAsync(id, category, userId); | |
| 141 | + if (!ok) | |
| 142 | + { | |
| 143 | + return errorCode switch | |
| 144 | + { | |
| 145 | + "forbidden" => Forbid(), | |
| 146 | + _ => NotFound() | |
| 147 | + }; | |
| 148 | + } | |
| 149 | + return RedirectToAction(nameof(Index), new { tripId = existing.TripId }); | |
| 150 | + } | |
| 151 | + | |
| 152 | + ViewData["TripId"] = existing.TripId; | |
| 153 | + return View(category); | |
| 154 | + } | |
| 155 | + | |
| 156 | + // GET: Budget/DeleteCategory/5 | |
| 157 | + public async Task<IActionResult> DeleteCategory(Guid id) | |
| 158 | + { | |
| 159 | + var userId = GetUserId(); | |
| 160 | + | |
| 161 | + var category = await _budgetCategoryService.GetByIdAsync(id); | |
| 162 | + if (category == null) return NotFound(); | |
| 163 | + | |
| 164 | + if (!await _tripService.IsOrganizerAsync(category.TripId, userId)) return Forbid(); | |
| 165 | + | |
| 166 | + ViewData["TripId"] = category.TripId; | |
| 167 | + return View(category); | |
| 168 | + } | |
| 169 | + | |
| 170 | + // POST: Budget/DeleteCategory/5 | |
| 171 | + [HttpPost, ActionName("DeleteCategory")] | |
| 172 | + [ValidateAntiForgeryToken] | |
| 173 | + public async Task<IActionResult> DeleteCategoryConfirmed(Guid id) | |
| 174 | + { | |
| 175 | + var userId = GetUserId(); | |
| 176 | + | |
| 177 | + var category = await _budgetCategoryService.GetByIdAsync(id); | |
| 178 | + if (category == null) return NotFound(); | |
| 179 | + | |
| 180 | + var tripId = category.TripId; | |
| 181 | + var (ok, errorCode) = await _budgetCategoryService.DeleteAsync(id, userId); | |
| 182 | + if (!ok) | |
| 183 | + { | |
| 184 | + return errorCode switch | |
| 185 | + { | |
| 186 | + "forbidden" => Forbid(), | |
| 187 | + _ => NotFound() | |
| 188 | + }; | |
| 189 | + } | |
| 190 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 191 | + } | |
| 192 | +} | |
| 193 | + | |
| 194 | +public class BudgetCategoryViewModel | |
| 195 | +{ | |
| 196 | + public Guid Id { get; set; } | |
| 197 | + public string Name { get; set; } = default!; | |
| 198 | + public string? IconName { get; set; } | |
| 199 | + public decimal PlannedAmount { get; set; } | |
| 200 | + public decimal SpentAmount { get; set; } | |
| 201 | + public int DisplayOrder { get; set; } | |
| 202 | + | |
| 203 | + public int ProgressPercentage => | |
| 204 | + PlannedAmount > 0 ? (int)(SpentAmount / PlannedAmount * 100) : 0; | |
| 205 | + | |
| 206 | + public string ProgressBarClass => | |
| 207 | + ProgressPercentage switch | |
| 208 | + { | |
| 209 | + > 90 => "bg-danger", | |
| 210 | + > 70 => "bg-warning", | |
| 211 | + _ => "bg-success" | |
| 212 | + }; | |
| 213 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/ExpensesController.cs +270 −0
| @@ -0,0 +1,270 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 9 | +using Microsoft.AspNetCore.Authorization; | |
| 10 | +using Microsoft.AspNetCore.Identity; | |
| 11 | +using Microsoft.AspNetCore.Mvc; | |
| 12 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 13 | + | |
| 14 | +namespace SplitApp.WebApp.Controllers; | |
| 15 | + | |
| 16 | +[Authorize] | |
| 17 | +public class ExpensesController : Controller | |
| 18 | +{ | |
| 19 | + private readonly IExpenseService _expenseService; | |
| 20 | + private readonly ITripService _tripService; | |
| 21 | + private readonly IBudgetCategoryService _budgetCategoryService; | |
| 22 | + private readonly UserManager<AppUser> _userManager; | |
| 23 | + | |
| 24 | + public ExpensesController( | |
| 25 | + IExpenseService expenseService, | |
| 26 | + ITripService tripService, | |
| 27 | + IBudgetCategoryService budgetCategoryService, | |
| 28 | + UserManager<AppUser> userManager) | |
| 29 | + { | |
| 30 | + _expenseService = expenseService; | |
| 31 | + _tripService = tripService; | |
| 32 | + _budgetCategoryService = budgetCategoryService; | |
| 33 | + _userManager = userManager; | |
| 34 | + } | |
| 35 | + | |
| 36 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 37 | + | |
| 38 | + // GET: Expenses?tripId=xxx | |
| 39 | + public async Task<IActionResult> Index(Guid tripId) | |
| 40 | + { | |
| 41 | + var userId = GetUserId(); | |
| 42 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 43 | + | |
| 44 | + var trip = await _tripService.GetByIdWithDetailsAsync(tripId, userId); | |
| 45 | + if (trip == null) return NotFound(); | |
| 46 | + | |
| 47 | + var expenses = await _expenseService.GetByTripIdAsync(tripId, userId); | |
| 48 | + | |
| 49 | + var model = new ExpensesIndexViewModel | |
| 50 | + { | |
| 51 | + TripId = tripId, | |
| 52 | + TripName = trip.Name, | |
| 53 | + DefaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR", | |
| 54 | + CurrencySymbol = trip.DefaultCurrency?.Symbol ?? "\u20ac", | |
| 55 | + TripStatus = trip.Status.ToString(), | |
| 56 | + Expenses = expenses | |
| 57 | + }; | |
| 58 | + | |
| 59 | + return View(model); | |
| 60 | + } | |
| 61 | + | |
| 62 | + // GET: Expenses/Create?tripId=xxx | |
| 63 | + public async Task<IActionResult> Create(Guid tripId) | |
| 64 | + { | |
| 65 | + var userId = GetUserId(); | |
| 66 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 67 | + | |
| 68 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 69 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 70 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 71 | + | |
| 72 | + await PopulateDropdowns(tripId); | |
| 73 | + ViewData["TripId"] = tripId; | |
| 74 | + | |
| 75 | + var expense = new ExpenseBllDto | |
| 76 | + { | |
| 77 | + TripId = tripId, | |
| 78 | + PaidByUserId = userId, | |
| 79 | + ExpenseDate = DateTime.UtcNow, | |
| 80 | + SplitMethod = ESplitMethod.EqualAll | |
| 81 | + }; | |
| 82 | + | |
| 83 | + return View(expense); | |
| 84 | + } | |
| 85 | + | |
| 86 | + // POST: Expenses/Create | |
| 87 | + [HttpPost] | |
| 88 | + [ValidateAntiForgeryToken] | |
| 89 | + public async Task<IActionResult> Create(ExpenseBllDto expense, Guid[] selectedParticipants, decimal[] splitAmounts, decimal[] splitPercentages) | |
| 90 | + { | |
| 91 | + var userId = GetUserId(); | |
| 92 | + if (!await _tripService.IsParticipantAsync(expense.TripId, userId)) return Forbid(); | |
| 93 | + | |
| 94 | + if (ModelState.IsValid) | |
| 95 | + { | |
| 96 | + await _expenseService.CreateExpenseWithSplitsAsync(expense, selectedParticipants, splitAmounts, splitPercentages); | |
| 97 | + return RedirectToAction(nameof(Index), new { tripId = expense.TripId }); | |
| 98 | + } | |
| 99 | + | |
| 100 | + await PopulateDropdowns(expense.TripId, expense.BudgetCategoryId, expense.CurrencyId); | |
| 101 | + ViewData["TripId"] = expense.TripId; | |
| 102 | + return View(expense); | |
| 103 | + } | |
| 104 | + | |
| 105 | + // GET: Expenses/Edit/5 | |
| 106 | + public async Task<IActionResult> Edit(Guid id) | |
| 107 | + { | |
| 108 | + var userId = GetUserId(); | |
| 109 | + | |
| 110 | + var expense = await _expenseService.GetRawByIdAsync(id); | |
| 111 | + if (expense == null) return NotFound(); | |
| 112 | + | |
| 113 | + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid(); | |
| 114 | + | |
| 115 | + var trip = await _tripService.GetRawByIdAsync(expense.TripId); | |
| 116 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 117 | + return RedirectToAction(nameof(Index), new { tripId = expense.TripId }); | |
| 118 | + | |
| 119 | + await PopulateDropdowns(expense.TripId, expense.BudgetCategoryId, expense.CurrencyId); | |
| 120 | + ViewData["TripId"] = expense.TripId; | |
| 121 | + | |
| 122 | + return View(expense); | |
| 123 | + } | |
| 124 | + | |
| 125 | + // POST: Expenses/Edit/5 | |
| 126 | + [HttpPost] | |
| 127 | + [ValidateAntiForgeryToken] | |
| 128 | + public async Task<IActionResult> Edit(Guid id, ExpenseBllDto expense) | |
| 129 | + { | |
| 130 | + if (id != expense.Id) return NotFound(); | |
| 131 | + | |
| 132 | + var userId = GetUserId(); | |
| 133 | + | |
| 134 | + if (ModelState.IsValid) | |
| 135 | + { | |
| 136 | + var result = await _expenseService.UpdateExpenseAsync(id, expense, userId); | |
| 137 | + if (!result.success) | |
| 138 | + { | |
| 139 | + return result.errorCode switch | |
| 140 | + { | |
| 141 | + "notfound" => NotFound(), | |
| 142 | + "forbidden" => Forbid(), | |
| 143 | + "badstatus" => RedirectToAction(nameof(Index), new { tripId = expense.TripId }), | |
| 144 | + _ => NotFound() | |
| 145 | + }; | |
| 146 | + } | |
| 147 | + // Need to get tripId from existing since it's not in incoming after success | |
| 148 | + var updated = await _expenseService.GetRawByIdAsync(id); | |
| 149 | + return RedirectToAction(nameof(Index), new { tripId = updated?.TripId ?? expense.TripId }); | |
| 150 | + } | |
| 151 | + | |
| 152 | + var existingEntity = await _expenseService.GetRawByIdAsync(id); | |
| 153 | + if (existingEntity == null) return NotFound(); | |
| 154 | + | |
| 155 | + await PopulateDropdowns(existingEntity.TripId, expense.BudgetCategoryId, expense.CurrencyId); | |
| 156 | + ViewData["TripId"] = existingEntity.TripId; | |
| 157 | + return View(expense); | |
| 158 | + } | |
| 159 | + | |
| 160 | + // GET: Expenses/Delete/5 | |
| 161 | + public async Task<IActionResult> Delete(Guid id) | |
| 162 | + { | |
| 163 | + var userId = GetUserId(); | |
| 164 | + | |
| 165 | + var expense = await _expenseService.GetByIdWithDetailsAsync(id, userId); | |
| 166 | + if (expense == null) | |
| 167 | + { | |
| 168 | + // Either NotFound or not a participant | |
| 169 | + var raw = await _expenseService.GetRawByIdAsync(id); | |
| 170 | + if (raw == null) return NotFound(); | |
| 171 | + return Forbid(); | |
| 172 | + } | |
| 173 | + | |
| 174 | + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid(); | |
| 175 | + | |
| 176 | + var trip = await _tripService.GetRawByIdAsync(expense.TripId); | |
| 177 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 178 | + return RedirectToAction(nameof(Index), new { tripId = expense.TripId }); | |
| 179 | + | |
| 180 | + ViewData["TripId"] = expense.TripId; | |
| 181 | + return View(expense); | |
| 182 | + } | |
| 183 | + | |
| 184 | + // POST: Expenses/Delete/5 | |
| 185 | + [HttpPost, ActionName("Delete")] | |
| 186 | + [ValidateAntiForgeryToken] | |
| 187 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 188 | + { | |
| 189 | + var userId = GetUserId(); | |
| 190 | + | |
| 191 | + var expense = await _expenseService.GetRawByIdAsync(id); | |
| 192 | + if (expense == null) return NotFound(); | |
| 193 | + | |
| 194 | + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid(); | |
| 195 | + | |
| 196 | + var trip = await _tripService.GetRawByIdAsync(expense.TripId); | |
| 197 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 198 | + return RedirectToAction(nameof(Index), new { tripId = expense.TripId }); | |
| 199 | + | |
| 200 | + var tripId = expense.TripId; | |
| 201 | + | |
| 202 | + await _expenseService.DeleteExpenseWithSplitsAsync(id); | |
| 203 | + | |
| 204 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 205 | + } | |
| 206 | + | |
| 207 | + private async Task PopulateDropdowns(Guid tripId, Guid? selectedCategoryId = null, Guid? selectedCurrencyId = null) | |
| 208 | + { | |
| 209 | + var categories = await _budgetCategoryService.GetByTripIdRawAsync(tripId); | |
| 210 | + ViewData["BudgetCategoryId"] = new SelectList(categories, "Id", "Name", selectedCategoryId); | |
| 211 | + | |
| 212 | + var currencies = await _tripService.GetAllCurrenciesAsync(); | |
| 213 | + ViewData["CurrencyId"] = new SelectList(currencies, "Id", "Code", selectedCurrencyId); | |
| 214 | + | |
| 215 | + ViewData["SplitMethods"] = new SelectList( | |
| 216 | + Enum.GetValues<ESplitMethod>().Select(e => new { Value = (int)e, Text = e.ToString() }), | |
| 217 | + "Value", "Text"); | |
| 218 | + | |
| 219 | + var userId = GetUserId(); | |
| 220 | + var participants = await _tripService.GetParticipantsAsync(tripId, userId); | |
| 221 | + | |
| 222 | + ViewData["Participants"] = participants; | |
| 223 | + ViewData["PaidByUserId"] = new SelectList( | |
| 224 | + participants.Select(p => new | |
| 225 | + { | |
| 226 | + Value = p.UserId, | |
| 227 | + Text = $"{p.User!.FirstName} {p.User.LastName}" | |
| 228 | + }), | |
| 229 | + "Value", "Text", userId); | |
| 230 | + | |
| 231 | + // Load split presets for this trip | |
| 232 | + var presets = await _expenseService.GetSplitPresetsByTripAsync(tripId, userId); | |
| 233 | + ViewData["SplitPresets"] = presets; | |
| 234 | + } | |
| 235 | + | |
| 236 | + // POST: Expenses/SavePreset | |
| 237 | + [HttpPost] | |
| 238 | + [ValidateAntiForgeryToken] | |
| 239 | + public async Task<IActionResult> SavePreset(Guid tripId, string presetName, int splitMethod, Guid[] selectedParticipants, decimal[] splitPercentages) | |
| 240 | + { | |
| 241 | + var userId = GetUserId(); | |
| 242 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 243 | + | |
| 244 | + await _expenseService.SavePresetAsync(tripId, presetName, (ESplitMethod)splitMethod, selectedParticipants, splitPercentages, userId); | |
| 245 | + return RedirectToAction(nameof(Create), new { tripId }); | |
| 246 | + } | |
| 247 | + | |
| 248 | + // POST: Expenses/DeletePreset | |
| 249 | + [HttpPost] | |
| 250 | + [ValidateAntiForgeryToken] | |
| 251 | + public async Task<IActionResult> DeletePreset(Guid id, Guid tripId) | |
| 252 | + { | |
| 253 | + var userId = GetUserId(); | |
| 254 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 255 | + | |
| 256 | + await _expenseService.DeletePresetAsync(id, tripId, userId); | |
| 257 | + | |
| 258 | + return RedirectToAction(nameof(Create), new { tripId }); | |
| 259 | + } | |
| 260 | +} | |
| 261 | + | |
| 262 | +public class ExpensesIndexViewModel | |
| 263 | +{ | |
| 264 | + public Guid TripId { get; set; } | |
| 265 | + public string TripName { get; set; } = default!; | |
| 266 | + public string DefaultCurrencyCode { get; set; } = default!; | |
| 267 | + public string CurrencySymbol { get; set; } = default!; | |
| 268 | + public string TripStatus { get; set; } = default!; | |
| 269 | + public List<ExpenseBllDto> Expenses { get; set; } = new(); | |
| 270 | +} |
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 +181 −0
| @@ -0,0 +1,181 @@ | ||
| 1 | +using SplitApp.WebApp.Application.Services; | |
| 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 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 7 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 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 MembersController : Controller | |
| 16 | +{ | |
| 17 | + private readonly ITripService _tripService; | |
| 18 | + private readonly IInvitationService _invitationService; | |
| 19 | + private readonly UserManager<AppUser> _userManager; | |
| 20 | + | |
| 21 | + public MembersController( | |
| 22 | + ITripService tripService, | |
| 23 | + IInvitationService invitationService, | |
| 24 | + UserManager<AppUser> userManager) | |
| 25 | + { | |
| 26 | + _tripService = tripService; | |
| 27 | + _invitationService = invitationService; | |
| 28 | + _userManager = userManager; | |
| 29 | + } | |
| 30 | + | |
| 31 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 32 | + | |
| 33 | + // GET: Members?tripId=xxx | |
| 34 | + public async Task<IActionResult> Index(Guid tripId) | |
| 35 | + { | |
| 36 | + var userId = GetUserId(); | |
| 37 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 38 | + | |
| 39 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 40 | + if (trip == null) return NotFound(); | |
| 41 | + | |
| 42 | + var participants = await _tripService.GetParticipantsAsync(tripId, userId); | |
| 43 | + | |
| 44 | + var isOrganizer = participants.Any(p => p.UserId == userId && p.Role == EParticipantRole.Organizer); | |
| 45 | + | |
| 46 | + var pendingInvitations = await _invitationService.GetPendingByTripIdAsync(tripId, userId); | |
| 47 | + | |
| 48 | + ViewData["TripId"] = tripId; | |
| 49 | + ViewData["TripName"] = trip.Name; | |
| 50 | + ViewData["CurrentUserId"] = userId; | |
| 51 | + ViewData["IsOrganizer"] = isOrganizer; | |
| 52 | + ViewData["PendingInvitations"] = pendingInvitations; | |
| 53 | + | |
| 54 | + return View(participants); | |
| 55 | + } | |
| 56 | + | |
| 57 | + // GET: Members/Invite?tripId=xxx | |
| 58 | + public async Task<IActionResult> Invite(Guid tripId) | |
| 59 | + { | |
| 60 | + var userId = GetUserId(); | |
| 61 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 62 | + | |
| 63 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 64 | + if (trip == null) return NotFound(); | |
| 65 | + | |
| 66 | + ViewData["TripId"] = tripId; | |
| 67 | + ViewData["TripName"] = trip.Name; | |
| 68 | + | |
| 69 | + return View(); | |
| 70 | + } | |
| 71 | + | |
| 72 | + // POST: Members/Invite | |
| 73 | + [HttpPost] | |
| 74 | + [ValidateAntiForgeryToken] | |
| 75 | + public async Task<IActionResult> Invite(Guid tripId, int _) | |
| 76 | + { | |
| 77 | + var userId = GetUserId(); | |
| 78 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 79 | + | |
| 80 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 81 | + if (trip == null) return NotFound(); | |
| 82 | + | |
| 83 | + var invitation = await _invitationService.CreateInvitationAsync(tripId, userId); | |
| 84 | + | |
| 85 | + var inviteUrl = Url.Action("AcceptInvitation", "Members", new { token = invitation.Token }, Request.Scheme); | |
| 86 | + | |
| 87 | + ViewData["TripId"] = tripId; | |
| 88 | + ViewData["TripName"] = trip.Name; | |
| 89 | + ViewData["InviteUrl"] = inviteUrl; | |
| 90 | + ViewData["Token"] = invitation.Token; | |
| 91 | + | |
| 92 | + return View("InviteGenerated"); | |
| 93 | + } | |
| 94 | + | |
| 95 | + // GET: Members/AcceptInvitation?token=xxx | |
| 96 | + [AllowAnonymous] | |
| 97 | + public async Task<IActionResult> AcceptInvitation(string token) | |
| 98 | + { | |
| 99 | + var invitation = await _invitationService.GetByTokenAsync(token); | |
| 100 | + | |
| 101 | + if (invitation == null) return NotFound(); | |
| 102 | + | |
| 103 | + if (invitation.Status != EInvitationStatus.Pending || invitation.ExpiresAt < DateTime.UtcNow) | |
| 104 | + { | |
| 105 | + ViewData["Error"] = "This invitation has expired or is no longer valid."; | |
| 106 | + return View("InvitationInvalid"); | |
| 107 | + } | |
| 108 | + | |
| 109 | + // Load trip for the view | |
| 110 | + invitation.Trip = await _tripService.GetRawByIdAsync(invitation.TripId); | |
| 111 | + | |
| 112 | + ViewData["Token"] = token; | |
| 113 | + return View(invitation); | |
| 114 | + } | |
| 115 | + | |
| 116 | + // POST: Members/AcceptInvitation | |
| 117 | + [HttpPost] | |
| 118 | + [ValidateAntiForgeryToken] | |
| 119 | + public async Task<IActionResult> AcceptInvitation(string token, int _) | |
| 120 | + { | |
| 121 | + var userId = GetUserId(); | |
| 122 | + | |
| 123 | + var invitation = await _invitationService.GetByTokenAsync(token); | |
| 124 | + if (invitation == null) return NotFound(); | |
| 125 | + | |
| 126 | + var success = await _invitationService.AcceptInvitationAsync(token, userId); | |
| 127 | + | |
| 128 | + if (!success) | |
| 129 | + { | |
| 130 | + ViewData["Error"] = "This invitation has expired or is no longer valid."; | |
| 131 | + return View("InvitationInvalid"); | |
| 132 | + } | |
| 133 | + | |
| 134 | + return RedirectToAction("Details", "Trips", new { id = invitation.TripId }); | |
| 135 | + } | |
| 136 | + | |
| 137 | + // POST: Members/Remove | |
| 138 | + [HttpPost] | |
| 139 | + [ValidateAntiForgeryToken] | |
| 140 | + public async Task<IActionResult> Remove(Guid tripId, Guid participantId) | |
| 141 | + { | |
| 142 | + var userId = GetUserId(); | |
| 143 | + | |
| 144 | + if (!await _tripService.IsOrganizerAsync(tripId, userId)) return Forbid(); | |
| 145 | + | |
| 146 | + var participant = await _tripService.GetParticipantByIdAsync(participantId); | |
| 147 | + if (participant == null || participant.TripId != tripId) return NotFound(); | |
| 148 | + | |
| 149 | + // Cannot remove yourself | |
| 150 | + if (participant.UserId == userId) | |
| 151 | + { | |
| 152 | + TempData["Error"] = "You cannot remove yourself from the trip."; | |
| 153 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 154 | + } | |
| 155 | + | |
| 156 | + await _tripService.RemoveParticipantByIdAsync(tripId, participantId, userId); | |
| 157 | + | |
| 158 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 159 | + } | |
| 160 | + | |
| 161 | + // POST: Members/RevokeInvitation | |
| 162 | + [HttpPost] | |
| 163 | + [ValidateAntiForgeryToken] | |
| 164 | + public async Task<IActionResult> RevokeInvitation(Guid id, Guid tripId) | |
| 165 | + { | |
| 166 | + var userId = GetUserId(); | |
| 167 | + | |
| 168 | + var (ok, errorCode) = await _invitationService.RevokeInvitationAsync(id, tripId, userId); | |
| 169 | + if (!ok) | |
| 170 | + { | |
| 171 | + return errorCode switch | |
| 172 | + { | |
| 173 | + "forbidden" => Forbid(), | |
| 174 | + "notfound" => NotFound(), | |
| 175 | + _ => NotFound() | |
| 176 | + }; | |
| 177 | + } | |
| 178 | + | |
| 179 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 180 | + } | |
| 181 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/PollsClientController.cs +148 −0
| @@ -0,0 +1,148 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 9 | +using Microsoft.AspNetCore.Authorization; | |
| 10 | +using Microsoft.AspNetCore.Identity; | |
| 11 | +using Microsoft.AspNetCore.Mvc; | |
| 12 | + | |
| 13 | +namespace SplitApp.WebApp.Controllers; | |
| 14 | + | |
| 15 | +[Authorize] | |
| 16 | +public class PollsClientController : Controller | |
| 17 | +{ | |
| 18 | + private readonly ITripService _tripService; | |
| 19 | + private readonly IPollService _pollService; | |
| 20 | + private readonly UserManager<AppUser> _userManager; | |
| 21 | + | |
| 22 | + public PollsClientController( | |
| 23 | + ITripService tripService, | |
| 24 | + IPollService pollService, | |
| 25 | + UserManager<AppUser> userManager) | |
| 26 | + { | |
| 27 | + _tripService = tripService; | |
| 28 | + _pollService = pollService; | |
| 29 | + _userManager = userManager; | |
| 30 | + } | |
| 31 | + | |
| 32 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 33 | + | |
| 34 | + // GET: PollsClient?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.GetByIdAsync(tripId, userId); | |
| 41 | + if (trip == null) return NotFound(); | |
| 42 | + | |
| 43 | + var polls = await _pollService.GetByTripIdAsync(tripId, userId); | |
| 44 | + | |
| 45 | + ViewData["TripId"] = tripId; | |
| 46 | + ViewData["TripName"] = trip.Name; | |
| 47 | + | |
| 48 | + return View(polls); | |
| 49 | + } | |
| 50 | + | |
| 51 | + // GET: PollsClient/Create?tripId=xxx | |
| 52 | + public async Task<IActionResult> Create(Guid tripId) | |
| 53 | + { | |
| 54 | + var userId = GetUserId(); | |
| 55 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 56 | + | |
| 57 | + ViewData["TripId"] = tripId; | |
| 58 | + return View(); | |
| 59 | + } | |
| 60 | + | |
| 61 | + // POST: PollsClient/Create | |
| 62 | + [HttpPost] | |
| 63 | + [ValidateAntiForgeryToken] | |
| 64 | + public async Task<IActionResult> Create(Guid tripId, string question, bool allowMultipleVotes, | |
| 65 | + bool isAnonymous, string option1, string option2, string? option3, string? option4, string? option5) | |
| 66 | + { | |
| 67 | + var userId = GetUserId(); | |
| 68 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 69 | + | |
| 70 | + if (string.IsNullOrWhiteSpace(question) || string.IsNullOrWhiteSpace(option1) || | |
| 71 | + string.IsNullOrWhiteSpace(option2)) | |
| 72 | + { | |
| 73 | + ModelState.AddModelError("", "Question and at least 2 options are required."); | |
| 74 | + ViewData["TripId"] = tripId; | |
| 75 | + return View(); | |
| 76 | + } | |
| 77 | + | |
| 78 | + var poll = new TripPollBllDto | |
| 79 | + { | |
| 80 | + TripId = tripId, | |
| 81 | + CreatedByUserId = userId, | |
| 82 | + Question = question, | |
| 83 | + AllowMultipleVotes = allowMultipleVotes, | |
| 84 | + IsAnonymous = isAnonymous | |
| 85 | + }; | |
| 86 | + | |
| 87 | + var options = new[] { option1, option2, option3, option4, option5 } | |
| 88 | + .Where(o => !string.IsNullOrWhiteSpace(o)) | |
| 89 | + .Select(o => o!) | |
| 90 | + .ToList(); | |
| 91 | + | |
| 92 | + var created = await _pollService.CreatePollWithOptionsAsync(poll, options); | |
| 93 | + | |
| 94 | + return RedirectToAction(nameof(Details), new { id = created.Id }); | |
| 95 | + } | |
| 96 | + | |
| 97 | + // GET: PollsClient/Details/5 | |
| 98 | + public async Task<IActionResult> Details(Guid id) | |
| 99 | + { | |
| 100 | + var userId = GetUserId(); | |
| 101 | + var poll = await _pollService.GetByIdWithDetailsAsync(id, userId); | |
| 102 | + | |
| 103 | + if (poll == null) return NotFound(); | |
| 104 | + | |
| 105 | + ViewData["TripId"] = poll.TripId; | |
| 106 | + ViewData["UserId"] = userId; | |
| 107 | + ViewData["IsCreator"] = poll.CreatedByUserId == userId; | |
| 108 | + | |
| 109 | + return View(poll); | |
| 110 | + } | |
| 111 | + | |
| 112 | + // POST: PollsClient/Vote | |
| 113 | + [HttpPost] | |
| 114 | + [ValidateAntiForgeryToken] | |
| 115 | + public async Task<IActionResult> Vote(Guid pollId, Guid optionId) | |
| 116 | + { | |
| 117 | + var userId = GetUserId(); | |
| 118 | + var poll = await _pollService.GetByIdAsync(pollId, userId); | |
| 119 | + | |
| 120 | + if (poll == null) return NotFound(); | |
| 121 | + | |
| 122 | + if (poll.ClosedAt != null) return RedirectToAction(nameof(Details), new { id = pollId }); | |
| 123 | + | |
| 124 | + await _pollService.ToggleVoteAsync(pollId, optionId, userId); | |
| 125 | + | |
| 126 | + return RedirectToAction(nameof(Details), new { id = pollId }); | |
| 127 | + } | |
| 128 | + | |
| 129 | + // POST: PollsClient/Close/5 | |
| 130 | + [HttpPost] | |
| 131 | + [ValidateAntiForgeryToken] | |
| 132 | + public async Task<IActionResult> Close(Guid id) | |
| 133 | + { | |
| 134 | + var userId = GetUserId(); | |
| 135 | + var (ok, errorCode) = await _pollService.ClosePollAsync(id, userId, organizerAllowed: false); | |
| 136 | + if (!ok) | |
| 137 | + { | |
| 138 | + return errorCode switch | |
| 139 | + { | |
| 140 | + "notfound" => NotFound(), | |
| 141 | + "forbidden" => Forbid(), | |
| 142 | + _ => NotFound() | |
| 143 | + }; | |
| 144 | + } | |
| 145 | + | |
| 146 | + return RedirectToAction(nameof(Details), new { id }); | |
| 147 | + } | |
| 148 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/SettlementController.cs +194 −0
| @@ -0,0 +1,194 @@ | ||
| 1 | +using SplitApp.WebApp.Application.Services; | |
| 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 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 7 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 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 SettlementController : Controller | |
| 16 | +{ | |
| 17 | + private readonly ITripService _tripService; | |
| 18 | + private readonly ISettlementService _settlementService; | |
| 19 | + private readonly UserManager<AppUser> _userManager; | |
| 20 | + | |
| 21 | + public SettlementController( | |
| 22 | + ITripService tripService, | |
| 23 | + ISettlementService settlementService, | |
| 24 | + UserManager<AppUser> userManager) | |
| 25 | + { | |
| 26 | + _tripService = tripService; | |
| 27 | + _settlementService = settlementService; | |
| 28 | + _userManager = userManager; | |
| 29 | + } | |
| 30 | + | |
| 31 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 32 | + | |
| 33 | + // GET: Settlement?tripId=xxx | |
| 34 | + public async Task<IActionResult> Index(Guid tripId) | |
| 35 | + { | |
| 36 | + var userId = GetUserId(); | |
| 37 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 38 | + | |
| 39 | + var trip = await _tripService.GetByIdWithDetailsAsync(tripId, userId); | |
| 40 | + if (trip == null) return NotFound(); | |
| 41 | + | |
| 42 | + // Calculate balances via service | |
| 43 | + var balanceEntries = await _settlementService.CalculateBalancesAsync(tripId); | |
| 44 | + | |
| 45 | + // Map to view model | |
| 46 | + var balances = balanceEntries.Select(b => new SettlementBalanceViewModel | |
| 47 | + { | |
| 48 | + UserId = b.UserId, | |
| 49 | + UserName = b.UserName, | |
| 50 | + TotalPaid = b.TotalPaid, | |
| 51 | + TotalOwed = b.TotalOwed | |
| 52 | + }).OrderByDescending(b => b.NetBalance).ToList(); | |
| 53 | + | |
| 54 | + // Get existing settlement plan (only exists after trip is finalized) | |
| 55 | + var latestPlan = await _settlementService.GetLatestPlanRawAsync(tripId); | |
| 56 | + | |
| 57 | + // Preview payments when trip is active (not saved to DB) | |
| 58 | + var previewPayments = trip.Status == ETripStatus.Active | |
| 59 | + ? _settlementService.PreviewSettlement(balanceEntries) | |
| 60 | + : new List<PreviewPayment>(); | |
| 61 | + | |
| 62 | + var model = new SettlementIndexViewModel | |
| 63 | + { | |
| 64 | + TripId = tripId, | |
| 65 | + TripName = trip.Name, | |
| 66 | + CurrencySymbol = trip.DefaultCurrency?.Symbol ?? "$", | |
| 67 | + TripStatus = trip.Status.ToString(), | |
| 68 | + IsOrganizer = await _tripService.IsOrganizerAsync(tripId, userId), | |
| 69 | + CurrentUserId = userId, | |
| 70 | + Balances = balances, | |
| 71 | + LatestPlan = latestPlan, | |
| 72 | + PreviewPayments = previewPayments | |
| 73 | + }; | |
| 74 | + | |
| 75 | + return View(model); | |
| 76 | + } | |
| 77 | + | |
| 78 | + // POST: Settlement/Finalize | |
| 79 | + [HttpPost] | |
| 80 | + [ValidateAntiForgeryToken] | |
| 81 | + public async Task<IActionResult> Finalize(Guid tripId) | |
| 82 | + { | |
| 83 | + var userId = GetUserId(); | |
| 84 | + var (ok, errorCode) = await _tripService.FinalizeTripAsync(tripId, userId); | |
| 85 | + if (!ok) | |
| 86 | + { | |
| 87 | + return errorCode switch | |
| 88 | + { | |
| 89 | + "forbidden" => Forbid(), | |
| 90 | + "notfound" => NotFound(), | |
| 91 | + "badstatus" => BadRequest(), | |
| 92 | + _ => NotFound() | |
| 93 | + }; | |
| 94 | + } | |
| 95 | + | |
| 96 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 97 | + } | |
| 98 | + | |
| 99 | + // POST: Settlement/Reopen | |
| 100 | + [HttpPost] | |
| 101 | + [ValidateAntiForgeryToken] | |
| 102 | + public async Task<IActionResult> Reopen(Guid tripId) | |
| 103 | + { | |
| 104 | + var userId = GetUserId(); | |
| 105 | + var (ok, errorCode) = await _tripService.ReopenTripAsync(tripId, userId); | |
| 106 | + if (!ok) | |
| 107 | + { | |
| 108 | + return errorCode switch | |
| 109 | + { | |
| 110 | + "forbidden" => Forbid(), | |
| 111 | + "notfound" => NotFound(), | |
| 112 | + "badstatus" => RedirectToAction(nameof(Index), new { tripId }), | |
| 113 | + "payments-confirmed" => RedirectToAction(nameof(Index), new { tripId }), | |
| 114 | + _ => NotFound() | |
| 115 | + }; | |
| 116 | + } | |
| 117 | + | |
| 118 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // POST: Settlement/MarkPaid/5 | |
| 122 | + [HttpPost] | |
| 123 | + [ValidateAntiForgeryToken] | |
| 124 | + public async Task<IActionResult> MarkPaid(Guid paymentId) | |
| 125 | + { | |
| 126 | + var userId = GetUserId(); | |
| 127 | + var payment = await _settlementService.GetPaymentByIdAsync(paymentId); | |
| 128 | + if (payment == null) return NotFound(); | |
| 129 | + | |
| 130 | + var plan = await _settlementService.GetPlanByIdAsync(payment.SettlementPlanId); | |
| 131 | + if (plan == null) return NotFound(); | |
| 132 | + | |
| 133 | + var (ok, errorCode) = await _settlementService.MarkPaidGuardedAsync(paymentId, userId); | |
| 134 | + if (!ok) | |
| 135 | + { | |
| 136 | + return errorCode switch | |
| 137 | + { | |
| 138 | + "forbidden" => Forbid(), | |
| 139 | + "notfound" => NotFound(), | |
| 140 | + _ => NotFound() | |
| 141 | + }; | |
| 142 | + } | |
| 143 | + | |
| 144 | + return RedirectToAction(nameof(Index), new { tripId = plan.TripId }); | |
| 145 | + } | |
| 146 | + | |
| 147 | + // POST: Settlement/ConfirmReceipt/5 | |
| 148 | + [HttpPost] | |
| 149 | + [ValidateAntiForgeryToken] | |
| 150 | + public async Task<IActionResult> ConfirmReceipt(Guid paymentId) | |
| 151 | + { | |
| 152 | + var userId = GetUserId(); | |
| 153 | + var payment = await _settlementService.GetPaymentByIdAsync(paymentId); | |
| 154 | + if (payment == null) return NotFound(); | |
| 155 | + | |
| 156 | + var plan = await _settlementService.GetPlanByIdAsync(payment.SettlementPlanId); | |
| 157 | + if (plan == null) return NotFound(); | |
| 158 | + | |
| 159 | + var (ok, errorCode) = await _settlementService.ConfirmPaymentGuardedAsync(paymentId, userId); | |
| 160 | + if (!ok) | |
| 161 | + { | |
| 162 | + return errorCode switch | |
| 163 | + { | |
| 164 | + "forbidden" => Forbid(), | |
| 165 | + "notfound" => NotFound(), | |
| 166 | + _ => NotFound() | |
| 167 | + }; | |
| 168 | + } | |
| 169 | + | |
| 170 | + return RedirectToAction(nameof(Index), new { tripId = plan.TripId }); | |
| 171 | + } | |
| 172 | +} | |
| 173 | + | |
| 174 | +public class SettlementIndexViewModel | |
| 175 | +{ | |
| 176 | + public Guid TripId { get; set; } | |
| 177 | + public string TripName { get; set; } = default!; | |
| 178 | + public string CurrencySymbol { get; set; } = default!; | |
| 179 | + public string TripStatus { get; set; } = default!; | |
| 180 | + public bool IsOrganizer { get; set; } | |
| 181 | + public Guid CurrentUserId { get; set; } | |
| 182 | + public List<SettlementBalanceViewModel> Balances { get; set; } = new(); | |
| 183 | + public SplitApp.WebApp.Application.DTO.SettlementPlanBllDto? LatestPlan { get; set; } | |
| 184 | + public List<PreviewPayment> PreviewPayments { get; set; } = new(); | |
| 185 | +} | |
| 186 | + | |
| 187 | +public class SettlementBalanceViewModel | |
| 188 | +{ | |
| 189 | + public Guid UserId { get; set; } | |
| 190 | + public string UserName { get; set; } = default!; | |
| 191 | + public decimal TotalPaid { get; set; } | |
| 192 | + public decimal TotalOwed { get; set; } | |
| 193 | + public decimal NetBalance => TotalPaid - TotalOwed; | |
| 194 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/TripsController.cs +264 −0
| @@ -0,0 +1,264 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 9 | +using Microsoft.AspNetCore.Authorization; | |
| 10 | +using Microsoft.AspNetCore.Identity; | |
| 11 | +using Microsoft.AspNetCore.Mvc; | |
| 12 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 13 | +using SplitApp.WebApp.Hosting.Helpers; | |
| 14 | + | |
| 15 | +namespace SplitApp.WebApp.Controllers; | |
| 16 | + | |
| 17 | +[Authorize] | |
| 18 | +public class TripsController : Controller | |
| 19 | +{ | |
| 20 | + private readonly ITripService _tripService; | |
| 21 | + private readonly IExpenseService _expenseService; | |
| 22 | + private readonly IBudgetCategoryService _budgetCategoryService; | |
| 23 | + private readonly UserManager<AppUser> _userManager; | |
| 24 | + | |
| 25 | + public TripsController( | |
| 26 | + ITripService tripService, | |
| 27 | + IExpenseService expenseService, | |
| 28 | + IBudgetCategoryService budgetCategoryService, | |
| 29 | + UserManager<AppUser> userManager) | |
| 30 | + { | |
| 31 | + _tripService = tripService; | |
| 32 | + _expenseService = expenseService; | |
| 33 | + _budgetCategoryService = budgetCategoryService; | |
| 34 | + _userManager = userManager; | |
| 35 | + } | |
| 36 | + | |
| 37 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 38 | + | |
| 39 | + // GET: Trips | |
| 40 | + public async Task<IActionResult> Index() | |
| 41 | + { | |
| 42 | + var userId = GetUserId(); | |
| 43 | + | |
| 44 | + var trips = await _tripService.GetUserTripsAsync(userId); | |
| 45 | + | |
| 46 | + var model = new List<TripIndexViewModel>(); | |
| 47 | + foreach (var trip in trips) | |
| 48 | + { | |
| 49 | + var participant = trip.Participants?.FirstOrDefault(p => p.UserId == userId && p.IsActive); | |
| 50 | + if (participant == null) continue; | |
| 51 | + | |
| 52 | + model.Add(new TripIndexViewModel | |
| 53 | + { | |
| 54 | + Id = trip.Id, | |
| 55 | + Name = trip.Name, | |
| 56 | + Destination = trip.Destination, | |
| 57 | + Status = trip.Status, | |
| 58 | + StartDate = trip.StartDate, | |
| 59 | + EndDate = trip.EndDate, | |
| 60 | + Role = participant.Role, | |
| 61 | + CurrencyCode = trip.DefaultCurrency?.Code ?? "" | |
| 62 | + }); | |
| 63 | + } | |
| 64 | + | |
| 65 | + return View(model); | |
| 66 | + } | |
| 67 | + | |
| 68 | + // GET: Trips/Details/5 | |
| 69 | + public async Task<IActionResult> Details(Guid id) | |
| 70 | + { | |
| 71 | + var userId = GetUserId(); | |
| 72 | + | |
| 73 | + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId); | |
| 74 | + if (trip == null) return NotFound(); | |
| 75 | + | |
| 76 | + // Get participant role | |
| 77 | + var participants = await _tripService.GetParticipantsAsync(id, userId); | |
| 78 | + var participant = participants.FirstOrDefault(p => p.UserId == userId); | |
| 79 | + | |
| 80 | + var defaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR"; | |
| 81 | + | |
| 82 | + // Get expenses with details for balance calculation | |
| 83 | + var expensesAll = await _expenseService.GetByTripIdAsync(id, userId); | |
| 84 | + | |
| 85 | + var recentExpenses = expensesAll | |
| 86 | + .OrderByDescending(e => e.ExpenseDate) | |
| 87 | + .Take(5) | |
| 88 | + .ToList(); | |
| 89 | + | |
| 90 | + var totalExpenses = expensesAll.Sum(e => | |
| 91 | + CurrencyConverter.Convert(e.Amount, e.Currency?.Code ?? defaultCurrencyCode, defaultCurrencyCode)); | |
| 92 | + | |
| 93 | + // Calculate balances for each participant | |
| 94 | + var balances = new Dictionary<Guid, SettlementBalanceViewModel>(); | |
| 95 | + foreach (var p in participants) | |
| 96 | + { | |
| 97 | + balances[p.UserId] = new SettlementBalanceViewModel | |
| 98 | + { | |
| 99 | + UserId = p.UserId, | |
| 100 | + UserName = !string.IsNullOrWhiteSpace(p.User?.FullName) | |
| 101 | + ? p.User!.FullName | |
| 102 | + : (p.User?.Email ?? "Unknown"), | |
| 103 | + TotalPaid = 0, | |
| 104 | + TotalOwed = 0 | |
| 105 | + }; | |
| 106 | + } | |
| 107 | + | |
| 108 | + foreach (var expense in expensesAll) | |
| 109 | + { | |
| 110 | + var expenseWithSplits = await _expenseService.GetByIdWithDetailsAsync(expense.Id, userId); | |
| 111 | + if (expenseWithSplits == null) continue; | |
| 112 | + | |
| 113 | + var expCurrency = expenseWithSplits.Currency?.Code ?? defaultCurrencyCode; | |
| 114 | + var convertedAmount = CurrencyConverter.Convert(expenseWithSplits.Amount, expCurrency, defaultCurrencyCode); | |
| 115 | + | |
| 116 | + if (balances.ContainsKey(expenseWithSplits.PaidByUserId)) | |
| 117 | + balances[expenseWithSplits.PaidByUserId].TotalPaid += convertedAmount; | |
| 118 | + | |
| 119 | + if (expenseWithSplits.Splits != null) | |
| 120 | + { | |
| 121 | + foreach (var split in expenseWithSplits.Splits) | |
| 122 | + { | |
| 123 | + var convertedSplit = CurrencyConverter.Convert(split.Amount, expCurrency, defaultCurrencyCode); | |
| 124 | + if (balances.ContainsKey(split.UserId)) | |
| 125 | + balances[split.UserId].TotalOwed += convertedSplit; | |
| 126 | + } | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + // Calculate budget totals (only category-assigned expenses count against budget) | |
| 131 | + var budgetCategories = await _budgetCategoryService.GetByTripIdAsync(id, userId); | |
| 132 | + var totalPlanned = budgetCategories.Sum(c => c.PlannedAmount ?? 0); | |
| 133 | + var totalBudgetSpent = budgetCategories.Sum(c => c.SpentAmount); | |
| 134 | + var budgetUsedPct = totalPlanned > 0 ? (int)(totalBudgetSpent * 100 / totalPlanned) : 0; | |
| 135 | + | |
| 136 | + // Current user's balance | |
| 137 | + var currentUserBalance = balances.ContainsKey(userId) ? balances[userId].NetBalance : 0; | |
| 138 | + | |
| 139 | + ViewData["TripId"] = id; | |
| 140 | + ViewData["TripName"] = trip.Name; | |
| 141 | + ViewData["ParticipantCount"] = participants.Count; | |
| 142 | + ViewData["RecentExpenses"] = recentExpenses; | |
| 143 | + ViewData["TotalExpenses"] = totalExpenses; | |
| 144 | + ViewData["UserRole"] = participant?.Role ?? EParticipantRole.Participant; | |
| 145 | + ViewData["Balances"] = balances.Values.OrderByDescending(b => b.NetBalance).ToList(); | |
| 146 | + ViewData["CurrentUserBalance"] = currentUserBalance; | |
| 147 | + ViewData["BudgetUsedPct"] = budgetUsedPct; | |
| 148 | + ViewData["TotalPlanned"] = totalPlanned; | |
| 149 | + ViewData["CurrencySymbol"] = trip.DefaultCurrency?.Symbol ?? "\u20ac"; | |
| 150 | + | |
| 151 | + return View(trip); | |
| 152 | + } | |
| 153 | + | |
| 154 | + // GET: Trips/Create | |
| 155 | + public async Task<IActionResult> Create() | |
| 156 | + { | |
| 157 | + await PopulateCurrencyDropdown(); | |
| 158 | + return View(); | |
| 159 | + } | |
| 160 | + | |
| 161 | + // POST: Trips/Create | |
| 162 | + [HttpPost] | |
| 163 | + [ValidateAntiForgeryToken] | |
| 164 | + public async Task<IActionResult> Create(TripBllDto trip) | |
| 165 | + { | |
| 166 | + var userId = GetUserId(); | |
| 167 | + | |
| 168 | + if (ModelState.IsValid) | |
| 169 | + { | |
| 170 | + var created = await _tripService.CreateTripAsync(trip, userId); | |
| 171 | + return RedirectToAction(nameof(Details), new { id = created.Id }); | |
| 172 | + } | |
| 173 | + | |
| 174 | + await PopulateCurrencyDropdown(trip.DefaultCurrencyId); | |
| 175 | + return View(trip); | |
| 176 | + } | |
| 177 | + | |
| 178 | + // GET: Trips/Edit/5 | |
| 179 | + public async Task<IActionResult> Edit(Guid id) | |
| 180 | + { | |
| 181 | + var userId = GetUserId(); | |
| 182 | + | |
| 183 | + var trip = await _tripService.GetByIdForOrganizerAsync(id, userId); | |
| 184 | + if (trip == null) | |
| 185 | + { | |
| 186 | + // Distinguish not-organizer vs not-found | |
| 187 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 188 | + return NotFound(); | |
| 189 | + } | |
| 190 | + | |
| 191 | + await PopulateCurrencyDropdown(trip.DefaultCurrencyId); | |
| 192 | + return View(trip); | |
| 193 | + } | |
| 194 | + | |
| 195 | + // POST: Trips/Edit/5 | |
| 196 | + [HttpPost] | |
| 197 | + [ValidateAntiForgeryToken] | |
| 198 | + public async Task<IActionResult> Edit(Guid id, TripBllDto trip) | |
| 199 | + { | |
| 200 | + if (id != trip.Id) return NotFound(); | |
| 201 | + | |
| 202 | + var userId = GetUserId(); | |
| 203 | + | |
| 204 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 205 | + | |
| 206 | + if (ModelState.IsValid) | |
| 207 | + { | |
| 208 | + var updated = await _tripService.UpdateAsync(trip, userId); | |
| 209 | + if (updated == null) return NotFound(); | |
| 210 | + return RedirectToAction(nameof(Details), new { id }); | |
| 211 | + } | |
| 212 | + | |
| 213 | + await PopulateCurrencyDropdown(trip.DefaultCurrencyId); | |
| 214 | + return View(trip); | |
| 215 | + } | |
| 216 | + | |
| 217 | + // GET: Trips/Delete/5 | |
| 218 | + public async Task<IActionResult> Delete(Guid id) | |
| 219 | + { | |
| 220 | + var userId = GetUserId(); | |
| 221 | + | |
| 222 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 223 | + | |
| 224 | + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId); | |
| 225 | + if (trip == null) return NotFound(); | |
| 226 | + | |
| 227 | + return View(trip); | |
| 228 | + } | |
| 229 | + | |
| 230 | + // POST: Trips/Delete/5 | |
| 231 | + [HttpPost, ActionName("Delete")] | |
| 232 | + [ValidateAntiForgeryToken] | |
| 233 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 234 | + { | |
| 235 | + var userId = GetUserId(); | |
| 236 | + | |
| 237 | + var success = await _tripService.DeleteAsync(id, userId); | |
| 238 | + if (!success) | |
| 239 | + { | |
| 240 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 241 | + return NotFound(); | |
| 242 | + } | |
| 243 | + | |
| 244 | + return RedirectToAction(nameof(Index)); | |
| 245 | + } | |
| 246 | + | |
| 247 | + private async Task PopulateCurrencyDropdown(Guid? selectedId = null) | |
| 248 | + { | |
| 249 | + var currencies = await _tripService.GetAllCurrenciesAsync(); | |
| 250 | + ViewData["DefaultCurrencyId"] = new SelectList(currencies, "Id", "Code", selectedId); | |
| 251 | + } | |
| 252 | +} | |
| 253 | + | |
| 254 | +public class TripIndexViewModel | |
| 255 | +{ | |
| 256 | + public Guid Id { get; set; } | |
| 257 | + public string Name { get; set; } = default!; | |
| 258 | + public string? Destination { get; set; } | |
| 259 | + public ETripStatus Status { get; set; } | |
| 260 | + public DateTime? StartDate { get; set; } | |
| 261 | + public DateTime? EndDate { get; set; } | |
| 262 | + public EParticipantRole Role { get; set; } | |
| 263 | + public string CurrencyCode { get; set; } = default!; | |
| 264 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Controllers/WishlistClientController.cs +281 −0
| @@ -0,0 +1,281 @@ | ||
| 1 | +using SplitApp.WebApp.Application.DTO; | |
| 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 SplitApp.Modules.Users.Domain.Entities; | |
| 8 | +using SplitApp.Modules.Users.Domain.Entities; | |
| 9 | +using Microsoft.AspNetCore.Authorization; | |
| 10 | +using Microsoft.AspNetCore.Identity; | |
| 11 | +using Microsoft.AspNetCore.Mvc; | |
| 12 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 13 | + | |
| 14 | +namespace SplitApp.WebApp.Controllers; | |
| 15 | + | |
| 16 | +[Authorize] | |
| 17 | +public class WishlistClientController : Controller | |
| 18 | +{ | |
| 19 | + private readonly ITripService _tripService; | |
| 20 | + private readonly IWishlistService _wishlistService; | |
| 21 | + private readonly UserManager<AppUser> _userManager; | |
| 22 | + | |
| 23 | + public WishlistClientController( | |
| 24 | + ITripService tripService, | |
| 25 | + IWishlistService wishlistService, | |
| 26 | + UserManager<AppUser> userManager) | |
| 27 | + { | |
| 28 | + _tripService = tripService; | |
| 29 | + _wishlistService = wishlistService; | |
| 30 | + _userManager = userManager; | |
| 31 | + } | |
| 32 | + | |
| 33 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 34 | + | |
| 35 | + // GET: WishlistClient?tripId=xxx | |
| 36 | + public async Task<IActionResult> Index(Guid tripId) | |
| 37 | + { | |
| 38 | + var userId = GetUserId(); | |
| 39 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 40 | + | |
| 41 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 42 | + if (trip == null) return NotFound(); | |
| 43 | + | |
| 44 | + var items = await _wishlistService.GetByTripIdAsync(tripId, userId); | |
| 45 | + | |
| 46 | + ViewData["CurrentUserId"] = userId; | |
| 47 | + | |
| 48 | + var model = items.Select(item => new WishlistItemViewModel | |
| 49 | + { | |
| 50 | + Id = item.Id, | |
| 51 | + AddedByUserId = item.AddedByUserId, | |
| 52 | + Title = item.Title, | |
| 53 | + Description = item.Description, | |
| 54 | + Category = item.Category, | |
| 55 | + Priority = item.Priority, | |
| 56 | + EstimatedCost = item.EstimatedCost, | |
| 57 | + Url = item.Url, | |
| 58 | + Location = item.Location, | |
| 59 | + IsCompleted = item.IsCompleted, | |
| 60 | + AddedByName = item.AddedByUserFullName ?? "Unknown", | |
| 61 | + VoteCount = item.VoteCount, | |
| 62 | + UserHasVoted = item.VoterUserIds.Contains(userId) | |
| 63 | + }).ToList(); | |
| 64 | + | |
| 65 | + ViewData["TripId"] = tripId; | |
| 66 | + ViewData["TripName"] = trip.Name; | |
| 67 | + | |
| 68 | + return View(model); | |
| 69 | + } | |
| 70 | + | |
| 71 | + // GET: WishlistClient/Create?tripId=xxx | |
| 72 | + public async Task<IActionResult> Create(Guid tripId) | |
| 73 | + { | |
| 74 | + var userId = GetUserId(); | |
| 75 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 76 | + | |
| 77 | + ViewData["TripId"] = tripId; | |
| 78 | + PopulateEnumDropdowns(); | |
| 79 | + return View(new TripWishlistItemBllDto { TripId = tripId }); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // POST: WishlistClient/Create | |
| 83 | + [HttpPost] | |
| 84 | + [ValidateAntiForgeryToken] | |
| 85 | + public async Task<IActionResult> Create(TripWishlistItemBllDto item) | |
| 86 | + { | |
| 87 | + var userId = GetUserId(); | |
| 88 | + | |
| 89 | + if (ModelState.IsValid) | |
| 90 | + { | |
| 91 | + var (created, errorCode) = await _wishlistService.CreateAsync(item, userId); | |
| 92 | + if (created == null) | |
| 93 | + { | |
| 94 | + if (errorCode == "forbidden") return Forbid(); | |
| 95 | + return NotFound(); | |
| 96 | + } | |
| 97 | + return RedirectToAction(nameof(Index), new { tripId = item.TripId }); | |
| 98 | + } | |
| 99 | + | |
| 100 | + ViewData["TripId"] = item.TripId; | |
| 101 | + PopulateEnumDropdowns(); | |
| 102 | + return View(item); | |
| 103 | + } | |
| 104 | + | |
| 105 | + // POST: WishlistClient/Vote/5 | |
| 106 | + [HttpPost] | |
| 107 | + [ValidateAntiForgeryToken] | |
| 108 | + public async Task<IActionResult> Vote(Guid id) | |
| 109 | + { | |
| 110 | + var userId = GetUserId(); | |
| 111 | + | |
| 112 | + var item = await _wishlistService.GetByIdRawAsync(id); | |
| 113 | + if (item == null) return NotFound(); | |
| 114 | + | |
| 115 | + var (ok, errorCode) = await _wishlistService.ToggleVoteAsync(id, userId); | |
| 116 | + if (!ok) | |
| 117 | + { | |
| 118 | + return errorCode switch | |
| 119 | + { | |
| 120 | + "notfound" => NotFound(), | |
| 121 | + "forbidden" => Forbid(), | |
| 122 | + _ => NotFound() | |
| 123 | + }; | |
| 124 | + } | |
| 125 | + | |
| 126 | + return RedirectToAction(nameof(Index), new { tripId = item.TripId }); | |
| 127 | + } | |
| 128 | + | |
| 129 | + // POST: WishlistClient/Complete/5 | |
| 130 | + [HttpPost] | |
| 131 | + [ValidateAntiForgeryToken] | |
| 132 | + public async Task<IActionResult> Complete(Guid id) | |
| 133 | + { | |
| 134 | + var userId = GetUserId(); | |
| 135 | + | |
| 136 | + var item = await _wishlistService.GetByIdRawAsync(id); | |
| 137 | + if (item == null) return NotFound(); | |
| 138 | + | |
| 139 | + var (ok, errorCode) = await _wishlistService.ToggleCompleteAsync(id, userId); | |
| 140 | + if (!ok) | |
| 141 | + { | |
| 142 | + return errorCode switch | |
| 143 | + { | |
| 144 | + "notfound" => NotFound(), | |
| 145 | + "forbidden" => Forbid(), | |
| 146 | + _ => NotFound() | |
| 147 | + }; | |
| 148 | + } | |
| 149 | + | |
| 150 | + return RedirectToAction(nameof(Index), new { tripId = item.TripId }); | |
| 151 | + } | |
| 152 | + | |
| 153 | + // GET: WishlistClient/Edit/5 | |
| 154 | + public async Task<IActionResult> Edit(Guid id) | |
| 155 | + { | |
| 156 | + var userId = GetUserId(); | |
| 157 | + | |
| 158 | + var item = await _wishlistService.GetByIdAsync(id, userId); | |
| 159 | + if (item == null) | |
| 160 | + { | |
| 161 | + var raw = await _wishlistService.GetByIdRawAsync(id); | |
| 162 | + if (raw == null) return NotFound(); | |
| 163 | + return Forbid(); | |
| 164 | + } | |
| 165 | + | |
| 166 | + if (item.AddedByUserId != userId) return Forbid(); | |
| 167 | + | |
| 168 | + ViewData["TripId"] = item.TripId; | |
| 169 | + PopulateEnumDropdowns(); | |
| 170 | + return View(item); | |
| 171 | + } | |
| 172 | + | |
| 173 | + // POST: WishlistClient/Edit/5 | |
| 174 | + [HttpPost] | |
| 175 | + [ValidateAntiForgeryToken] | |
| 176 | + public async Task<IActionResult> Edit(Guid id, TripWishlistItemBllDto item) | |
| 177 | + { | |
| 178 | + if (id != item.Id) return NotFound(); | |
| 179 | + | |
| 180 | + var userId = GetUserId(); | |
| 181 | + | |
| 182 | + if (ModelState.IsValid) | |
| 183 | + { | |
| 184 | + var preUpdate = await _wishlistService.GetByIdRawAsync(id); | |
| 185 | + if (preUpdate == null) return NotFound(); | |
| 186 | + | |
| 187 | + var (ok, errorCode) = await _wishlistService.UpdateAsync(id, item, userId); | |
| 188 | + if (!ok) | |
| 189 | + { | |
| 190 | + return errorCode switch | |
| 191 | + { | |
| 192 | + "notfound" => NotFound(), | |
| 193 | + "forbidden" => Forbid(), | |
| 194 | + _ => NotFound() | |
| 195 | + }; | |
| 196 | + } | |
| 197 | + | |
| 198 | + return RedirectToAction(nameof(Index), new { tripId = preUpdate.TripId }); | |
| 199 | + } | |
| 200 | + | |
| 201 | + var raw = await _wishlistService.GetByIdRawAsync(id); | |
| 202 | + if (raw == null) return NotFound(); | |
| 203 | + ViewData["TripId"] = raw.TripId; | |
| 204 | + PopulateEnumDropdowns(); | |
| 205 | + return View(item); | |
| 206 | + } | |
| 207 | + | |
| 208 | + // GET: WishlistClient/Delete/5 | |
| 209 | + public async Task<IActionResult> Delete(Guid id) | |
| 210 | + { | |
| 211 | + var userId = GetUserId(); | |
| 212 | + | |
| 213 | + var item = await _wishlistService.GetByIdAsync(id, userId); | |
| 214 | + if (item == null) | |
| 215 | + { | |
| 216 | + var raw = await _wishlistService.GetByIdRawAsync(id); | |
| 217 | + if (raw == null) return NotFound(); | |
| 218 | + return Forbid(); | |
| 219 | + } | |
| 220 | + if (item.AddedByUserId != userId) return Forbid(); | |
| 221 | + | |
| 222 | + // Re-fetch with includes via trip items list | |
| 223 | + var tripItems = await _wishlistService.GetByTripIdAsync(item.TripId, userId); | |
| 224 | + var itemWithDetails = tripItems.FirstOrDefault(w => w.Id == id); | |
| 225 | + | |
| 226 | + ViewData["TripId"] = item.TripId; | |
| 227 | + return View(itemWithDetails ?? item); | |
| 228 | + } | |
| 229 | + | |
| 230 | + // POST: WishlistClient/Delete/5 | |
| 231 | + [HttpPost, ActionName("Delete")] | |
| 232 | + [ValidateAntiForgeryToken] | |
| 233 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 234 | + { | |
| 235 | + var userId = GetUserId(); | |
| 236 | + | |
| 237 | + var existing = await _wishlistService.GetByIdRawAsync(id); | |
| 238 | + if (existing == null) return NotFound(); | |
| 239 | + var tripId = existing.TripId; | |
| 240 | + | |
| 241 | + var (ok, errorCode) = await _wishlistService.DeleteAsync(id, userId); | |
| 242 | + if (!ok) | |
| 243 | + { | |
| 244 | + return errorCode switch | |
| 245 | + { | |
| 246 | + "notfound" => NotFound(), | |
| 247 | + "forbidden" => Forbid(), | |
| 248 | + _ => NotFound() | |
| 249 | + }; | |
| 250 | + } | |
| 251 | + | |
| 252 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 253 | + } | |
| 254 | + | |
| 255 | + private void PopulateEnumDropdowns() | |
| 256 | + { | |
| 257 | + ViewData["Categories"] = new SelectList( | |
| 258 | + Enum.GetValues<EWishlistCategory>().Select(e => new { Value = (int)e, Text = e.ToString() }), | |
| 259 | + "Value", "Text"); | |
| 260 | + ViewData["Priorities"] = new SelectList( | |
| 261 | + Enum.GetValues<EWishlistPriority>().Select(e => new { Value = (int)e, Text = e.ToString() }), | |
| 262 | + "Value", "Text"); | |
| 263 | + } | |
| 264 | +} | |
| 265 | + | |
| 266 | +public class WishlistItemViewModel | |
| 267 | +{ | |
| 268 | + public Guid Id { get; set; } | |
| 269 | + public Guid AddedByUserId { get; set; } | |
| 270 | + public string Title { get; set; } = default!; | |
| 271 | + public string? Description { get; set; } | |
| 272 | + public EWishlistCategory Category { get; set; } | |
| 273 | + public EWishlistPriority Priority { get; set; } | |
| 274 | + public decimal? EstimatedCost { get; set; } | |
| 275 | + public string? Url { get; set; } | |
| 276 | + public string? Location { get; set; } | |
| 277 | + public bool IsCompleted { get; set; } | |
| 278 | + public string AddedByName { get; set; } = default!; | |
| 279 | + public int VoteCount { get; set; } | |
| 280 | + public bool UserHasVoted { get; set; } | |
| 281 | +} |
added SplitApp.Modular/src/SplitApp.WebApp/Hosting/AppDataInit.cs +363 −0
| @@ -0,0 +1,363 @@ | ||
| 1 | +using Microsoft.EntityFrameworkCore; | |
| 2 | +using SplitApp.Modules.Expenses.Domain.Entities; | |
| 3 | +using SplitApp.Modules.Expenses.Domain.Enums; | |
| 4 | +using SplitApp.Modules.Expenses.Infrastructure.Persistence; | |
| 5 | +using SplitApp.Modules.Trips.Domain.Entities; | |
| 6 | +using SplitApp.Modules.Trips.Domain.Enums; | |
| 7 | +using SplitApp.Modules.Trips.Infrastructure.Persistence; | |
| 8 | +using SplitApp.Modules.Users.Infrastructure.Persistence; | |
| 9 | +using SplitApp.Shared.Kernel.Localization; | |
| 10 | + | |
| 11 | +namespace SplitApp.WebApp.Hosting; | |
| 12 | + | |
| 13 | +public static class AppDataInit | |
| 14 | +{ | |
| 15 | + public static void SeedExampleData(IServiceProvider services) | |
| 16 | + { | |
| 17 | + using var scope = services.CreateScope(); | |
| 18 | + var users = scope.ServiceProvider.GetRequiredService<UsersDbContext>(); | |
| 19 | + var trips = scope.ServiceProvider.GetRequiredService<TripsDbContext>(); | |
| 20 | + var expenses = scope.ServiceProvider.GetRequiredService<ExpensesDbContext>(); | |
| 21 | + | |
| 22 | + if (trips.Trips.Any()) return; | |
| 23 | + | |
| 24 | + var admin = users.Users.First(u => u.Email == "admin@taltech.ee").Id; | |
| 25 | + var testUser = users.Users.First(u => u.Email == "user@taltech.ee").Id; | |
| 26 | + var alice = users.Users.First(u => u.Email == "alice@taltech.ee").Id; | |
| 27 | + var bob = users.Users.First(u => u.Email == "bob@taltech.ee").Id; | |
| 28 | + var charlie = users.Users.First(u => u.Email == "charlie@taltech.ee").Id; | |
| 29 | + var diana = users.Users.First(u => u.Email == "diana@taltech.ee").Id; | |
| 30 | + | |
| 31 | + var eur = expenses.Currencies.First(c => c.Code == "EUR").Id; | |
| 32 | + var usd = expenses.Currencies.First(c => c.Code == "USD").Id; | |
| 33 | + var gbp = expenses.Currencies.First(c => c.Code == "GBP").Id; | |
| 34 | + | |
| 35 | + var now = DateTime.UtcNow; | |
| 36 | + | |
| 37 | + // ────────────────────────────────────────────────────────────── | |
| 38 | + // Trip 1: Barcelona Weekend — Active, 4 participants | |
| 39 | + // ────────────────────────────────────────────────────────────── | |
| 40 | + var trip1 = new Trip | |
| 41 | + { | |
| 42 | + Name = "Barcelona Weekend", | |
| 43 | + Description = "A long weekend exploring Barcelona with friends", | |
| 44 | + Destination = "Barcelona, Spain", | |
| 45 | + StartDate = new DateTime(2026, 4, 10, 0, 0, 0, DateTimeKind.Utc), | |
| 46 | + EndDate = new DateTime(2026, 4, 13, 0, 0, 0, DateTimeKind.Utc), | |
| 47 | + Status = ETripStatus.Active, | |
| 48 | + DefaultCurrencyId = eur, | |
| 49 | + CreatedById = admin, | |
| 50 | + }; | |
| 51 | + trips.Trips.Add(trip1); | |
| 52 | + | |
| 53 | + trips.TripParticipants.AddRange( | |
| 54 | + new TripParticipant { TripId = trip1.Id, UserId = admin, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-10), IsActive = true }, | |
| 55 | + new TripParticipant { TripId = trip1.Id, UserId = alice, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-9), IsActive = true }, | |
| 56 | + new TripParticipant { TripId = trip1.Id, UserId = bob, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-8), IsActive = true }, | |
| 57 | + new TripParticipant { TripId = trip1.Id, UserId = charlie, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-7), IsActive = true }); | |
| 58 | + | |
| 59 | + var cat1Food = new BudgetCategory { TripId = trip1.Id, Name = Lang("Food & Drinks", "Toit ja joogid"), IconName = "cup-hot", PlannedAmount = 400m, DisplayOrder = 0 }; | |
| 60 | + var cat1Transport = new BudgetCategory { TripId = trip1.Id, Name = Lang("Transport", "Transport"), IconName = "bus-front", PlannedAmount = 150m, DisplayOrder = 1 }; | |
| 61 | + var cat1Activities = new BudgetCategory { TripId = trip1.Id, Name = Lang("Activities", "Tegevused"), IconName = "binoculars", PlannedAmount = 200m, DisplayOrder = 2 }; | |
| 62 | + var cat1Accommodation = new BudgetCategory { TripId = trip1.Id, Name = Lang("Accommodation", "Majutus"), IconName = "house", PlannedAmount = 500m, DisplayOrder = 3 }; | |
| 63 | + trips.BudgetCategories.AddRange(cat1Food, cat1Transport, cat1Activities, cat1Accommodation); | |
| 64 | + | |
| 65 | + var poll1 = new TripPoll { TripId = trip1.Id, CreatedByUserId = admin, Question = "Where should we eat on the last night?" }; | |
| 66 | + trips.TripPolls.Add(poll1); | |
| 67 | + var poll1A = new TripPollOption { PollId = poll1.Id, Text = "Can Culleretes (oldest restaurant)", DisplayOrder = 0 }; | |
| 68 | + var poll1B = new TripPollOption { PollId = poll1.Id, Text = "El Xampanyet (tapas)", DisplayOrder = 1 }; | |
| 69 | + var poll1C = new TripPollOption { PollId = poll1.Id, Text = "Cerveceria Catalana", DisplayOrder = 2 }; | |
| 70 | + trips.TripPollOptions.AddRange(poll1A, poll1B, poll1C); | |
| 71 | + trips.TripPollVotes.AddRange( | |
| 72 | + new TripPollVote { PollOptionId = poll1B.Id, UserId = admin }, | |
| 73 | + new TripPollVote { PollOptionId = poll1A.Id, UserId = alice }, | |
| 74 | + new TripPollVote { PollOptionId = poll1B.Id, UserId = bob }); | |
| 75 | + | |
| 76 | + trips.TripWishlistItems.AddRange( | |
| 77 | + new TripWishlistItem { TripId = trip1.Id, AddedByUserId = alice, Title = "Casa Batllo", Description = "Gaudi's famous building on Passeig de Gracia", Category = EWishlistCategory.Place, Priority = EWishlistPriority.MustDo, EstimatedCost = 35m, Location = "Passeig de Gracia 43", DisplayOrder = 0 }, | |
| 78 | + new TripWishlistItem { TripId = trip1.Id, AddedByUserId = bob, Title = "Beach volleyball", Description = "Play at Barceloneta beach in the morning", Category = EWishlistCategory.Activity, Priority = EWishlistPriority.NiceToHave, Location = "Barceloneta Beach", DisplayOrder = 1 }, | |
| 79 | + new TripWishlistItem { TripId = trip1.Id, AddedByUserId = charlie, Title = "Flamenco show", Description = "Evening flamenco performance", Category = EWishlistCategory.Activity, Priority = EWishlistPriority.MustDo, EstimatedCost = 45m, DisplayOrder = 2 }, | |
| 80 | + new TripWishlistItem { TripId = trip1.Id, AddedByUserId = admin, Title = "La Paradeta seafood", Description = "Fresh seafood market-style restaurant", Category = EWishlistCategory.Restaurant, Priority = EWishlistPriority.Optional, Location = "Carrer Comercial 7", DisplayOrder = 3 }); | |
| 81 | + | |
| 82 | + // ────────────────────────────────────────────────────────────── | |
| 83 | + // Trip 2: London Business Trip — Settled, 3 participants | |
| 84 | + // ────────────────────────────────────────────────────────────── | |
| 85 | + var trip2 = new Trip | |
| 86 | + { | |
| 87 | + Name = "London Business Trip", | |
| 88 | + Description = "Conference and team meetings in London", | |
| 89 | + Destination = "London, UK", | |
| 90 | + StartDate = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), | |
| 91 | + EndDate = new DateTime(2026, 3, 4, 0, 0, 0, DateTimeKind.Utc), | |
| 92 | + Status = ETripStatus.Settled, | |
| 93 | + DefaultCurrencyId = gbp, | |
| 94 | + CreatedById = testUser, | |
| 95 | + }; | |
| 96 | + trips.Trips.Add(trip2); | |
| 97 | + trips.TripParticipants.AddRange( | |
| 98 | + new TripParticipant { TripId = trip2.Id, UserId = testUser, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-30), IsActive = true }, | |
| 99 | + new TripParticipant { TripId = trip2.Id, UserId = admin, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-29), IsActive = true }, | |
| 100 | + new TripParticipant { TripId = trip2.Id, UserId = diana, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-28), IsActive = true }); | |
| 101 | + | |
| 102 | + // ────────────────────────────────────────────────────────────── | |
| 103 | + // Trip 3: Summer Cabin — Active, 5 participants, partial settlement | |
| 104 | + // ────────────────────────────────────────────────────────────── | |
| 105 | + var trip3 = new Trip | |
| 106 | + { | |
| 107 | + Name = "Summer Cabin Getaway", | |
| 108 | + Description = "Relaxing weekend at a cabin by the lake", | |
| 109 | + Destination = "Otepää, Estonia", | |
| 110 | + StartDate = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), | |
| 111 | + EndDate = new DateTime(2026, 5, 18, 0, 0, 0, DateTimeKind.Utc), | |
| 112 | + Status = ETripStatus.Active, | |
| 113 | + DefaultCurrencyId = eur, | |
| 114 | + CreatedById = alice, | |
| 115 | + }; | |
| 116 | + trips.Trips.Add(trip3); | |
| 117 | + trips.TripParticipants.AddRange( | |
| 118 | + new TripParticipant { TripId = trip3.Id, UserId = alice, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-3), IsActive = true }, | |
| 119 | + new TripParticipant { TripId = trip3.Id, UserId = bob, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-2), IsActive = true }, | |
| 120 | + new TripParticipant { TripId = trip3.Id, UserId = charlie, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-2), IsActive = true }, | |
| 121 | + new TripParticipant { TripId = trip3.Id, UserId = diana, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-1), IsActive = true }, | |
| 122 | + new TripParticipant { TripId = trip3.Id, UserId = admin, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-1), IsActive = true }); | |
| 123 | + | |
| 124 | + var poll3 = new TripPoll { TripId = trip3.Id, CreatedByUserId = alice, Question = "What activity for Saturday afternoon?", AllowMultipleVotes = true }; | |
| 125 | + trips.TripPolls.Add(poll3); | |
| 126 | + var poll3A = new TripPollOption { PollId = poll3.Id, Text = "Hiking to the viewpoint", DisplayOrder = 0 }; | |
| 127 | + var poll3B = new TripPollOption { PollId = poll3.Id, Text = "Fishing at the lake", DisplayOrder = 1 }; | |
| 128 | + var poll3C = new TripPollOption { PollId = poll3.Id, Text = "Board games at the cabin", DisplayOrder = 2 }; | |
| 129 | + var poll3D = new TripPollOption { PollId = poll3.Id, Text = "Cycling around the area", DisplayOrder = 3 }; | |
| 130 | + trips.TripPollOptions.AddRange(poll3A, poll3B, poll3C, poll3D); | |
| 131 | + trips.TripPollVotes.AddRange( | |
| 132 | + new TripPollVote { PollOptionId = poll3A.Id, UserId = alice }, | |
| 133 | + new TripPollVote { PollOptionId = poll3B.Id, UserId = alice }, | |
| 134 | + new TripPollVote { PollOptionId = poll3A.Id, UserId = bob }, | |
| 135 | + new TripPollVote { PollOptionId = poll3C.Id, UserId = charlie }, | |
| 136 | + new TripPollVote { PollOptionId = poll3B.Id, UserId = diana }, | |
| 137 | + new TripPollVote { PollOptionId = poll3D.Id, UserId = admin }, | |
| 138 | + new TripPollVote { PollOptionId = poll3A.Id, UserId = admin }); | |
| 139 | + | |
| 140 | + trips.TripWishlistItems.AddRange( | |
| 141 | + new TripWishlistItem { TripId = trip3.Id, AddedByUserId = bob, Title = "Smoke sauna experience", Description = "Traditional Estonian smoke sauna at the lakeside", Category = EWishlistCategory.Activity, Priority = EWishlistPriority.MustDo, EstimatedCost = 15m, DisplayOrder = 0 }, | |
| 142 | + new TripWishlistItem { TripId = trip3.Id, AddedByUserId = diana, Title = "Visit Otepää Adventure Park",Description = "Rope courses and zip lines in the forest", Category = EWishlistCategory.Activity, Priority = EWishlistPriority.NiceToHave,EstimatedCost = 25m, Location = "Otepää Adventure Park", DisplayOrder = 1 }, | |
| 143 | + new TripWishlistItem { TripId = trip3.Id, AddedByUserId = alice, Title = "Pühajärve beach", Description = "Swimming and sunbathing at the sacred lake", Category = EWishlistCategory.Place, Priority = EWishlistPriority.MustDo, Location = "Pühajärv", DisplayOrder = 2, IsCompleted = true, CompletedAt = now.AddHours(-5) }); | |
| 144 | + | |
| 145 | + trips.TripInvitations.Add(new TripInvitation | |
| 146 | + { | |
| 147 | + TripId = trip3.Id, InvitedByUserId = alice, | |
| 148 | + Token = Guid.NewGuid().ToString("N"), | |
| 149 | + Status = EInvitationStatus.Pending, | |
| 150 | + ExpiresAt = now.AddDays(7), | |
| 151 | + }); | |
| 152 | + | |
| 153 | + // ────────────────────────────────────────────────────────────── | |
| 154 | + // Trip 4: NYC Adventure — Archived, 3 participants | |
| 155 | + // ────────────────────────────────────────────────────────────── | |
| 156 | + var trip4 = new Trip | |
| 157 | + { | |
| 158 | + Name = "NYC Adventure", | |
| 159 | + Description = "Week in New York City exploring Manhattan and Brooklyn", | |
| 160 | + Destination = "New York, USA", | |
| 161 | + StartDate = new DateTime(2025, 12, 20, 0, 0, 0, DateTimeKind.Utc), | |
| 162 | + EndDate = new DateTime(2025, 12, 27, 0, 0, 0, DateTimeKind.Utc), | |
| 163 | + Status = ETripStatus.Archived, | |
| 164 | + DefaultCurrencyId = usd, | |
| 165 | + CreatedById = bob, | |
| 166 | + }; | |
| 167 | + trips.Trips.Add(trip4); | |
| 168 | + trips.TripParticipants.AddRange( | |
| 169 | + new TripParticipant { TripId = trip4.Id, UserId = bob, Role = EParticipantRole.Organizer, JoinedAt = now.AddDays(-90), IsActive = true }, | |
| 170 | + new TripParticipant { TripId = trip4.Id, UserId = alice, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-89), IsActive = true }, | |
| 171 | + new TripParticipant { TripId = trip4.Id, UserId = diana, Role = EParticipantRole.Participant, JoinedAt = now.AddDays(-88), IsActive = true }); | |
| 172 | + | |
| 173 | + var poll4 = new TripPoll | |
| 174 | + { | |
| 175 | + TripId = trip4.Id, CreatedByUserId = bob, | |
| 176 | + Question = "Best day of the trip?", | |
| 177 | + ClosedAt = new DateTime(2025, 12, 27, 12, 0, 0, DateTimeKind.Utc), | |
| 178 | + }; | |
| 179 | + trips.TripPolls.Add(poll4); | |
| 180 | + var poll4A = new TripPollOption { PollId = poll4.Id, Text = "Broadway night", DisplayOrder = 0 }; | |
| 181 | + var poll4B = new TripPollOption { PollId = poll4.Id, Text = "Central Park day", DisplayOrder = 1 }; | |
| 182 | + var poll4C = new TripPollOption { PollId = poll4.Id, Text = "Brooklyn Bridge walk", DisplayOrder = 2 }; | |
| 183 | + trips.TripPollOptions.AddRange(poll4A, poll4B, poll4C); | |
| 184 | + trips.TripPollVotes.AddRange( | |
| 185 | + new TripPollVote { PollOptionId = poll4A.Id, UserId = alice }, | |
| 186 | + new TripPollVote { PollOptionId = poll4A.Id, UserId = diana }, | |
| 187 | + new TripPollVote { PollOptionId = poll4B.Id, UserId = bob }); | |
| 188 | + | |
| 189 | + // Save Trips first so all Trip/Participant/BudgetCategory IDs are committed | |
| 190 | + // before Expenses references them by Guid (cross-schema, no FK). | |
| 191 | + trips.SaveChanges(); | |
| 192 | + | |
| 193 | + // ────────────────────────────────────────────────────────────── | |
| 194 | + // Trip 1 expenses: 11 items, equal split among 4 (one subset) | |
| 195 | + // ────────────────────────────────────────────────────────────── | |
| 196 | + var trip1Members = new[] { admin, alice, bob, charlie }; | |
| 197 | + var trip1Expenses = new (string desc, decimal amount, Guid paidBy, Guid? catId, DateTime date, ESplitMethod split)[] | |
| 198 | + { | |
| 199 | + ("Airbnb apartment (3 nights)", 480.00m, admin, cat1Accommodation.Id, now.AddDays(-5), ESplitMethod.EqualAll), | |
| 200 | + ("Airport taxi", 35.00m, alice, cat1Transport.Id, now.AddDays(-5), ESplitMethod.EqualAll), | |
| 201 | + ("Grocery shopping", 62.50m, bob, cat1Food.Id, now.AddDays(-4), ESplitMethod.EqualAll), | |
| 202 | + ("Dinner at La Boqueria", 128.00m, admin, cat1Food.Id, now.AddDays(-4), ESplitMethod.EqualAll), | |
| 203 | + ("Sagrada Familia tickets", 104.00m, charlie, cat1Activities.Id, now.AddDays(-3), ESplitMethod.EqualAll), | |
| 204 | + ("Metro passes (4x)", 44.00m, alice, cat1Transport.Id, now.AddDays(-3), ESplitMethod.EqualAll), | |
| 205 | + ("Tapas bar lunch", 76.00m, bob, cat1Food.Id, now.AddDays(-3), ESplitMethod.EqualAll), | |
| 206 | + ("Park Guell entry", 40.00m, admin, cat1Activities.Id, now.AddDays(-2), ESplitMethod.EqualAll), | |
| 207 | + ("Sangria and snacks", 48.50m, charlie, cat1Food.Id, now.AddDays(-2), ESplitMethod.EqualAll), | |
| 208 | + ("Souvenir shopping", 55.00m, alice, cat1Food.Id, now.AddDays(-1), ESplitMethod.EqualSubset), | |
| 209 | + ("Return taxi to airport", 38.00m, bob, cat1Transport.Id, now.AddDays(-1), ESplitMethod.EqualAll), | |
| 210 | + }; | |
| 211 | + | |
| 212 | + foreach (var (desc, amount, paidBy, catId, date, split) in trip1Expenses) | |
| 213 | + { | |
| 214 | + var ex = new Expense | |
| 215 | + { | |
| 216 | + TripId = trip1.Id, PaidByUserId = paidBy, Amount = amount, | |
| 217 | + Description = desc, ExpenseDate = date, BudgetCategoryId = catId, | |
| 218 | + CurrencyId = eur, SplitMethod = split, | |
| 219 | + }; | |
| 220 | + expenses.Expenses.Add(ex); | |
| 221 | + AddEqualSplits(expenses, ex, split == ESplitMethod.EqualSubset | |
| 222 | + ? new[] { alice, bob, charlie } | |
| 223 | + : trip1Members); | |
| 224 | + } | |
| 225 | + | |
| 226 | + // SplitPresets (Expenses module) | |
| 227 | + var preset1All = new SplitPreset { TripId = trip1.Id, Name = "Everyone equal", SplitMethod = ESplitMethod.EqualAll, CreatedById = admin }; | |
| 228 | + var preset1Hotel = new SplitPreset { TripId = trip1.Id, Name = "Hotel group", SplitMethod = ESplitMethod.EqualSubset, CreatedById = admin }; | |
| 229 | + expenses.SplitPresets.AddRange(preset1All, preset1Hotel); | |
| 230 | + expenses.SplitPresetMembers.AddRange( | |
| 231 | + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = admin }, | |
| 232 | + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = alice }, | |
| 233 | + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = bob }); | |
| 234 | + | |
| 235 | + // ────────────────────────────────────────────────────────────── | |
| 236 | + // Trip 2 expenses + completed settlement | |
| 237 | + // ────────────────────────────────────────────────────────────── | |
| 238 | + var trip2Members = new[] { testUser, admin, diana }; | |
| 239 | + var trip2Expenses = new (string desc, decimal amount, Guid paidBy, DateTime date)[] | |
| 240 | + { | |
| 241 | + ("Hotel (2 nights)", 340.00m, testUser, now.AddDays(-28)), | |
| 242 | + ("Heathrow Express", 75.00m, admin, now.AddDays(-28)), | |
| 243 | + ("Conference dinner", 185.00m, testUser, now.AddDays(-27)), | |
| 244 | + ("Uber rides", 48.00m, diana, now.AddDays(-27)), | |
| 245 | + ("Team lunch", 92.00m, admin, now.AddDays(-26)), | |
| 246 | + ("Coffee & snacks", 24.50m, diana, now.AddDays(-26)), | |
| 247 | + }; | |
| 248 | + foreach (var (desc, amount, paidBy, date) in trip2Expenses) | |
| 249 | + { | |
| 250 | + var ex = new Expense | |
| 251 | + { | |
| 252 | + TripId = trip2.Id, PaidByUserId = paidBy, Amount = amount, | |
| 253 | + Description = desc, ExpenseDate = date, CurrencyId = gbp, | |
| 254 | + SplitMethod = ESplitMethod.EqualAll, | |
| 255 | + }; | |
| 256 | + expenses.Expenses.Add(ex); | |
| 257 | + AddEqualSplits(expenses, ex, trip2Members); | |
| 258 | + } | |
| 259 | + | |
| 260 | + var plan2 = new SettlementPlan | |
| 261 | + { | |
| 262 | + TripId = trip2.Id, CreatedByUserId = testUser, | |
| 263 | + TotalAmount = 158.50m, Status = ESettlementStatus.Completed, | |
| 264 | + CompletedAt = now.AddDays(-20), | |
| 265 | + }; | |
| 266 | + expenses.SettlementPlans.Add(plan2); | |
| 267 | + expenses.SettlementPayments.AddRange( | |
| 268 | + new SettlementPayment { SettlementPlanId = plan2.Id, FromUserId = diana, ToUserId = testUser, Amount = 130.50m, Status = EPaymentStatus.Confirmed, MarkedPaidAt = now.AddDays(-22), ConfirmedAt = now.AddDays(-20) }, | |
| 269 | + new SettlementPayment { SettlementPlanId = plan2.Id, FromUserId = diana, ToUserId = admin, Amount = 28.00m, Status = EPaymentStatus.Confirmed, MarkedPaidAt = now.AddDays(-21), ConfirmedAt = now.AddDays(-20) }); | |
| 270 | + | |
| 271 | + // ────────────────────────────────────────────────────────────── | |
| 272 | + // Trip 3 expenses + in-progress settlement | |
| 273 | + // ────────────────────────────────────────────────────────────── | |
| 274 | + var trip3Members = new[] { alice, bob, charlie, diana, admin }; | |
| 275 | + var trip3Expenses = new (string desc, decimal amount, Guid paidBy, DateTime date)[] | |
| 276 | + { | |
| 277 | + ("Cabin rental (3 nights)", 600.00m, alice, now.AddDays(-2)), | |
| 278 | + ("BBQ supplies and meat", 95.00m, bob, now.AddDays(-1)), | |
| 279 | + ("Firewood and charcoal", 25.00m, charlie, now.AddDays(-1)), | |
| 280 | + ("Drinks and beverages", 78.00m, diana, now), | |
| 281 | + ("Fishing gear rental", 40.00m, admin, now), | |
| 282 | + ("Breakfast groceries", 42.00m, alice, now), | |
| 283 | + ("Canoe rental (half day)", 60.00m, bob, now), | |
| 284 | + }; | |
| 285 | + foreach (var (desc, amount, paidBy, date) in trip3Expenses) | |
| 286 | + { | |
| 287 | + var ex = new Expense | |
| 288 | + { | |
| 289 | + TripId = trip3.Id, PaidByUserId = paidBy, Amount = amount, | |
| 290 | + Description = desc, ExpenseDate = date, CurrencyId = eur, | |
| 291 | + SplitMethod = ESplitMethod.EqualAll, | |
| 292 | + }; | |
| 293 | + expenses.Expenses.Add(ex); | |
| 294 | + AddEqualSplits(expenses, ex, trip3Members); | |
| 295 | + } | |
| 296 | + | |
| 297 | + var plan3 = new SettlementPlan | |
| 298 | + { | |
| 299 | + TripId = trip3.Id, CreatedByUserId = alice, | |
| 300 | + TotalAmount = 350m, Status = ESettlementStatus.InProgress, | |
| 301 | + }; | |
| 302 | + expenses.SettlementPlans.Add(plan3); | |
| 303 | + expenses.SettlementPayments.AddRange( | |
| 304 | + new SettlementPayment { SettlementPlanId = plan3.Id, FromUserId = charlie, ToUserId = alice, Amount = 145.00m, Status = EPaymentStatus.MarkedPaid, MarkedPaidAt = now.AddHours(-2) }, | |
| 305 | + new SettlementPayment { SettlementPlanId = plan3.Id, FromUserId = diana, ToUserId = alice, Amount = 110.00m, Status = EPaymentStatus.Pending }, | |
| 306 | + new SettlementPayment { SettlementPlanId = plan3.Id, FromUserId = admin, ToUserId = bob, Amount = 95.00m, Status = EPaymentStatus.Pending }); | |
| 307 | + | |
| 308 | + // ────────────────────────────────────────────────────────────── | |
| 309 | + // Trip 4 expenses (archived) | |
| 310 | + // ────────────────────────────────────────────────────────────── | |
| 311 | + var trip4Members = new[] { bob, alice, diana }; | |
| 312 | + var trip4Expenses = new (string desc, decimal amount, Guid paidBy, DateTime date)[] | |
| 313 | + { | |
| 314 | + ("Hotel in Midtown (6 nights)", 1800.00m, bob, new DateTime(2025, 12, 20, 12, 0, 0, DateTimeKind.Utc)), | |
| 315 | + ("Broadway show tickets", 450.00m, alice, new DateTime(2025, 12, 21, 20, 0, 0, DateTimeKind.Utc)), | |
| 316 | + ("Statue of Liberty ferry", 63.00m, diana, new DateTime(2025, 12, 22, 10, 0, 0, DateTimeKind.Utc)), | |
| 317 | + ("Central Park bike rental", 75.00m, bob, new DateTime(2025, 12, 23, 14, 0, 0, DateTimeKind.Utc)), | |
| 318 | + ("Dinner in Little Italy", 195.00m, alice, new DateTime(2025, 12, 23, 20, 0, 0, DateTimeKind.Utc)), | |
| 319 | + ("Brooklyn Bridge walk snacks", 28.00m, diana, new DateTime(2025, 12, 24, 11, 0, 0, DateTimeKind.Utc)), | |
| 320 | + ("MoMA tickets", 75.00m, bob, new DateTime(2025, 12, 25, 10, 0, 0, DateTimeKind.Utc)), | |
| 321 | + ("Times Square shopping", 220.00m, alice, new DateTime(2025, 12, 26, 15, 0, 0, DateTimeKind.Utc)), | |
| 322 | + ("JFK taxi", 65.00m, diana, new DateTime(2025, 12, 27, 8, 0, 0, DateTimeKind.Utc)), | |
| 323 | + }; | |
| 324 | + foreach (var (desc, amount, paidBy, date) in trip4Expenses) | |
| 325 | + { | |
| 326 | + var ex = new Expense | |
| 327 | + { | |
| 328 | + TripId = trip4.Id, PaidByUserId = paidBy, Amount = amount, | |
| 329 | + Description = desc, ExpenseDate = date, CurrencyId = usd, | |
| 330 | + SplitMethod = ESplitMethod.EqualAll, | |
| 331 | + }; | |
| 332 | + expenses.Expenses.Add(ex); | |
| 333 | + AddEqualSplits(expenses, ex, trip4Members); | |
| 334 | + } | |
| 335 | + | |
| 336 | + expenses.SaveChanges(); | |
| 337 | + } | |
| 338 | + | |
| 339 | + private static LangStr Lang(string en, string et) | |
| 340 | + { | |
| 341 | + var s = new LangStr(en, "en"); | |
| 342 | + s["et"] = et; | |
| 343 | + return s; | |
| 344 | + } | |
| 345 | + | |
| 346 | + private static void AddEqualSplits(ExpensesDbContext db, Expense expense, Guid[] memberIds) | |
| 347 | + { | |
| 348 | + var count = memberIds.Length; | |
| 349 | + var baseAmt = Math.Floor(expense.Amount / count * 100m) / 100m; | |
| 350 | + var remainderCents = (int)Math.Round((expense.Amount - baseAmt * count) * 100m); | |
| 351 | + | |
| 352 | + for (var i = 0; i < count; i++) | |
| 353 | + { | |
| 354 | + var splitAmt = baseAmt + (i < remainderCents ? 0.01m : 0m); | |
| 355 | + db.ExpenseSplits.Add(new ExpenseSplit | |
| 356 | + { | |
| 357 | + ExpenseId = expense.Id, | |
| 358 | + UserId = memberIds[i], | |
| 359 | + Amount = splitAmt, | |
| 360 | + }); | |
| 361 | + } | |
| 362 | + } | |
| 363 | +} |
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/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 +191 −0
| @@ -0,0 +1,191 @@ | ||
| 1 | +using System.Globalization; | |
| 2 | +using Asp.Versioning; | |
| 3 | +using Asp.Versioning.ApiExplorer; | |
| 4 | +using Microsoft.AspNetCore.Localization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.Extensions.Options; | |
| 7 | +using SplitApp.Modules.Expenses.Infrastructure; | |
| 8 | +using SplitApp.Modules.Trips.Infrastructure; | |
| 9 | +using SplitApp.Modules.Users.Infrastructure; | |
| 10 | +using SplitApp.WebApp.Hosting; | |
| 11 | +using Swashbuckle.AspNetCore.SwaggerGen; | |
| 12 | + | |
| 13 | +var builder = WebApplication.CreateBuilder(args); | |
| 14 | + | |
| 15 | +// MVC + API + view localization (matches phase 2). ApplicationParts surface every module's | |
| 16 | +// controllers without the host needing to know specifics. | |
| 17 | +builder.Services | |
| 18 | + .AddControllersWithViews(opts => | |
| 19 | + { | |
| 20 | + opts.ModelBinderProviders.Insert(0, new InvariantDecimalModelBinderProvider()); | |
| 21 | + }) | |
| 22 | + .AddApplicationPart(typeof(SplitApp.Modules.Users.Api.Controllers.AccountController).Assembly) | |
| 23 | + .AddApplicationPart(typeof(SplitApp.Modules.Trips.Api.Controllers.TripsController).Assembly) | |
| 24 | + .AddApplicationPart(typeof(SplitApp.Modules.Expenses.Api.Controllers.ExpensesController).Assembly) | |
| 25 | + .AddViewLocalization() | |
| 26 | + .AddDataAnnotationsLocalization(options => | |
| 27 | + { | |
| 28 | + // All [Display]/[Required]/[MaxLength] etc. annotations resolve their labels + | |
| 29 | + // error messages through Resources/Views/Shared.{resx,et.resx}. This restores | |
| 30 | + // phase 2's translated labels (e.g. asp-for="Trip.Name" → "Nimi") without | |
| 31 | + // needing per-entity resx files. | |
| 32 | + options.DataAnnotationLocalizerProvider = (type, factory) => | |
| 33 | + factory.Create(typeof(App.Resources.Views.Shared)); | |
| 34 | + }); | |
| 35 | + | |
| 36 | +builder.Services.AddRazorPages(); | |
| 37 | + | |
| 38 | +// API versioning | |
| 39 | +builder.Services.AddApiVersioning(options => | |
| 40 | +{ | |
| 41 | + options.ReportApiVersions = true; | |
| 42 | + options.DefaultApiVersion = new ApiVersion(1, 0); | |
| 43 | + options.AssumeDefaultVersionWhenUnspecified = true; | |
| 44 | + options.ApiVersionReader = new UrlSegmentApiVersionReader(); | |
| 45 | +}).AddApiExplorer(options => | |
| 46 | +{ | |
| 47 | + options.GroupNameFormat = "'v'VVV"; | |
| 48 | + options.SubstituteApiVersionInUrl = true; | |
| 49 | +}); | |
| 50 | + | |
| 51 | +builder.Services.AddEndpointsApiExplorer(); | |
| 52 | +builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>(); | |
| 53 | +builder.Services.AddSwaggerGen(); | |
| 54 | + | |
| 55 | +// Per-module DI registration. Composition root is the only place all three are visible. | |
| 56 | +builder.Services.AddUsersModule(builder.Configuration); | |
| 57 | +builder.Services.AddTripsModule(builder.Configuration); | |
| 58 | +builder.Services.AddExpensesModule(builder.Configuration); | |
| 59 | + | |
| 60 | +// Composition-root facade: aggregates the three module DbContexts into a phase-2-style | |
| 61 | +// IAppUnitOfWork so the lifted BLL services keep working unchanged. | |
| 62 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Contracts.IAppUnitOfWork, | |
| 63 | + SplitApp.WebApp.Application.Persistence.AppUnitOfWork>(); | |
| 64 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Persistence.CrossModuleNavigationLoader>(); | |
| 65 | + | |
| 66 | +// Phase 2 BLL services (lifted into WebApp/Application). These use IAppUnitOfWork. | |
| 67 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ITripService, SplitApp.WebApp.Application.Services.TripService>(); | |
| 68 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IExpenseService, SplitApp.WebApp.Application.Services.ExpenseService>(); | |
| 69 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISettlementService, SplitApp.WebApp.Application.Services.SettlementService>(); | |
| 70 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IInvitationService, SplitApp.WebApp.Application.Services.InvitationService>(); | |
| 71 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IPollService, SplitApp.WebApp.Application.Services.PollService>(); | |
| 72 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IBudgetCategoryService, SplitApp.WebApp.Application.Services.BudgetCategoryService>(); | |
| 73 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.IWishlistService, SplitApp.WebApp.Application.Services.WishlistService>(); | |
| 74 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.ISplitPresetService, SplitApp.WebApp.Application.Services.SplitPresetService>(); | |
| 75 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Identity.IIdentityService, SplitApp.WebApp.Application.Services.Identity.IdentityService>(); | |
| 76 | + | |
| 77 | +// Admin BLL services | |
| 78 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IBudgetCategoryAdminService, SplitApp.WebApp.Application.Services.Admin.BudgetCategoryAdminService>(); | |
| 79 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ICurrencyAdminService, SplitApp.WebApp.Application.Services.Admin.CurrencyAdminService>(); | |
| 80 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripAdminService, SplitApp.WebApp.Application.Services.Admin.TripAdminService>(); | |
| 81 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IExpenseAdminService, SplitApp.WebApp.Application.Services.Admin.ExpenseAdminService>(); | |
| 82 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IPollAdminService, SplitApp.WebApp.Application.Services.Admin.PollAdminService>(); | |
| 83 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IWishlistAdminService, SplitApp.WebApp.Application.Services.Admin.WishlistAdminService>(); | |
| 84 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPlanAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPlanAdminService>(); | |
| 85 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISettlementPaymentAdminService, SplitApp.WebApp.Application.Services.Admin.SettlementPaymentAdminService>(); | |
| 86 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ISplitPresetAdminService, SplitApp.WebApp.Application.Services.Admin.SplitPresetAdminService>(); | |
| 87 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.ITripParticipantAdminService, SplitApp.WebApp.Application.Services.Admin.TripParticipantAdminService>(); | |
| 88 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IInvitationAdminService, SplitApp.WebApp.Application.Services.Admin.InvitationAdminService>(); | |
| 89 | +builder.Services.AddScoped<SplitApp.WebApp.Application.Services.Admin.IAdminStatsService, SplitApp.WebApp.Application.Services.Admin.AdminStatsService>(); | |
| 90 | + | |
| 91 | +// Localization — EN+ET. resx files in Resources/Views/Shared.{resx,et.resx} are | |
| 92 | +// embedded with explicit LogicalName so they match the App.Resources.Views.Shared | |
| 93 | +// marker type (declared in Resources/Shared.cs). ResourcesPath="" because the | |
| 94 | +// LogicalName already targets the namespace directly. | |
| 95 | +builder.Services.AddLocalization(options => options.ResourcesPath = ""); | |
| 96 | + | |
| 97 | +var supportedCultures = (builder.Configuration.GetSection("SupportedCultures").Get<string[]>() | |
| 98 | + ?? new[] { "en", "et" }) | |
| 99 | + .Select(c => new CultureInfo(c)).ToArray(); | |
| 100 | +builder.Services.Configure<RequestLocalizationOptions>(options => | |
| 101 | +{ | |
| 102 | + options.SupportedCultures = supportedCultures; | |
| 103 | + options.SupportedUICultures = supportedCultures; | |
| 104 | + options.DefaultRequestCulture = new RequestCulture("en", "en"); | |
| 105 | + options.SetDefaultCulture("en"); | |
| 106 | + options.RequestCultureProviders = new List<IRequestCultureProvider> | |
| 107 | + { | |
| 108 | + new QueryStringRequestCultureProvider(), | |
| 109 | + new CookieRequestCultureProvider() | |
| 110 | + }; | |
| 111 | +}); | |
| 112 | + | |
| 113 | +builder.Services.AddAuthorization(); | |
| 114 | + | |
| 115 | +// CORS — same wide-open policy as phase 2 so external SPA frontends (Vite/Vue) can call | |
| 116 | +// /api/v1/* directly. WithExposedHeaders surfaces api-versioning headers to the JS client. | |
| 117 | +builder.Services.AddCors(options => | |
| 118 | +{ | |
| 119 | + options.AddPolicy("CorsAllowAll", policy => | |
| 120 | + { | |
| 121 | + policy | |
| 122 | + .AllowAnyOrigin() | |
| 123 | + .AllowAnyHeader() | |
| 124 | + .AllowAnyMethod() | |
| 125 | + .WithExposedHeaders("X-Version", "X-Version-Created-At"); | |
| 126 | + }); | |
| 127 | +}); | |
| 128 | + | |
| 129 | +var app = builder.Build(); | |
| 130 | + | |
| 131 | +// Per-module pipeline + migration hooks. Skipped in Testing so WebApplicationFactory | |
| 132 | +// smoke tests can boot without a real database. | |
| 133 | +if (!app.Environment.IsEnvironment("Testing")) | |
| 134 | +{ | |
| 135 | + app.UseUsersModule(); | |
| 136 | + app.UseTripsModule(); | |
| 137 | + app.UseExpensesModule(); | |
| 138 | + | |
| 139 | + // Cross-module example data — host-level concern, runs after all per-module | |
| 140 | + // migrations + per-module seeds (currencies, roles, identity users) are in place. | |
| 141 | + if (app.Configuration.GetValue<bool>("DataInitialization:SeedData")) | |
| 142 | + { | |
| 143 | + SplitApp.WebApp.Hosting.AppDataInit.SeedExampleData(app.Services); | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +if (app.Environment.IsDevelopment()) | |
| 148 | +{ | |
| 149 | + app.UseDeveloperExceptionPage(); | |
| 150 | +} | |
| 151 | +else | |
| 152 | +{ | |
| 153 | + app.UseExceptionHandler("/Home/Error"); | |
| 154 | +} | |
| 155 | + | |
| 156 | +// Swagger is always on so the Docker container (Production env) still serves it. | |
| 157 | +app.UseSwagger(); | |
| 158 | +app.UseSwaggerUI(options => | |
| 159 | +{ | |
| 160 | + var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>(); | |
| 161 | + foreach (var description in provider.ApiVersionDescriptions) | |
| 162 | + { | |
| 163 | + options.SwaggerEndpoint( | |
| 164 | + $"/swagger/{description.GroupName}/swagger.json", | |
| 165 | + description.GroupName.ToUpperInvariant()); | |
| 166 | + } | |
| 167 | +}); | |
| 168 | + | |
| 169 | +app.UseStaticFiles(); | |
| 170 | +app.UseHttpsRedirection(); | |
| 171 | +app.UseRouting(); | |
| 172 | + | |
| 173 | +app.UseRequestLocalization(app.Services.GetRequiredService<IOptions<RequestLocalizationOptions>>().Value); | |
| 174 | + | |
| 175 | +app.UseCors("CorsAllowAll"); | |
| 176 | + | |
| 177 | +app.UseAuthentication(); | |
| 178 | +app.UseAuthorization(); | |
| 179 | + | |
| 180 | +app.MapControllerRoute( | |
| 181 | + name: "areas", | |
| 182 | + pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}"); | |
| 183 | +app.MapControllerRoute( | |
| 184 | + name: "default", | |
| 185 | + pattern: "{controller=Home}/{action=Index}/{id?}"); | |
| 186 | +app.MapControllers(); | |
| 187 | +app.MapRazorPages(); | |
| 188 | + | |
| 189 | +app.Run(); | |
| 190 | + | |
| 191 | +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õrdselt (kõik)</value></data> | |
| 47 | + <data name="ESplitMethod_EqualSubset" xml:space="preserve"><value>Võrdselt (valik)</value></data> | |
| 48 | + <data name="ESplitMethod_ExactAmounts" xml:space="preserve"><value>Tä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õ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ü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ä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õ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ü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ä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ü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öö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ä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õte</value></data> | |
| 86 | + <data name="Dates" xml:space="preserve"><value>Kuupä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ü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ü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õ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üü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õ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äev</value></data> | |
| 133 | + <data name="End Date" xml:space="preserve"><value>Lõppkuupä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õ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ä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õ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õrdselt (kõik)</value></data> | |
| 158 | + <data name="Equal (subset)" xml:space="preserve"><value>Võrdselt (valik)</value></data> | |
| 159 | + <data name="Exact amounts" xml:space="preserve"><value>Tä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õ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ä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ää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ä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ä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õ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ääleta</value></data> | |
| 205 | + <data name="Voted" xml:space="preserve"><value>Hääletatud</value></data> | |
| 206 | + <data name="Mark Done" xml:space="preserve"><value>Mä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õi restorane, mida soovid külastada.</value></data> | |
| 212 | + | |
| 213 | + <!-- ========== POLLS ========== --> | |
| 214 | + <data name="Create Poll" xml:space="preserve"><value>Loo kü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üsitlus</value></data> | |
| 220 | + <data name="Back to Polls" xml:space="preserve"><value>Tagasi küsitluste juurde</value></data> | |
| 221 | + <data name="Poll Results" xml:space="preserve"><value>Küsitluse tulemused</value></data> | |
| 222 | + <data name="No polls yet" xml:space="preserve"><value>Küsitlused puuduvad</value></data> | |
| 223 | + <data name="Create a poll to help your group make decisions." xml:space="preserve"><value>Loo küsitlus, et aidata grupil otsuseid teha.</value></data> | |
| 224 | + <data name="votes" xml:space="preserve"><value>häält</value></data> | |
| 225 | + <data name="Multiple votes allowed" xml:space="preserve"><value>Mitu häält lubatud</value></data> | |
| 226 | + <data name="Allow multiple votes" xml:space="preserve"><value>Luba mitu häält</value></data> | |
| 227 | + <data name="Anonymous voting" xml:space="preserve"><value>Anonuumne hääletamine</value></data> | |
| 228 | + <data name="At least 2 options are required. Up to 5 options supported." xml:space="preserve"><value>Vajalik on vähemalt 2 valikut. Toetatud kuni 5 valikut.</value></data> | |
| 229 | + <data name="total votes" xml:space="preserve"><value>häält kokku</value></data> | |
| 230 | + <data name="Your vote" xml:space="preserve"><value>Sinu hääl</value></data> | |
| 231 | + <data name="Winner" xml:space="preserve"><value>Võitja</value></data> | |
| 232 | + <data name="Unvote" xml:space="preserve"><value>Tühista hää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ü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äev</value></data> | |
| 250 | + <data name="EndDate" xml:space="preserve"><value>Lõppkuupä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ä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ä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õ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õ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ü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ä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ühma reisikulude haldamine lihtsaks tehtud.</value></data> | |
| 296 | + <data name="AppDescription" xml:space="preserve"><value>Jälgi kulusid, jaga kulusid, halda eelarveid ja arvlda võlgu oma reisigrupiga.</value></data> | |
| 297 | + | |
| 298 | + <!-- ========== INVITATION ========== --> | |
| 299 | + <data name="Accept Invitation" xml:space="preserve"><value>Võ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õ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äeva pärast.</value></data> | |
| 308 | + <data name="The invitation link will expire after 7 days." xml:space="preserve"><value>Kutse link aegub 7 päeva pä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ää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üsitlus</value></data> | |
| 318 | + <data name="DeletePoll" xml:space="preserve"><value>Kustuta küsitlus</value></data> | |
| 319 | + <data name="EditPoll" xml:space="preserve"><value>Muuda kü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ü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ü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ü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 — 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õik arveldatud!</value></data> | |
| 342 | + <data name="An error occurred while processing your request." xml:space="preserve"><value>Teie päringu töötlemisel tekkis viga.</value></data> | |
| 343 | + <data name="Anonymous" xml:space="preserve"><value>Anonüümne</value></data> | |
| 344 | + <data name="At least 2 required" xml:space="preserve"><value>Vähemalt 2 nõ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üsitlusi, koosta soovinimekirju ja hää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õik, mida vajad grupireisiks</value></data> | |
| 358 | + <data name="From splitting dinner bills to planning activities, SplitApp handles it all." xml:space="preserve"><value>Õhtusöögiarve jagamisest tegevuste planeerimiseni — SplitApp teeb kõik ä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öö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äält</value></data> | |
| 371 | + <data name="Multiple votes" xml:space="preserve"><value>Mitu hää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 — kõ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õ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 ületatud!</value></data> | |
| 382 | + <data name="Overall Progress" xml:space="preserve"><value>Üldine edenemine</value></data> | |
| 383 | + <data name="Overview" xml:space="preserve"><value>Ü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älgi kulusid, jaga kulusid ja arvlda võlgu oma reisigrupiga — kõik ühes kohas.</value></data> | |
| 385 | + <data name="Planned" xml:space="preserve"><value>Planeeritud</value></data> | |
| 386 | + <data name="Poll" xml:space="preserve"><value>Kü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ä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ä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ä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õlgneb, ja arvlda minimaalsete maksetega.</value></data> | |
| 398 | + <data name="Set up your next adventure" xml:space="preserve"><value>Seadista oma jä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õ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ä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õrdselt, täpsete summade võ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älgimist.</value></data> | |
| 408 | + <data name="System Administration" xml:space="preserve"><value>Süsteemihaldus</value></data> | |
| 409 | + <data name="Total Trips" xml:space="preserve"><value>Reise kokku</value></data> | |
| 410 | + <data name="Track & Split" xml:space="preserve"><value>Jälgi ja jaga</value></data> | |
| 411 | + <data name="Track a group expense" xml:space="preserve"><value>Jä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õhtusöö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ä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ü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ü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 "{0}" created" xml:space="preserve"><value>Reis „{0}" loodi</value></data> | |
| 477 | + <data name="Expense {0:0.00} added to "{1}"" xml:space="preserve"><value>Kulu {0:0.00} lisatud reisile „{1}"</value></data> | |
| 478 | + <data name="Settlement plan for "{0}" ({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 & Split" xml:space="preserve"><value>Track & 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 "{0}" created" xml:space="preserve"><value>Trip "{0}" created</value></data> | |
| 477 | + <data name="Expense {0:0.00} added to "{1}"" xml:space="preserve"><value>Expense {0:0.00} added to "{1}"</value></data> | |
| 478 | + <data name="Settlement plan for "{0}" ({1})" xml:space="preserve"><value>Settlement plan for "{0}" ({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 +45 −0
| @@ -0,0 +1,45 @@ | ||
| 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="..\Modules\Users\SplitApp.Modules.Users.Api\SplitApp.Modules.Users.Api.csproj" /> | |
| 7 | + <ProjectReference Include="..\Modules\Users\SplitApp.Modules.Users.Infrastructure\SplitApp.Modules.Users.Infrastructure.csproj" /> | |
| 8 | + <ProjectReference Include="..\Modules\Trips\SplitApp.Modules.Trips.Api\SplitApp.Modules.Trips.Api.csproj" /> | |
| 9 | + <ProjectReference Include="..\Modules\Trips\SplitApp.Modules.Trips.Infrastructure\SplitApp.Modules.Trips.Infrastructure.csproj" /> | |
| 10 | + <ProjectReference Include="..\Modules\Expenses\SplitApp.Modules.Expenses.Api\SplitApp.Modules.Expenses.Api.csproj" /> | |
| 11 | + <ProjectReference Include="..\Modules\Expenses\SplitApp.Modules.Expenses.Infrastructure\SplitApp.Modules.Expenses.Infrastructure.csproj" /> | |
| 12 | + </ItemGroup> | |
| 13 | + | |
| 14 | + <ItemGroup> | |
| 15 | + <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" /> | |
| 16 | + <PackageReference Include="MediatR" Version="12.4.1" /> | |
| 17 | + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" /> | |
| 18 | + <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.5" /> | |
| 19 | + <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" /> | |
| 20 | + <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.5" /> | |
| 21 | + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5"> | |
| 22 | + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | |
| 23 | + <PrivateAssets>all</PrivateAssets> | |
| 24 | + </PackageReference> | |
| 25 | + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> | |
| 26 | + <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> | |
| 27 | + </ItemGroup> | |
| 28 | + | |
| 29 | + <PropertyGroup> | |
| 30 | + <TargetFramework>net10.0</TargetFramework> | |
| 31 | + <Nullable>enable</Nullable> | |
| 32 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 33 | + </PropertyGroup> | |
| 34 | + | |
| 35 | + <ItemGroup> | |
| 36 | + <!-- Force LogicalName so the resource manifest matches the marker type | |
| 37 | + App.Resources.Views.Shared (in Resources/Shared.cs) regardless of | |
| 38 | + what physical folder we keep the resx files in. --> | |
| 39 | + <EmbeddedResource Update="Resources\Views\Shared.resx" LogicalName="App.Resources.Views.Shared.resources" /> | |
| 40 | + <EmbeddedResource Update="Resources\Views\Shared.et.resx" LogicalName="App.Resources.Views.Shared.et.resources" /> | |
| 41 | + <EmbeddedResource Update="Resources\Domain\Enums.resx" LogicalName="App.Resources.Domain.Enums.resources" /> | |
| 42 | + <EmbeddedResource Update="Resources\Domain\Enums.et.resx" LogicalName="App.Resources.Domain.Enums.et.resources" /> | |
| 43 | + </ItemGroup> | |
| 44 | + | |
| 45 | +</Project> |
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 | + · @Localizer["Paid by"] @(Model.PaidByUser != null ? $"{Model.PaidByUser.FirstName} {Model.PaidByUser.LastName}" : "?") | |
| 32 | + @if (Model.BudgetCategory != null) | |
| 33 | + { | |
| 34 | + <span>· @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 | + · @expense.ExpenseDate.ToString("MMM dd, yyyy") | |
| 65 | + @if (expense.BudgetCategory != null) | |
| 66 | + { | |
| 67 | + <span>·</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 · @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 | + · @totalVotes @Localizer["votes"] | |
| 70 | + · @(poll.Options?.Count ?? 0) @Localizer["options"] | |
| 71 | + @if (poll.AllowMultipleVotes) | |
| 72 | + { | |
| 73 | + <span>· <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 +116 −0
| @@ -0,0 +1,116 @@ | ||
| 1 | +@using Microsoft.AspNetCore.Identity | |
| 2 | + | |
| 3 | +@inject SignInManager<AppUser> _signInManager | |
| 4 | + | |
| 5 | +<!DOCTYPE html> | |
| 6 | +<html lang="@Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName"> | |
| 7 | +<head> | |
| 8 | + <meta charset="utf-8" /> | |
| 9 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 10 | + <meta name="theme-color" content="#e8604c" /> | |
| 11 | + <title>@ViewData["Title"] - SplitApp</title> | |
| 12 | + <script type="importmap"></script> | |
| 13 | + | |
| 14 | + <!-- Fonts --> | |
| 15 | + <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 16 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 17 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| 18 | + | |
| 19 | + <!-- Icons --> | |
| 20 | + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" /> | |
| 21 | + | |
| 22 | + <!-- Styles --> | |
| 23 | + <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> | |
| 24 | + <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> | |
| 25 | + <link rel="stylesheet" href="~/css/splitapp-design.css" asp-append-version="true" /> | |
| 26 | + <link rel="stylesheet" href="~/SplitApp.WebApp.styles.css" asp-append-version="true" /> | |
| 27 | +</head> | |
| 28 | +<body> | |
| 29 | + <!-- Toast Container --> | |
| 30 | + <div id="sa-toast-container" class="sa-toast-container"></div> | |
| 31 | + | |
| 32 | + <!-- TempData Messages (read by splitapp.js) --> | |
| 33 | + <div id="sa-tempdata-messages" style="display:none" | |
| 34 | + data-success="@TempData["Success"]" | |
| 35 | + data-error="@TempData["Error"]" | |
| 36 | + data-warning="@TempData["Warning"]"></div> | |
| 37 | + | |
| 38 | + <!-- Navbar --> | |
| 39 | + <header> | |
| 40 | + <nav class="sa-navbar navbar navbar-expand-md"> | |
| 41 | + <div class="container"> | |
| 42 | + <a class="sa-navbar-brand" asp-area="" asp-controller="Home" asp-action="Index"> | |
| 43 | + <i class="bi bi-airplane-fill"></i> | |
| 44 | + SplitApp | |
| 45 | + </a> | |
| 46 | + <button class="navbar-toggler border-0" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" | |
| 47 | + aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation"> | |
| 48 | + <i class="bi bi-list" style="font-size: 1.5rem; color: var(--sa-gray-700);"></i> | |
| 49 | + </button> | |
| 50 | + <div class="navbar-collapse collapse" id="mainNav"> | |
| 51 | + <ul class="navbar-nav me-auto"> | |
| 52 | + <li class="nav-item"> | |
| 53 | + <a class="nav-link" asp-area="" asp-controller="Home" asp-action="Index"> | |
| 54 | + <i class="bi bi-house-door me-1"></i>@Localizer["Home"] | |
| 55 | + </a> | |
| 56 | + </li> | |
| 57 | + <li class="nav-item"> | |
| 58 | + <a class="nav-link" href="/swagger" target="_blank"> | |
| 59 | + <i class="bi bi-braces me-1"></i>API | |
| 60 | + </a> | |
| 61 | + </li> | |
| 62 | + @if (_signInManager.IsSignedIn(User)) | |
| 63 | + { | |
| 64 | + <li class="nav-item"> | |
| 65 | + <a class="nav-link" asp-area="" asp-controller="Trips" asp-action="Index"> | |
| 66 | + <i class="bi bi-luggage me-1"></i>@Localizer["Trips"] | |
| 67 | + </a> | |
| 68 | + </li> | |
| 69 | + @if (User.IsInRole("admin")) | |
| 70 | + { | |
| 71 | + <li class="nav-item"> | |
| 72 | + <a class="nav-link" asp-area="Admin" asp-controller="Dashboard" asp-action="Index"> | |
| 73 | + <i class="bi bi-speedometer2 me-1"></i>@Localizer["Admin Panel"] | |
| 74 | + </a> | |
| 75 | + </li> | |
| 76 | + } | |
| 77 | + } | |
| 78 | + </ul> | |
| 79 | + <div class="d-flex align-items-center gap-3"> | |
| 80 | + <partial name="_LanguageSelection" /> | |
| 81 | + <partial name="_LoginPartial" /> | |
| 82 | + </div> | |
| 83 | + </div> | |
| 84 | + </div> | |
| 85 | + </nav> | |
| 86 | + </header> | |
| 87 | + | |
| 88 | + <!-- Main Content --> | |
| 89 | + <div class="container sa-animate-fade-in" style="padding-top: var(--sa-space-6); padding-bottom: var(--sa-space-8);"> | |
| 90 | + <main role="main"> | |
| 91 | + @RenderBody() | |
| 92 | + </main> | |
| 93 | + </div> | |
| 94 | + | |
| 95 | + <!-- Footer --> | |
| 96 | + <footer class="sa-footer"> | |
| 97 | + <div class="container d-flex justify-content-between align-items-center flex-wrap gap-3"> | |
| 98 | + <div> | |
| 99 | + <span style="font-weight: 600; color: var(--sa-gray-200);"> | |
| 100 | + <i class="bi bi-airplane-fill me-1"></i> SplitApp | |
| 101 | + </span> | |
| 102 | + <span class="ms-2">© 2026 TalTech</span> | |
| 103 | + </div> | |
| 104 | + <div class="d-flex align-items-center gap-2"> | |
| 105 | + <span style="font-size: 0.8rem;">@Thread.CurrentThread.CurrentUICulture.Name</span> | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + </footer> | |
| 109 | + | |
| 110 | + <script src="~/lib/jquery/dist/jquery.min.js"></script> | |
| 111 | + <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> | |
| 112 | + <script src="~/js/splitapp.js" asp-append-version="true"></script> | |
| 113 | + <script src="~/js/site.js" asp-append-version="true"></script> | |
| 114 | + @await RenderSectionAsync("Scripts", required: false) | |
| 115 | +</body> | |
| 116 | +</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 +50 −0
| @@ -0,0 +1,50 @@ | ||
| 1 | +@using Microsoft.AspNetCore.Identity | |
| 2 | + | |
| 3 | +@inject SignInManager<AppUser> SignInManager | |
| 4 | +@inject UserManager<AppUser> UserManager | |
| 5 | + | |
| 6 | +@if (SignInManager.IsSignedIn(User)) | |
| 7 | +{ | |
| 8 | + var user = await UserManager.GetUserAsync(User); | |
| 9 | + var initials = ""; | |
| 10 | + if (user != null) | |
| 11 | + { | |
| 12 | + initials = (user.FirstName?.Length > 0 ? user.FirstName[0].ToString() : "") + | |
| 13 | + (user.LastName?.Length > 0 ? user.LastName[0].ToString() : ""); | |
| 14 | + } | |
| 15 | + | |
| 16 | + <div class="dropdown"> | |
| 17 | + <button class="sa-nav-avatar-btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" | |
| 18 | + data-bs-auto-close="true" style="border: none;"> | |
| 19 | + <span class="sa-avatar sa-avatar-sm sa-avatar-2" style="border:none;">@initials</span> | |
| 20 | + <span class="sa-hide-mobile">@User.Identity?.Name</span> | |
| 21 | + </button> | |
| 22 | + <ul class="dropdown-menu dropdown-menu-end"> | |
| 23 | + <li> | |
| 24 | + <a class="dropdown-item" asp-area="Identity" asp-page="/Account/Manage/Index"> | |
| 25 | + <i class="bi bi-person me-2"></i>@Localizer["Profile"] | |
| 26 | + </a> | |
| 27 | + </li> | |
| 28 | + <li><hr class="dropdown-divider" /></li> | |
| 29 | + <li> | |
| 30 | + <form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" | |
| 31 | + asp-route-returnUrl="@Url.Action("Index", "Home", new { area = "" })"> | |
| 32 | + <button type="submit" class="dropdown-item text-danger"> | |
| 33 | + <i class="bi bi-box-arrow-right me-2"></i>@Localizer["Logout"] | |
| 34 | + </button> | |
| 35 | + </form> | |
| 36 | + </li> | |
| 37 | + </ul> | |
| 38 | + </div> | |
| 39 | +} | |
| 40 | +else | |
| 41 | +{ | |
| 42 | + <div class="sa-navbar-auth-btns"> | |
| 43 | + <a class="sa-btn sa-btn-ghost sa-btn-sm" asp-area="Identity" asp-page="/Account/Login"> | |
| 44 | + @Localizer["Log in"] | |
| 45 | + </a> | |
| 46 | + <a class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill" asp-area="Identity" asp-page="/Account/Register"> | |
| 47 | + @Localizer["Sign up"] | |
| 48 | + </a> | |
| 49 | + </div> | |
| 50 | +} |
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 | + · @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 +15 −0
| @@ -0,0 +1,15 @@ | ||
| 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.Modules.Users.Domain.Entities | |
| 11 | +@using SplitApp.Shared.Kernel.Localization | |
| 12 | +@using Microsoft.Extensions.Localization | |
| 13 | +@using Microsoft.AspNetCore.Mvc.Localization | |
| 14 | +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers | |
| 15 | +@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 +27 −0
| @@ -0,0 +1,27 @@ | ||
| 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 | + "SeedIdentity": true, | |
| 9 | + "SeedData": true | |
| 10 | + }, | |
| 11 | + "SupportedCultures": ["en", "et"], | |
| 12 | + "DefaultCulture": "en", | |
| 13 | + "LangStrDefaultCulture": "en", | |
| 14 | + "JWT": { | |
| 15 | + "Key": "dev-only-jwt-signing-key-override-in-production-via-JWT_KEY-env-var", | |
| 16 | + "Issuer": "itcollege.taltech.ee", | |
| 17 | + "Audience": "itcollege.taltech.ee", | |
| 18 | + "ExpiresInSeconds": 1800 | |
| 19 | + }, | |
| 20 | + "Logging": { | |
| 21 | + "LogLevel": { | |
| 22 | + "Default": "Information", | |
| 23 | + "Microsoft.AspNetCore": "Warning" | |
| 24 | + } | |
| 25 | + }, | |
| 26 | + "AllowedHosts": "*" | |
| 27 | +} |
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/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/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/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/tests/SplitApp.Modules.Expenses.Tests/CurrencyConverterTests.cs +61 −0
| @@ -0,0 +1,61 @@ | ||
| 1 | +using SplitApp.Modules.Expenses.Application; | |
| 2 | + | |
| 3 | +namespace SplitApp.Modules.Expenses.Tests; | |
| 4 | + | |
| 5 | +public class CurrencyConverterTests | |
| 6 | +{ | |
| 7 | + [Fact] | |
| 8 | + public void Convert_SameCurrency_ReturnsAmountUnchanged() | |
| 9 | + { | |
| 10 | + Assert.Equal(100m, CurrencyConverter.Convert(100m, "EUR", "EUR")); | |
| 11 | + } | |
| 12 | + | |
| 13 | + [Theory] | |
| 14 | + [InlineData(100, "USD", "EUR", 92.0)] | |
| 15 | + [InlineData(100, "EUR", "USD", 108.70)] | |
| 16 | + [InlineData(100, "GBP", "EUR", 116.0)] | |
| 17 | + public void Convert_KnownCurrencies_ReturnsCorrectExchange(decimal amount, string from, string to, decimal expected) | |
| 18 | + { | |
| 19 | + Assert.Equal(expected, CurrencyConverter.Convert(amount, from, to)); | |
| 20 | + } | |
| 21 | + | |
| 22 | + [Fact] | |
| 23 | + public void Convert_UnknownCurrency_FallsBackToOneToOne() | |
| 24 | + { | |
| 25 | + Assert.Equal(100m, CurrencyConverter.Convert(100m, "XYZ", "EUR")); | |
| 26 | + Assert.Equal(100m, CurrencyConverter.Convert(100m, "EUR", "XYZ")); | |
| 27 | + } | |
| 28 | + | |
| 29 | + [Fact] | |
| 30 | + public void Convert_ZeroAmount_ReturnsZero() | |
| 31 | + { | |
| 32 | + Assert.Equal(0m, CurrencyConverter.Convert(0m, "USD", "EUR")); | |
| 33 | + Assert.Equal(0m, CurrencyConverter.Convert(0m, "EUR", "EUR")); | |
| 34 | + } | |
| 35 | + | |
| 36 | + [Theory] | |
| 37 | + [InlineData(-100, "USD", "EUR", -92.0)] | |
| 38 | + [InlineData(-50, "EUR", "USD", -54.35)] | |
| 39 | + public void Convert_NegativeAmount_ConvertsLikeRefund(decimal amount, string from, string to, decimal expected) | |
| 40 | + { | |
| 41 | + Assert.Equal(expected, CurrencyConverter.Convert(amount, from, to)); | |
| 42 | + } | |
| 43 | + | |
| 44 | + [Fact] | |
| 45 | + public void Convert_ResultIsAlwaysRoundedToTwoDecimals() | |
| 46 | + { | |
| 47 | + var result = CurrencyConverter.Convert(33.337m, "USD", "EUR"); | |
| 48 | + var fractionalDigits = decimal.GetBits(result)[3] >> 16 & 0xFF; | |
| 49 | + Assert.True(fractionalDigits <= 2, $"Expected ≤2 decimal places, got {fractionalDigits} (value {result})"); | |
| 50 | + } | |
| 51 | + | |
| 52 | + [Fact] | |
| 53 | + public void Convert_RoundTrip_StaysWithinOneCentTolerance() | |
| 54 | + { | |
| 55 | + var original = 100m; | |
| 56 | + var toUsd = CurrencyConverter.Convert(original, "EUR", "USD"); | |
| 57 | + var backToEur = CurrencyConverter.Convert(toUsd, "USD", "EUR"); | |
| 58 | + Assert.True(Math.Abs(original - backToEur) <= 0.02m, | |
| 59 | + $"Round-trip lost too much precision: {original} → {toUsd} → {backToEur}"); | |
| 60 | + } | |
| 61 | +} |
added SplitApp.Modular/tests/SplitApp.Modules.Expenses.Tests/SplitApp.Modules.Expenses.Tests.csproj +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <PropertyGroup> | |
| 4 | + <TargetFramework>net10.0</TargetFramework> | |
| 5 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 6 | + <Nullable>enable</Nullable> | |
| 7 | + <IsPackable>false</IsPackable> | |
| 8 | + </PropertyGroup> | |
| 9 | + | |
| 10 | + <ItemGroup> | |
| 11 | + <PackageReference Include="coverlet.collector" Version="6.0.4" /> | |
| 12 | + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | |
| 13 | + <PackageReference Include="xunit" Version="2.9.3" /> | |
| 14 | + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> | |
| 15 | + </ItemGroup> | |
| 16 | + | |
| 17 | + <ItemGroup> | |
| 18 | + <Using Include="Xunit" /> | |
| 19 | + </ItemGroup> | |
| 20 | + | |
| 21 | + <ItemGroup> | |
| 22 | + <ProjectReference Include="..\..\src\Modules\Expenses\SplitApp.Modules.Expenses.Application\SplitApp.Modules.Expenses.Application.csproj" /> | |
| 23 | + </ItemGroup> | |
| 24 | + | |
| 25 | +</Project> | |
| No newline at end of file |
added SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/LangStrTests.cs +86 −0
| @@ -0,0 +1,86 @@ | ||
| 1 | +using SplitApp.Shared.Kernel.Localization; | |
| 2 | + | |
| 3 | +namespace SplitApp.Modules.Trips.Tests; | |
| 4 | + | |
| 5 | +public class LangStrTests | |
| 6 | +{ | |
| 7 | + [Fact] | |
| 8 | + public void Constructor_WithDefaultCulture_StoresValueUnderDefaultKey() | |
| 9 | + { | |
| 10 | + var s = new LangStr("Hello", "en"); | |
| 11 | + Assert.Equal("Hello", s.Translate("en")); | |
| 12 | + } | |
| 13 | + | |
| 14 | + [Fact] | |
| 15 | + public void Translate_WithUnknownCulture_FallsBackToDefault() | |
| 16 | + { | |
| 17 | + var s = new LangStr(); | |
| 18 | + s["en"] = "Hello"; | |
| 19 | + Assert.Equal("Hello", s.Translate("fr")); | |
| 20 | + } | |
| 21 | + | |
| 22 | + [Fact] | |
| 23 | + public void Translate_WithRegionalCulture_FallsBackToNeutralCulture() | |
| 24 | + { | |
| 25 | + var s = new LangStr(); | |
| 26 | + s["et"] = "Tere"; | |
| 27 | + Assert.Equal("Tere", s.Translate("et-EE")); | |
| 28 | + } | |
| 29 | + | |
| 30 | + [Theory] | |
| 31 | + [InlineData("en", "Hello")] | |
| 32 | + [InlineData("et", "Tere")] | |
| 33 | + [InlineData("de", "Hallo")] | |
| 34 | + public void Translate_ReturnsCorrectValueForKnownCulture(string culture, string expected) | |
| 35 | + { | |
| 36 | + var s = new LangStr(); | |
| 37 | + s["en"] = "Hello"; | |
| 38 | + s["et"] = "Tere"; | |
| 39 | + s["de"] = "Hallo"; | |
| 40 | + Assert.Equal(expected, s.Translate(culture)); | |
| 41 | + } | |
| 42 | + | |
| 43 | + [Fact] | |
| 44 | + public void ImplicitOperator_FromString_CreatesLangStrWithCurrentCulture() | |
| 45 | + { | |
| 46 | + LangStr s = "Test"; | |
| 47 | + Assert.Equal("Test", s.Translate()); | |
| 48 | + } | |
| 49 | + | |
| 50 | + [Fact] | |
| 51 | + public void Translate_EmptyDictionary_ReturnsNull() | |
| 52 | + { | |
| 53 | + var s = new LangStr(); | |
| 54 | + Assert.Null(s.Translate("en")); | |
| 55 | + } | |
| 56 | + | |
| 57 | + [Fact] | |
| 58 | + public void ToString_EmptyDictionary_ReturnsFourQuestionMarks() | |
| 59 | + { | |
| 60 | + var s = new LangStr(); | |
| 61 | + Assert.Equal("????", s.ToString()); | |
| 62 | + } | |
| 63 | + | |
| 64 | + [Fact] | |
| 65 | + public void Constructor_EmptyCulture_ThrowsApplicationException() | |
| 66 | + { | |
| 67 | + Assert.Throws<ApplicationException>(() => new LangStr("value", "")); | |
| 68 | + } | |
| 69 | + | |
| 70 | + [Fact] | |
| 71 | + public void SetTranslation_OverwritesExistingCultureValue() | |
| 72 | + { | |
| 73 | + var s = new LangStr(); | |
| 74 | + s["et"] = "Tere"; | |
| 75 | + s.SetTranslation("Tervist", "et"); | |
| 76 | + Assert.Equal("Tervist", s.Translate("et")); | |
| 77 | + } | |
| 78 | + | |
| 79 | + [Fact] | |
| 80 | + public void ImplicitOperatorToString_OnNullLangStr_ReturnsLiteralNullString() | |
| 81 | + { | |
| 82 | + LangStr? s = null; | |
| 83 | + string asString = s!; | |
| 84 | + Assert.Equal("null", asString); | |
| 85 | + } | |
| 86 | +} |
added SplitApp.Modular/tests/SplitApp.Modules.Trips.Tests/SplitApp.Modules.Trips.Tests.csproj +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <PropertyGroup> | |
| 4 | + <TargetFramework>net10.0</TargetFramework> | |
| 5 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 6 | + <Nullable>enable</Nullable> | |
| 7 | + <IsPackable>false</IsPackable> | |
| 8 | + </PropertyGroup> | |
| 9 | + | |
| 10 | + <ItemGroup> | |
| 11 | + <PackageReference Include="coverlet.collector" Version="6.0.4" /> | |
| 12 | + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | |
| 13 | + <PackageReference Include="xunit" Version="2.9.3" /> | |
| 14 | + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> | |
| 15 | + </ItemGroup> | |
| 16 | + | |
| 17 | + <ItemGroup> | |
| 18 | + <Using Include="Xunit" /> | |
| 19 | + </ItemGroup> | |
| 20 | + | |
| 21 | + <ItemGroup> | |
| 22 | + <ProjectReference Include="..\..\src\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" /> | |
| 23 | + </ItemGroup> | |
| 24 | + | |
| 25 | +</Project> | |
| No newline at end of file |
added SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/IdentityHelpersTests.cs +105 −0
| @@ -0,0 +1,105 @@ | ||
| 1 | +using System.IdentityModel.Tokens.Jwt; | |
| 2 | +using System.Security.Claims; | |
| 3 | +using SplitApp.Shared.Kernel.Auth; | |
| 4 | + | |
| 5 | +namespace SplitApp.Modules.Users.Tests; | |
| 6 | + | |
| 7 | +public class IdentityHelpersTests | |
| 8 | +{ | |
| 9 | + private const string Key = "this-is-a-long-enough-test-signing-key-for-hs256"; | |
| 10 | + private const string Issuer = "splitapp-test"; | |
| 11 | + private const string Audience = "splitapp-test-audience"; | |
| 12 | + | |
| 13 | + [Fact] | |
| 14 | + public void GenerateJwt_ProducesTokenContainingAllClaims() | |
| 15 | + { | |
| 16 | + var claims = new[] | |
| 17 | + { | |
| 18 | + new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()), | |
| 19 | + new Claim(ClaimTypes.Email, "alice@example.com"), | |
| 20 | + }; | |
| 21 | + | |
| 22 | + var jwt = IdentityHelpers.GenerateJwt(claims, Key, Issuer, Audience, expiresInSeconds: 60); | |
| 23 | + | |
| 24 | + Assert.False(string.IsNullOrWhiteSpace(jwt)); | |
| 25 | + | |
| 26 | + var parsed = new JwtSecurityTokenHandler().ReadJwtToken(jwt); | |
| 27 | + Assert.Equal(Issuer, parsed.Issuer); | |
| 28 | + Assert.Contains(parsed.Audiences, a => a == Audience); | |
| 29 | + Assert.Contains(parsed.Claims, c => c.Type == ClaimTypes.Email && c.Value == "alice@example.com"); | |
| 30 | + } | |
| 31 | + | |
| 32 | + [Fact] | |
| 33 | + public void ValidateJWT_ReturnsTrue_ForTokenSignedWithSameKey() | |
| 34 | + { | |
| 35 | + var jwt = IdentityHelpers.GenerateJwt( | |
| 36 | + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }, | |
| 37 | + Key, Issuer, Audience, expiresInSeconds: 60); | |
| 38 | + | |
| 39 | + Assert.True(IdentityHelpers.ValidateJWT(jwt, Key, Issuer, Audience)); | |
| 40 | + } | |
| 41 | + | |
| 42 | + [Fact] | |
| 43 | + public void ValidateJWT_ReturnsFalse_ForWrongSigningKey() | |
| 44 | + { | |
| 45 | + var jwt = IdentityHelpers.GenerateJwt( | |
| 46 | + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }, | |
| 47 | + Key, Issuer, Audience, expiresInSeconds: 60); | |
| 48 | + | |
| 49 | + Assert.False(IdentityHelpers.ValidateJWT(jwt, "different-signing-key-different-from-the-original-one", Issuer, Audience)); | |
| 50 | + } | |
| 51 | + | |
| 52 | + [Fact] | |
| 53 | + public void ValidateJWT_IgnoresExpiration_ForRefreshScenario() | |
| 54 | + { | |
| 55 | + var jwt = IdentityHelpers.GenerateJwt( | |
| 56 | + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }, | |
| 57 | + Key, Issuer, Audience, expiresInSeconds: -60); | |
| 58 | + | |
| 59 | + Assert.True(IdentityHelpers.ValidateJWT(jwt, Key, Issuer, Audience)); | |
| 60 | + } | |
| 61 | + | |
| 62 | + [Fact] | |
| 63 | + public void ValidateJWT_ReturnsFalse_ForWrongIssuer() | |
| 64 | + { | |
| 65 | + var jwt = IdentityHelpers.GenerateJwt( | |
| 66 | + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }, | |
| 67 | + Key, Issuer, Audience, expiresInSeconds: 60); | |
| 68 | + | |
| 69 | + Assert.False(IdentityHelpers.ValidateJWT(jwt, Key, "different-issuer", Audience)); | |
| 70 | + } | |
| 71 | + | |
| 72 | + [Fact] | |
| 73 | + public void ValidateJWT_ReturnsFalse_ForWrongAudience() | |
| 74 | + { | |
| 75 | + var jwt = IdentityHelpers.GenerateJwt( | |
| 76 | + new[] { new Claim(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()) }, | |
| 77 | + Key, Issuer, Audience, expiresInSeconds: 60); | |
| 78 | + | |
| 79 | + Assert.False(IdentityHelpers.ValidateJWT(jwt, Key, Issuer, "different-audience")); | |
| 80 | + } | |
| 81 | + | |
| 82 | + [Fact] | |
| 83 | + public void ValidateJWT_ReturnsFalse_ForMalformedToken() | |
| 84 | + { | |
| 85 | + Assert.False(IdentityHelpers.ValidateJWT("not-a-real-jwt-string", Key, Issuer, Audience)); | |
| 86 | + Assert.False(IdentityHelpers.ValidateJWT("a.b.c", Key, Issuer, Audience)); | |
| 87 | + } | |
| 88 | + | |
| 89 | + [Fact] | |
| 90 | + public void GenerateJwt_PreservesCustomClaimType_InProducedToken() | |
| 91 | + { | |
| 92 | + var claims = new[] | |
| 93 | + { | |
| 94 | + new Claim(ClaimTypes.NameIdentifier, "user-1"), | |
| 95 | + new Claim("trip_count", "3"), | |
| 96 | + new Claim(ClaimTypes.Role, "admin"), | |
| 97 | + }; | |
| 98 | + | |
| 99 | + var jwt = IdentityHelpers.GenerateJwt(claims, Key, Issuer, Audience, expiresInSeconds: 60); | |
| 100 | + var parsed = new JwtSecurityTokenHandler().ReadJwtToken(jwt); | |
| 101 | + | |
| 102 | + Assert.Contains(parsed.Claims, c => c.Type == "trip_count" && c.Value == "3"); | |
| 103 | + Assert.Contains(parsed.Claims, c => c.Value == "admin"); | |
| 104 | + } | |
| 105 | +} |
added SplitApp.Modular/tests/SplitApp.Modules.Users.Tests/SplitApp.Modules.Users.Tests.csproj +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <PropertyGroup> | |
| 4 | + <TargetFramework>net10.0</TargetFramework> | |
| 5 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 6 | + <Nullable>enable</Nullable> | |
| 7 | + <IsPackable>false</IsPackable> | |
| 8 | + </PropertyGroup> | |
| 9 | + | |
| 10 | + <ItemGroup> | |
| 11 | + <PackageReference Include="coverlet.collector" Version="6.0.4" /> | |
| 12 | + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | |
| 13 | + <PackageReference Include="xunit" Version="2.9.3" /> | |
| 14 | + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> | |
| 15 | + </ItemGroup> | |
| 16 | + | |
| 17 | + <ItemGroup> | |
| 18 | + <Using Include="Xunit" /> | |
| 19 | + </ItemGroup> | |
| 20 | + | |
| 21 | + <ItemGroup> | |
| 22 | + <ProjectReference Include="..\..\src\Shared\SplitApp.Shared.Kernel\SplitApp.Shared.Kernel.csproj" /> | |
| 23 | + </ItemGroup> | |
| 24 | + | |
| 25 | +</Project> | |
| No newline at end of file |
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/CrossModuleNavigationTests.cs +90 −0
| @@ -0,0 +1,90 @@ | ||
| 1 | +using System.IO; | |
| 2 | +using System.Linq; | |
| 3 | +using System.Text.RegularExpressions; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.IntegrationTests.Architecture; | |
| 6 | + | |
| 7 | +/// <summary> | |
| 8 | +/// Cross-module entity navigations are allowed only when annotated <c>[NotMapped]</c>. | |
| 9 | +/// EF still treats every module's schema as isolated (cross-module navs are never | |
| 10 | +/// loaded via Include) — the WebApp facade hydrates them in-memory after fetching | |
| 11 | +/// from the appropriate module's DbContext. A plain (mapped) navigation across | |
| 12 | +/// modules would let EF cross schemas and is therefore forbidden. | |
| 13 | +/// </summary> | |
| 14 | +public class CrossModuleNavigationTests | |
| 15 | +{ | |
| 16 | + private static string SolutionRoot() | |
| 17 | + { | |
| 18 | + var dir = new DirectoryInfo(AppContext.BaseDirectory); | |
| 19 | + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SplitApp.sln"))) | |
| 20 | + { | |
| 21 | + dir = dir.Parent; | |
| 22 | + } | |
| 23 | + Assert.NotNull(dir); | |
| 24 | + return dir!.FullName; | |
| 25 | + } | |
| 26 | + | |
| 27 | + [Fact] | |
| 28 | + public void NoDomainEntityHasMappedNavigationToAnotherModulesEntityType() | |
| 29 | + { | |
| 30 | + var modulesDir = Path.Combine(SolutionRoot(), "src", "Modules"); | |
| 31 | + var moduleNames = Directory.GetDirectories(modulesDir).Select(Path.GetFileName).ToList(); | |
| 32 | + | |
| 33 | + var entitiesByModule = moduleNames.ToDictionary( | |
| 34 | + m => m!, | |
| 35 | + m => Directory | |
| 36 | + .GetFiles(Path.Combine(modulesDir, m!, $"SplitApp.Modules.{m}.Domain", "Entities"), | |
| 37 | + "*.cs", SearchOption.TopDirectoryOnly) | |
| 38 | + .Select(Path.GetFileNameWithoutExtension) | |
| 39 | + .Where(n => n != null) | |
| 40 | + .Cast<string>() | |
| 41 | + .ToHashSet()); | |
| 42 | + | |
| 43 | + var failures = new List<string>(); | |
| 44 | + | |
| 45 | + foreach (var module in moduleNames) | |
| 46 | + { | |
| 47 | + var foreignEntities = entitiesByModule | |
| 48 | + .Where(kv => kv.Key != module) | |
| 49 | + .SelectMany(kv => kv.Value) | |
| 50 | + .ToHashSet(); | |
| 51 | + | |
| 52 | + var entityFiles = Directory.GetFiles( | |
| 53 | + Path.Combine(modulesDir, module!, $"SplitApp.Modules.{module}.Domain", "Entities"), | |
| 54 | + "*.cs", | |
| 55 | + SearchOption.TopDirectoryOnly); | |
| 56 | + | |
| 57 | + foreach (var file in entityFiles) | |
| 58 | + { | |
| 59 | + var lines = File.ReadAllLines(file); | |
| 60 | + for (var i = 0; i < lines.Length; i++) | |
| 61 | + { | |
| 62 | + var line = Regex.Replace(lines[i], "//.*", "").TrimStart(); | |
| 63 | + if (!line.StartsWith("public ")) continue; | |
| 64 | + | |
| 65 | + foreach (var foreign in foreignEntities) | |
| 66 | + { | |
| 67 | + // Look for "public Currency? Foo {", "public ICollection<Currency>? Foo {", etc. | |
| 68 | + var pattern = $@"public\s+(?:I[A-Za-z]+<\s*)?{Regex.Escape(foreign)}\??\s*>?\s*\??\s+\w+\s*\{{"; | |
| 69 | + if (!Regex.IsMatch(line, pattern)) continue; | |
| 70 | + | |
| 71 | + // Allow if [NotMapped] is on this line OR on any of the previous (attribute) lines | |
| 72 | + var hasNotMapped = false; | |
| 73 | + for (var k = i; k >= 0 && k >= i - 3; k--) | |
| 74 | + { | |
| 75 | + if (lines[k].Contains("[NotMapped]")) { hasNotMapped = true; break; } | |
| 76 | + if (k < i && !lines[k].TrimStart().StartsWith("[") && lines[k].TrimStart().Length > 0) break; | |
| 77 | + } | |
| 78 | + | |
| 79 | + if (!hasNotMapped) | |
| 80 | + { | |
| 81 | + failures.Add($"{Path.GetFileName(file)} (module {module}) line {i + 1}: mapped navigation to foreign entity '{foreign}'"); | |
| 82 | + } | |
| 83 | + } | |
| 84 | + } | |
| 85 | + } | |
| 86 | + } | |
| 87 | + | |
| 88 | + Assert.Empty(failures); | |
| 89 | + } | |
| 90 | +} |
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/DbContextSchemaIsolationTests.cs +70 −0
| @@ -0,0 +1,70 @@ | ||
| 1 | +using System.IO; | |
| 2 | +using System.Linq; | |
| 3 | +using System.Text.RegularExpressions; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.IntegrationTests.Architecture; | |
| 6 | + | |
| 7 | +/// <summary> | |
| 8 | +/// Each module's DbContext must only expose <c>DbSet<T></c> for entities that live in | |
| 9 | +/// its own Domain project (plus a small framework allowlist). This guards the rule that | |
| 10 | +/// modules never share tables and never query each other's storage directly. | |
| 11 | +/// </summary> | |
| 12 | +public class DbContextSchemaIsolationTests | |
| 13 | +{ | |
| 14 | + private static readonly string[] FrameworkAllowlist = | |
| 15 | + { | |
| 16 | + "DataProtectionKey", | |
| 17 | + }; | |
| 18 | + | |
| 19 | + private static string SolutionRoot() | |
| 20 | + { | |
| 21 | + var dir = new DirectoryInfo(AppContext.BaseDirectory); | |
| 22 | + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SplitApp.sln"))) | |
| 23 | + { | |
| 24 | + dir = dir.Parent; | |
| 25 | + } | |
| 26 | + Assert.NotNull(dir); | |
| 27 | + return dir!.FullName; | |
| 28 | + } | |
| 29 | + | |
| 30 | + public static IEnumerable<object[]> Modules() | |
| 31 | + { | |
| 32 | + yield return new object[] { "Users", "UsersDbContext.cs" }; | |
| 33 | + yield return new object[] { "Trips", "TripsDbContext.cs" }; | |
| 34 | + yield return new object[] { "Expenses", "ExpensesDbContext.cs" }; | |
| 35 | + } | |
| 36 | + | |
| 37 | + [Theory] | |
| 38 | + [MemberData(nameof(Modules))] | |
| 39 | + public void DbContextOnlyExposesDbSetsForOwnDomainEntities(string module, string dbContextFileName) | |
| 40 | + { | |
| 41 | + var moduleRoot = Path.Combine(SolutionRoot(), "src", "Modules", module); | |
| 42 | + var dbContextFile = Directory | |
| 43 | + .GetFiles(moduleRoot, dbContextFileName, SearchOption.AllDirectories) | |
| 44 | + .Single(); | |
| 45 | + | |
| 46 | + var domainEntitiesDir = Path.Combine(moduleRoot, $"SplitApp.Modules.{module}.Domain", "Entities"); | |
| 47 | + Assert.True(Directory.Exists(domainEntitiesDir), $"Missing entities dir: {domainEntitiesDir}"); | |
| 48 | + | |
| 49 | + var ownEntityNames = Directory | |
| 50 | + .GetFiles(domainEntitiesDir, "*.cs", SearchOption.TopDirectoryOnly) | |
| 51 | + .Select(Path.GetFileNameWithoutExtension) | |
| 52 | + .ToHashSet(StringComparer.Ordinal); | |
| 53 | + | |
| 54 | + var src = File.ReadAllText(dbContextFile); | |
| 55 | + var dbSetTypes = Regex.Matches(src, @"DbSet<(\w+)>") | |
| 56 | + .Select(m => m.Groups[1].Value) | |
| 57 | + .Distinct() | |
| 58 | + .ToList(); | |
| 59 | + | |
| 60 | + Assert.NotEmpty(dbSetTypes); | |
| 61 | + | |
| 62 | + var foreign = dbSetTypes | |
| 63 | + .Where(t => !ownEntityNames!.Contains(t!) && !FrameworkAllowlist.Contains(t)) | |
| 64 | + .ToList(); | |
| 65 | + | |
| 66 | + Assert.True( | |
| 67 | + foreign.Count == 0, | |
| 68 | + $"{dbContextFileName} exposes DbSet<> for non-{module}-domain types: {string.Join(", ", foreign)}"); | |
| 69 | + } | |
| 70 | +} |
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/Architecture/ModuleBoundaryTests.cs +87 −0
| @@ -0,0 +1,87 @@ | ||
| 1 | +using System.IO; | |
| 2 | +using System.Linq; | |
| 3 | +using System.Xml.Linq; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.IntegrationTests.Architecture; | |
| 6 | + | |
| 7 | +/// <summary> | |
| 8 | +/// Compile-time invariant: no module project may reference another module's project. | |
| 9 | +/// Modules can only depend on Shared.Kernel and Shared.Contracts. | |
| 10 | +/// </summary> | |
| 11 | +public class ModuleBoundaryTests | |
| 12 | +{ | |
| 13 | + private static string SolutionRoot() | |
| 14 | + { | |
| 15 | + var dir = new DirectoryInfo(AppContext.BaseDirectory); | |
| 16 | + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "SplitApp.sln"))) | |
| 17 | + { | |
| 18 | + dir = dir.Parent; | |
| 19 | + } | |
| 20 | + Assert.NotNull(dir); | |
| 21 | + return dir!.FullName; | |
| 22 | + } | |
| 23 | + | |
| 24 | + [Fact] | |
| 25 | + public void NoModuleReferencesAnotherModuleProject() | |
| 26 | + { | |
| 27 | + var modulesDir = Path.Combine(SolutionRoot(), "src", "Modules"); | |
| 28 | + Assert.True(Directory.Exists(modulesDir), $"Modules dir not found at {modulesDir}"); | |
| 29 | + | |
| 30 | + var moduleNames = Directory.GetDirectories(modulesDir).Select(Path.GetFileName).ToList(); | |
| 31 | + Assert.Equal(3, moduleNames.Count); | |
| 32 | + | |
| 33 | + var csprojFiles = Directory.GetFiles(modulesDir, "*.csproj", SearchOption.AllDirectories); | |
| 34 | + var failures = new List<string>(); | |
| 35 | + | |
| 36 | + foreach (var csproj in csprojFiles) | |
| 37 | + { | |
| 38 | + var owningModule = moduleNames.First(m => csproj.Contains($"Modules{Path.DirectorySeparatorChar}{m}{Path.DirectorySeparatorChar}")); | |
| 39 | + var doc = XDocument.Load(csproj); | |
| 40 | + var refs = doc.Descendants("ProjectReference") | |
| 41 | + .Select(r => (string?)r.Attribute("Include")) | |
| 42 | + .Where(r => r != null) | |
| 43 | + .ToList(); | |
| 44 | + | |
| 45 | + foreach (var refPath in refs) | |
| 46 | + { | |
| 47 | + foreach (var otherModule in moduleNames.Where(m => m != owningModule)) | |
| 48 | + { | |
| 49 | + if (refPath!.Contains($"Modules{Path.DirectorySeparatorChar}{otherModule}{Path.DirectorySeparatorChar}") | |
| 50 | + || refPath.Contains($"Modules\\{otherModule}\\") | |
| 51 | + || refPath.Contains($"Modules/{otherModule}/")) | |
| 52 | + { | |
| 53 | + failures.Add($"{Path.GetFileName(csproj)} (module {owningModule}) references {refPath} (module {otherModule})"); | |
| 54 | + } | |
| 55 | + } | |
| 56 | + } | |
| 57 | + } | |
| 58 | + | |
| 59 | + Assert.Empty(failures); | |
| 60 | + } | |
| 61 | + | |
| 62 | + [Fact] | |
| 63 | + public void SharedProjectsDoNotReferenceModules() | |
| 64 | + { | |
| 65 | + var sharedDir = Path.Combine(SolutionRoot(), "src", "Shared"); | |
| 66 | + var csprojFiles = Directory.GetFiles(sharedDir, "*.csproj", SearchOption.AllDirectories); | |
| 67 | + | |
| 68 | + var failures = new List<string>(); | |
| 69 | + foreach (var csproj in csprojFiles) | |
| 70 | + { | |
| 71 | + var doc = XDocument.Load(csproj); | |
| 72 | + var refs = doc.Descendants("ProjectReference") | |
| 73 | + .Select(r => (string?)r.Attribute("Include")) | |
| 74 | + .Where(r => r != null); | |
| 75 | + | |
| 76 | + foreach (var refPath in refs) | |
| 77 | + { | |
| 78 | + if (refPath!.Contains("Modules")) | |
| 79 | + { | |
| 80 | + failures.Add($"{Path.GetFileName(csproj)} references {refPath}"); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + Assert.Empty(failures); | |
| 86 | + } | |
| 87 | +} |
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostBootSmokeTests.cs +59 −0
| @@ -0,0 +1,59 @@ | ||
| 1 | +using System.Net; | |
| 2 | +using Microsoft.AspNetCore.Hosting; | |
| 3 | +using Microsoft.AspNetCore.Mvc.Testing; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.IntegrationTests; | |
| 6 | + | |
| 7 | +/// <summary> | |
| 8 | +/// Boots the full WebApp host in the "Testing" environment (which skips the | |
| 9 | +/// per-module Migrate calls) and verifies the public landing endpoints serve. | |
| 10 | +/// Proves DI composition across all three modules works end-to-end. | |
| 11 | +/// </summary> | |
| 12 | +public class HostBootSmokeTests : IClassFixture<WebApplicationFactory<Program>> | |
| 13 | +{ | |
| 14 | + private readonly WebApplicationFactory<Program> _factory; | |
| 15 | + | |
| 16 | + public HostBootSmokeTests(WebApplicationFactory<Program> factory) | |
| 17 | + { | |
| 18 | + _factory = factory.WithWebHostBuilder(b => | |
| 19 | + { | |
| 20 | + b.UseEnvironment("Testing"); | |
| 21 | + b.UseSetting("ConnectionStrings:DefaultConnection", "Host=ignored;Database=ignored"); | |
| 22 | + b.UseSetting("JWT:Issuer", "splitapp-test"); | |
| 23 | + b.UseSetting("JWT:Audience", "splitapp-test"); | |
| 24 | + b.UseSetting("JWT:Key", "this-is-a-long-enough-test-signing-key-for-hs256"); | |
| 25 | + }); | |
| 26 | + } | |
| 27 | + | |
| 28 | + [Fact] | |
| 29 | + public async Task Get_Root_ReturnsOk() | |
| 30 | + { | |
| 31 | + var client = _factory.CreateClient(); | |
| 32 | + var response = await client.GetAsync("/"); | |
| 33 | + Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |
| 34 | + var body = await response.Content.ReadAsStringAsync(); | |
| 35 | + Assert.Contains("SplitApp", body); | |
| 36 | + } | |
| 37 | + | |
| 38 | + [Fact] | |
| 39 | + public async Task Get_HomeIndex_RendersLandingPage() | |
| 40 | + { | |
| 41 | + var client = _factory.CreateClient(); | |
| 42 | + var response = await client.GetAsync("/Home/Index"); | |
| 43 | + Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |
| 44 | + var body = await response.Content.ReadAsStringAsync(); | |
| 45 | + // Phase 2 home view content | |
| 46 | + Assert.Contains("SplitApp", body); | |
| 47 | + } | |
| 48 | + | |
| 49 | + [Fact] | |
| 50 | + public async Task Get_TripsApi_RequiresAuth() | |
| 51 | + { | |
| 52 | + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions | |
| 53 | + { | |
| 54 | + AllowAutoRedirect = false, | |
| 55 | + }); | |
| 56 | + var response = await client.GetAsync("/api/v1/trips"); | |
| 57 | + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); | |
| 58 | + } | |
| 59 | +} |
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/HostFeatureTests.cs +85 −0
| @@ -0,0 +1,85 @@ | ||
| 1 | +using System.Net; | |
| 2 | +using Microsoft.AspNetCore.Hosting; | |
| 3 | +using Microsoft.AspNetCore.Mvc.Testing; | |
| 4 | + | |
| 5 | +namespace SplitApp.WebApp.IntegrationTests; | |
| 6 | + | |
| 7 | +/// <summary> | |
| 8 | +/// End-to-end host checks that exercise the cross-cutting requirements: | |
| 9 | +/// Swagger generation, Admin area protection, JWT requirement on a second | |
| 10 | +/// module's controller, and request-localization wiring. | |
| 11 | +/// </summary> | |
| 12 | +public class HostFeatureTests : IClassFixture<WebApplicationFactory<Program>> | |
| 13 | +{ | |
| 14 | + private readonly WebApplicationFactory<Program> _factory; | |
| 15 | + | |
| 16 | + public HostFeatureTests(WebApplicationFactory<Program> factory) | |
| 17 | + { | |
| 18 | + _factory = factory.WithWebHostBuilder(b => | |
| 19 | + { | |
| 20 | + b.UseEnvironment("Testing"); | |
| 21 | + b.UseSetting("ConnectionStrings:DefaultConnection", "Host=ignored;Database=ignored"); | |
| 22 | + b.UseSetting("JWT:Issuer", "splitapp-test"); | |
| 23 | + b.UseSetting("JWT:Audience", "splitapp-test"); | |
| 24 | + b.UseSetting("JWT:Key", "this-is-a-long-enough-test-signing-key-for-hs256"); | |
| 25 | + }); | |
| 26 | + } | |
| 27 | + | |
| 28 | + [Fact] | |
| 29 | + public async Task Get_SwaggerV1Json_ReturnsOpenApiDocument() | |
| 30 | + { | |
| 31 | + var client = _factory.CreateClient(); | |
| 32 | + var response = await client.GetAsync("/swagger/v1/swagger.json"); | |
| 33 | + Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |
| 34 | + | |
| 35 | + var body = await response.Content.ReadAsStringAsync(); | |
| 36 | + Assert.Contains("\"openapi\"", body); | |
| 37 | + Assert.Contains("/api/v1/Trips", body); | |
| 38 | + } | |
| 39 | + | |
| 40 | + [Fact] | |
| 41 | + public async Task Get_SwaggerUi_ReturnsOk() | |
| 42 | + { | |
| 43 | + var client = _factory.CreateClient(); | |
| 44 | + var response = await client.GetAsync("/swagger/index.html"); | |
| 45 | + Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |
| 46 | + } | |
| 47 | + | |
| 48 | + [Fact] | |
| 49 | + public async Task Get_AdminArea_RedirectsUnauthenticatedUserToLogin() | |
| 50 | + { | |
| 51 | + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions | |
| 52 | + { | |
| 53 | + AllowAutoRedirect = false, | |
| 54 | + }); | |
| 55 | + var response = await client.GetAsync("/Admin/Trips"); | |
| 56 | + | |
| 57 | + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); | |
| 58 | + Assert.NotNull(response.Headers.Location); | |
| 59 | + Assert.Contains("Login", response.Headers.Location!.OriginalString, StringComparison.OrdinalIgnoreCase); | |
| 60 | + } | |
| 61 | + | |
| 62 | + [Fact] | |
| 63 | + public async Task Get_ExpensesApi_RequiresAuth() | |
| 64 | + { | |
| 65 | + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions | |
| 66 | + { | |
| 67 | + AllowAutoRedirect = false, | |
| 68 | + }); | |
| 69 | + var response = await client.GetAsync($"/api/v1/expenses/trip/{Guid.NewGuid()}"); | |
| 70 | + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); | |
| 71 | + } | |
| 72 | + | |
| 73 | + [Fact] | |
| 74 | + public async Task Get_Home_WithEtCultureQuery_ReturnsOk() | |
| 75 | + { | |
| 76 | + var client = _factory.CreateClient(); | |
| 77 | + var response = await client.GetAsync("/?culture=et&ui-culture=et"); | |
| 78 | + Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |
| 79 | + | |
| 80 | + // RequestLocalization should accept "et" as a supported culture | |
| 81 | + // without throwing or falling back to a 4xx/5xx. | |
| 82 | + var body = await response.Content.ReadAsStringAsync(); | |
| 83 | + Assert.Contains("SplitApp", body); | |
| 84 | + } | |
| 85 | +} |
added SplitApp.Modular/tests/SplitApp.WebApp.IntegrationTests/SplitApp.WebApp.IntegrationTests.csproj +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <PropertyGroup> | |
| 4 | + <TargetFramework>net10.0</TargetFramework> | |
| 5 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 6 | + <Nullable>enable</Nullable> | |
| 7 | + <IsPackable>false</IsPackable> | |
| 8 | + </PropertyGroup> | |
| 9 | + | |
| 10 | + <ItemGroup> | |
| 11 | + <PackageReference Include="coverlet.collector" Version="6.0.4" /> | |
| 12 | + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | |
| 13 | + <PackageReference Include="xunit" Version="2.9.3" /> | |
| 14 | + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> | |
| 15 | + </ItemGroup> | |
| 16 | + | |
| 17 | + <ItemGroup> | |
| 18 | + <Using Include="Xunit" /> | |
| 19 | + </ItemGroup> | |
| 20 | + | |
| 21 | + <ItemGroup> | |
| 22 | + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" /> | |
| 23 | + </ItemGroup> | |
| 24 | + | |
| 25 | + <ItemGroup> | |
| 26 | + <ProjectReference Include="..\..\src\SplitApp.WebApp\SplitApp.WebApp.csproj" /> | |
| 27 | + </ItemGroup> | |
| 28 | + | |
| 29 | +</Project> | |
| No newline at end of file |
added architecture.md +221 −0
| @@ -0,0 +1,221 @@ | ||
| 1 | +# Architecture — SplitApp Phase 3 (Modular Monolith) | |
| 2 | + | |
| 3 | +This document is the architectural deep-dive for the Phase 3 refactor. The whole product lives under [`SplitApp.Modular/`](SplitApp.Modular/): one deployable, three internally isolated modules (**Users**, **Trips**, **Expenses**), MediatR for cross-module communication, schema-per-module Postgres isolation. | |
| 4 | + | |
| 5 | +For the original course context, see also [`modularmonolith.md`](modularmonolith.md) and [`phase3.md`](phase3.md). The module-level README at [`SplitApp.Modular/README.md`](SplitApp.Modular/README.md) and the dedicated [`SplitApp.Modular/docs/ARCHITECTURE.md`](SplitApp.Modular/docs/ARCHITECTURE.md) are the canonical references; this file is the high-level overview. | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## 1. The picture | |
| 10 | + | |
| 11 | +``` | |
| 12 | + ┌────────────────────────────────────────────────┐ | |
| 13 | + │ SplitApp.WebApp (host) │ | |
| 14 | + │ Program.cs · Controllers · Areas/Admin │ | |
| 15 | + │ Application/{Services, DTO, Mappers, │ | |
| 16 | + │ Persistence} │ | |
| 17 | + └────────────────────────────────────────────────┘ | |
| 18 | + │ │ │ | |
| 19 | + ▼ ▼ ▼ | |
| 20 | + ┌──────────┐ ┌──────────┐ ┌──────────┐ | |
| 21 | + │ Users │ │ Trips │ │ Expenses │ | |
| 22 | + │ Domain │ │ Domain │ │ Domain │ | |
| 23 | + │ App │ │ App │ │ App │ | |
| 24 | + │ Infra │ │ Infra │ │ Infra │ | |
| 25 | + │ Api │ │ Api │ │ Api │ | |
| 26 | + │ schema: │ │ schema: │ │ schema: │ | |
| 27 | + │ users │ │ trips │ │ expenses │ | |
| 28 | + └──────────┘ └──────────┘ └──────────┘ | |
| 29 | + ▲ ▲ ▲ | |
| 30 | + └─MediatR───┴─MediatR───┘ | |
| 31 | + ┌──────────────────────┐ ┌──────────────────────┐ | |
| 32 | + │ Shared.Contracts │ │ Shared.Kernel │ | |
| 33 | + │ IRequest/INotification│ │ BaseEntity, LangStr │ | |
| 34 | + └──────────────────────┘ └──────────────────────┘ | |
| 35 | +``` | |
| 36 | + | |
| 37 | +Each module = mini-Clean-Architecture (`Domain` ← `Application` ← `Infrastructure`, `Api` for REST). Each module owns its own `DbContext` scoped to its own Postgres schema. Cross-module function calls go through MediatR only — no direct `<ProjectReference>` between modules' `Application` / `Infrastructure` / `Api` layers. | |
| 38 | + | |
| 39 | +--- | |
| 40 | + | |
| 41 | +## 2. Module layout | |
| 42 | + | |
| 43 | +``` | |
| 44 | +SplitApp.Modular/ | |
| 45 | +├── SplitApp.sln | |
| 46 | +├── Directory.Build.props | |
| 47 | +├── src/ | |
| 48 | +│ ├── SplitApp.WebApp/ ← composition root, host, admin Area | |
| 49 | +│ │ ├── Program.cs ← AddXxxModule(...) wiring | |
| 50 | +│ │ ├── Application/ ← lifted phase-2 BLL | |
| 51 | +│ │ │ ├── Services/ (+ Admin/, Identity/) | |
| 52 | +│ │ │ ├── DTO/ ← BllDtos | |
| 53 | +│ │ │ ├── Mappers/ ← Domain ↔ BllDto factory mappers | |
| 54 | +│ │ │ ├── Persistence/AppUnitOfWork.cs ← aggregates 3 module DbContexts | |
| 55 | +│ │ │ └── Persistence/CrossModuleNavigationLoader.cs | |
| 56 | +│ │ ├── Areas/Admin/, Areas/Identity/, Controllers/, Views/, Resources/ | |
| 57 | +│ ├── Shared/ | |
| 58 | +│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers | |
| 59 | +│ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification | |
| 60 | +│ └── Modules/ | |
| 61 | +│ ├── Users/ | |
| 62 | +│ │ ├── SplitApp.Modules.Users.Domain/ ← AppUser, AppRole, AppRefreshToken | |
| 63 | +│ │ ├── SplitApp.Modules.Users.Application/ ← IIdentityService + JWT/refresh, MediatR handlers | |
| 64 | +│ │ ├── SplitApp.Modules.Users.Infrastructure/ ← UsersDbContext (schema "users"), repos, AddUsersModule | |
| 65 | +│ │ └── SplitApp.Modules.Users.Api/ ← /api/v1/identity/... | |
| 66 | +│ ├── Trips/ ← same 4-project layout, schema "trips" | |
| 67 | +│ └── Expenses/ ← same 4-project layout, schema "expenses" | |
| 68 | +└── tests/ | |
| 69 | + ├── SplitApp.Modules.{Users,Trips,Expenses}.Tests/ ← per-module unit tests | |
| 70 | + └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke | |
| 71 | +``` | |
| 72 | + | |
| 73 | +--- | |
| 74 | + | |
| 75 | +## 3. Reference rules (compiler-enforced + arch-test verified) | |
| 76 | + | |
| 77 | +| Source | Allowed targets | Notes | | |
| 78 | +|--------|----------------|-------| | |
| 79 | +| `Modules/X/Application` | same-module `Domain` + `Shared.Kernel` + `Shared.Contracts` | | | |
| 80 | +| `Modules/X/Infrastructure` | same-module `Domain` + `Application` + `Shared.Kernel` | | | |
| 81 | +| `Modules/X/Api` | same-module `Application` + `Shared.Kernel` + `Shared.Contracts` | | | |
| 82 | +| `Shared.*` | none of the modules | | | |
| 83 | +| `WebApp` | all 3 modules' `Api` + `Infrastructure` + `Shared.*` | composition root | | |
| 84 | + | |
| 85 | +**Domain-level caveat:** to keep view-rendering parity from phase 2 (mappers reading `Trip.DefaultCurrency.Code`, `TripParticipant.User.FirstName`, etc.), entity classes still declare cross-module navigation properties — annotated `[NotMapped]` so EF never crosses Postgres schemas. To make those property *types* compile, three Domain-to-Domain `<ProjectReference>`s exist: | |
| 86 | + | |
| 87 | +``` | |
| 88 | +Modules/Trips/SplitApp.Modules.Trips.Domain | |
| 89 | + → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs) | |
| 90 | + → Modules/Expenses/SplitApp.Modules.Expenses.Domain (for Currency refs) | |
| 91 | +Modules/Expenses/SplitApp.Modules.Expenses.Domain | |
| 92 | + → Modules/Users/SplitApp.Modules.Users.Domain (for AppUser refs) | |
| 93 | +``` | |
| 94 | + | |
| 95 | +This bends the strict "no direct references between modules" rule from `phase3.md` at the **Domain** level. **`Application`, `Infrastructure`, `Api` projects remain isolated** and use MediatR for actual function calls — only entity *types* are shared. Schema isolation, MediatR-only inter-module function calls, and per-module DbContext ownership are all preserved at runtime. | |
| 96 | + | |
| 97 | +`CrossModuleNavigationTests` enforces the `[NotMapped]` rule: a *mapped* navigation across modules (where EF would actually try to traverse) fails the build. | |
| 98 | + | |
| 99 | +--- | |
| 100 | + | |
| 101 | +## 4. Inter-module communication (MediatR) | |
| 102 | + | |
| 103 | +Cross-module calls go through MediatR. Contracts live in `SplitApp.Shared.Contracts/<Module>/{Queries|Events|Commands}/` 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. | |
| 104 | + | |
| 105 | +| Contract | Owner | Notes | | |
| 106 | +|----------|-------|-------| | |
| 107 | +| `GetUserByIdQuery : IRequest<UserDto?>` | Users | Used by Trips/Expenses for display-name lookup | | |
| 108 | +| `GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>>` | Users | Batch lookup | | |
| 109 | +| `UserDeletedEvent : INotification` | Users | Trips + Expenses subscribe to clean up rows | | |
| 110 | +| `GetTripByIdQuery : IRequest<TripSummaryDto?>` | Trips | Cross-module trip lookup | | |
| 111 | +| `GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>>` | Trips | | | |
| 112 | +| `IsTripParticipantQuery : IRequest<bool>` | Trips | IDOR guard in `ExpensesController` | | |
| 113 | +| `TripDeletedEvent : INotification` | Trips | Expenses subscribes to delete dependent expenses/settlements | | |
| 114 | +| `GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto>` | Expenses | Per-currency totals for a trip | | |
| 115 | +| `GetBudgetCategorySpentQuery : IRequest<IReadOnlyDictionary<Guid, decimal>>` | Expenses | Per-budget-category spent totals | | |
| 116 | +| `ExpenseSettledEvent : INotification` | Expenses | Reserved for future use | | |
| 117 | +| `SettlementPlanCompletedEvent : INotification` | Expenses | Trips subscribes to advance "Finalizing" trips to "Settled" once every payment is confirmed | | |
| 118 | + | |
| 119 | +--- | |
| 120 | + | |
| 121 | +## 5. Schema isolation | |
| 122 | + | |
| 123 | +Each module owns its own `DbContext`: | |
| 124 | + | |
| 125 | +- `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` → schema `users` | |
| 126 | +- `TripsDbContext : DbContext` → schema `trips` | |
| 127 | +- `ExpensesDbContext : DbContext` → schema `expenses` | |
| 128 | + | |
| 129 | +All three connect to the same physical Postgres database via the same `ConnectionStrings:DefaultConnection`. **Cross-module SQL joins are forbidden** — composition happens at the application layer through MediatR or the WebApp facade's `CrossModuleNavigationLoader`. | |
| 130 | + | |
| 131 | +Cross-module entity references are bare `Guid` fields with **no** EF foreign-key constraints (e.g. `Trip.CreatedById : Guid` references `users.AspNetUsers.Id` only conceptually). Referential integrity is maintained by: | |
| 132 | + | |
| 133 | +- Up-front MediatR validation queries (e.g. `IsTripParticipantQuery` before persisting an expense split) | |
| 134 | +- Domain-event cleanup on delete (`UserDeletedEvent`, `TripDeletedEvent`) | |
| 135 | + | |
| 136 | +--- | |
| 137 | + | |
| 138 | +## 6. Composition root and lifted phase 2 BLL | |
| 139 | + | |
| 140 | +`SplitApp.WebApp` is the only project that sees all three modules. It hosts the full phase 2 UI surface (101 Razor views, 21 MVC + Admin controllers, 10 REST API controllers, Identity Razor Register page) and lifts the entire phase 2 BLL layer into [`SplitApp.WebApp/Application/`](SplitApp.Modular/src/SplitApp.WebApp/Application/) under three buckets: | |
| 141 | + | |
| 142 | +| Bucket | Contents | | |
| 143 | +|---|---| | |
| 144 | +| `Application/DTO` | Phase-2 BLL DTOs (`TripBllDto`, `ExpenseBllDto`, …) — POCOs that views model-bind to | | |
| 145 | +| `Application/Mappers` | Entity ↔ BLL DTO factory mappers | | |
| 146 | +| `Application/Services` (+ `Services/Admin`, `Services/Identity`) | Phase-2 BLL services lifted unchanged; consume `IAppUnitOfWork` | | |
| 147 | +| `Application/Persistence/AppUnitOfWork.cs` | Composition-root facade aggregating `UsersDbContext`, `TripsDbContext`, `ExpensesDbContext` behind phase-2's `IAppUnitOfWork` interface — each repository routes to the appropriate module's DbContext | | |
| 148 | +| `Application/Persistence/CrossModuleNavigationLoader.cs` | Hydrates `[NotMapped]` cross-module nav properties (`Trip.DefaultCurrency`, `TripParticipant.User`, `Expense.PaidByUser`, …) after entity load by querying the owning module's DbContext separately. EF never crosses schemas. | | |
| 149 | + | |
| 150 | +The lifted BLL services keep working unchanged: `_uow.Trips.GetByIdAsync(...)` behaves like phase 2. In reality `AppUnitOfWork.Trips` routes the query to `TripsDbContext`, and `CrossModuleHydration` fills cross-module navs (`Trip.CreatedBy`, `Trip.DefaultCurrency`) afterwards via separate single-shot batched queries against the foreign module's DbContext. | |
| 151 | + | |
| 152 | +--- | |
| 153 | + | |
| 154 | +## 7. Phase 3 → phase 2 mapping (for context) | |
| 155 | + | |
| 156 | +| Phase 2 project | Phase 3 destination | | |
| 157 | +|---|---| | |
| 158 | +| `Base.Domain` (`BaseEntity`, `LangStr`) | `SplitApp.Shared.Kernel` | | |
| 159 | +| `Base.Contracts` (`IBaseEntity`, `IBaseRepository`, `IUnitOfWork`) | `SplitApp.Shared.Kernel` | | |
| 160 | +| `Base.Helpers` (`IdentityHelpers`) | `SplitApp.Shared.Kernel.Auth` | | |
| 161 | +| `App.Domain.Identity.*` | `SplitApp.Modules.Users.Domain.Entities` | | |
| 162 | +| `App.Domain.{Trip, TripParticipant, …}` | `SplitApp.Modules.Trips.Domain.Entities` | | |
| 163 | +| `App.Domain.{Expense, SettlementPlan, …, Currency}` | `SplitApp.Modules.Expenses.Domain.Entities` | | |
| 164 | +| `App.DAL.EF.AppDbContext` | Split into 3 `XxxDbContext` per module | | |
| 165 | +| `App.BLL.Services.Identity.*` | `SplitApp.Modules.Users.Application.Services` | | |
| 166 | +| `App.BLL.Services.*` (Trip, Expense, Settlement, …) | Lifted into `SplitApp.WebApp/Application/Services` (composition-root facade over the 3 module UoWs) | | |
| 167 | +| `App.BLL.DTO.*`, `App.BLL.Mappers.*` | Lifted into `SplitApp.WebApp/Application/{DTO, Mappers}` | | |
| 168 | +| `WebApp.ApiControllers.Identity.*` | `SplitApp.Modules.Users.Api.Controllers` | | |
| 169 | +| `WebApp.ApiControllers.{TripsController, …}` | `SplitApp.Modules.Trips.Api.Controllers` | | |
| 170 | +| `WebApp.ApiControllers.{ExpensesController, CurrenciesController, SettlementsController, SplitPresetsController}` | `SplitApp.Modules.Expenses.Api.Controllers` | | |
| 171 | +| `WebApp/Controllers/*` (MVC client) + `WebApp/Areas/Admin/*` + `WebApp/Areas/Identity/*` + `WebApp/Views/*` | `SplitApp.Modular/src/SplitApp.WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` (preserved structurally; namespace re-rooted to `SplitApp.WebApp.*`) | | |
| 172 | + | |
| 173 | +--- | |
| 174 | + | |
| 175 | +## 8. Architecture tests (run on `dotnet test`) | |
| 176 | + | |
| 177 | +`tests/SplitApp.WebApp.IntegrationTests/Architecture/`: | |
| 178 | + | |
| 179 | +1. **`ModuleBoundaryTests`** — no module's `Application`/`Infrastructure`/`Api` project has a `<ProjectReference>` to another module's project; no `Shared.*` project references a module. | |
| 180 | +2. **`DbContextSchemaIsolationTests`** — each `DbContext` only exposes `DbSet<T>` for entities that live in its own `Domain` project (plus a tiny framework allowlist). | |
| 181 | +3. **`CrossModuleNavigationTests`** — cross-module navigation properties are allowed only when annotated `[NotMapped]`. A plain mapped nav across modules (which would let EF cross schemas) fails the build. | |
| 182 | +4. **`HostBootSmokeTests`** — `WebApplicationFactory<Program>` boots the full host in `Testing` env (skipping per-module migrations) and verifies `/`, `/Home/Index`, and a 401 on unauthenticated API hits. | |
| 183 | + | |
| 184 | +A failing architecture test means a developer just violated the modular-monolith invariant. | |
| 185 | + | |
| 186 | +**Total test count: 25** — Expenses (5) + Trips (7) + Users (4) + IntegrationTests (9). All passing on the current build. | |
| 187 | + | |
| 188 | +--- | |
| 189 | + | |
| 190 | +## 9. Deployment | |
| 191 | + | |
| 192 | +**Production deployment:** https://travel.rasmusj.com/ | |
| 193 | + | |
| 194 | +The repo's root `Dockerfile` + `docker-compose.yml` build and run phase 3 locally: | |
| 195 | + | |
| 196 | +```bash | |
| 197 | +docker compose up --build | |
| 198 | +``` | |
| 199 | + | |
| 200 | +| Service | Container | Port | Notes | | |
| 201 | +|---|---|---|---| | |
| 202 | +| `phase3` | `phase3` | http://localhost:90 | Web app (host port `90` → container port `8080`) | | |
| 203 | +| `db` | `phase3-db` | (internal only) | PostgreSQL 16, schemas `users` / `trips` / `expenses` — not exposed to host | | |
| 204 | + | |
| 205 | +Per-module migrations run automatically on host startup. Sample data is seeded if `DataInitialization:SeedData=true`. | |
| 206 | + | |
| 207 | +For per-module migration commands (`dotnet ef migrations add ...`) and the full URL map, see [`SplitApp.Modular/docs/ARCHITECTURE.md`](SplitApp.Modular/docs/ARCHITECTURE.md) and [`SplitApp.Modular/README.md`](SplitApp.Modular/README.md). | |
| 208 | + | |
| 209 | +--- | |
| 210 | + | |
| 211 | +## 10. Why a modular monolith? | |
| 212 | + | |
| 213 | +| Approach | Problem | | |
| 214 | +|---|---| | |
| 215 | +| Classic monolith | Everything references everything — one change cascades | | |
| 216 | +| Microservices | Distributed-systems pain — network, serialization, eventual consistency, deployment complexity | | |
| 217 | +| **Modular monolith** | **Microservice-style boundaries + monolith deployment simplicity** | | |
| 218 | + | |
| 219 | +Future extraction path, if needed: *Classic monolith → Modular monolith → Microservices*. Each module already has its own schema, its own contracts, its own `DbContext`. Extracting a module into a separate service later means replacing in-process MediatR calls with HTTP/gRPC and domain events with a message broker — the code structure barely changes, only the transport layer. | |
| 220 | + | |
| 221 | +See [`modularmonolith.md`](modularmonolith.md) for the course material on the pattern. |
added arhitektuur.md +230 −0
| @@ -0,0 +1,230 @@ | ||
| 1 | +# SplitApp — Modulaarne Monoliit (Phase 3) | |
| 2 | + | |
| 3 | +## 1. Üldine pilt | |
| 4 | + | |
| 5 | +``` | |
| 6 | + ┌────────────────────────────────────────────────────┐ | |
| 7 | + │ SplitApp.WebApp (Composition Root) │ | |
| 8 | + │ Program.cs · MVC Controllers · Areas/Admin │ | |
| 9 | + │ Areas/Identity · Views · ViewModels │ | |
| 10 | + │ Application/{Services, DTO, Mappers, Persistence}│ | |
| 11 | + │ (lifted phase-2 BLL — kasutab IAppUnitOfWork) │ | |
| 12 | + └────────────────────────────────────────────────────┘ | |
| 13 | + │ │ │ | |
| 14 | + ▼ ▼ ▼ | |
| 15 | + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ | |
| 16 | + │ Users │ │ Trips │ │ Expenses │ | |
| 17 | + │ │ │ │ │ │ | |
| 18 | + │ Domain │ │ Domain │ │ Domain │ | |
| 19 | + │ Application│ │ Application│ │ Application│ | |
| 20 | + │ Infra │ │ Infra │ │ Infra │ | |
| 21 | + │ Api │ │ Api │ │ Api │ | |
| 22 | + │ │ │ │ │ │ | |
| 23 | + │ schema: │ │ schema: │ │ schema: │ | |
| 24 | + │ users │ │ trips │ │ expenses │ | |
| 25 | + └─────────────┘ └─────────────┘ └─────────────┘ | |
| 26 | + │ ▲ │ ▲ │ ▲ | |
| 27 | + │ │ MediatR │ │ MediatR │ │ MediatR | |
| 28 | + └──┴─────────────┴──┴─────────────┴──┘ | |
| 29 | + ┌─────────────────────────┐ | |
| 30 | + │ Shared.Contracts │ | |
| 31 | + │ IRequest / INotification│ | |
| 32 | + │ (Queries + Events) │ | |
| 33 | + └─────────────────────────┘ | |
| 34 | + ┌─────────────────────────┐ | |
| 35 | + │ Shared.Kernel │ | |
| 36 | + │ BaseEntity · LangStr │ | |
| 37 | + │ IdentityHelpers (JWT) │ | |
| 38 | + └─────────────────────────┘ | |
| 39 | +``` | |
| 40 | + | |
| 41 | +**Põhimõte:** üks deployment, kolm sisemiselt isoleeritud moodulit. Iga moodul omab oma domeeni, andmeid (Postgres schema) ja teenuseid. Moodulid suhtlevad **ainult MediatR-i kaudu** — mitte ühtegi otseviidet teise mooduli sisemusele. | |
| 42 | + | |
| 43 | +--- | |
| 44 | + | |
| 45 | +## 2. Lahenduse struktuur (`SplitApp.Modular/`) | |
| 46 | + | |
| 47 | +``` | |
| 48 | +SplitApp.sln | |
| 49 | +├── src/ | |
| 50 | +│ ├── SplitApp.WebApp/ ← composition root, host, MVC + admin | |
| 51 | +│ │ ├── Program.cs ← DI wiring, AddXxxModule(...) | |
| 52 | +│ │ ├── Application/ ← Phase 2 BLL liigutatud siia | |
| 53 | +│ │ │ ├── Services/ (+ Admin/, Identity/) ← TripService, ExpenseService, ... | |
| 54 | +│ │ │ ├── DTO/ ← TripBllDto, ExpenseBllDto, ... | |
| 55 | +│ │ │ ├── Mappers/ ← Domain↔BllDto factory mapperid | |
| 56 | +│ │ │ └── Persistence/ ← AppUnitOfWork (3 mooduli DbContexti agregaator) | |
| 57 | +│ │ ├── Areas/Admin/ ← admin UX, ViewModels, eraldi layout | |
| 58 | +│ │ ├── Areas/Identity/ ← Razor Pages (Register) | |
| 59 | +│ │ ├── Controllers/ ← klient-MVC kontrollerid | |
| 60 | +│ │ └── Views/ ← klient-vaated | |
| 61 | +│ ├── Shared/ | |
| 62 | +│ │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepository, IUnitOfWork, LangStr, IdentityHelpers | |
| 63 | +│ │ └── SplitApp.Shared.Contracts/ ← MediatR IRequest / INotification | |
| 64 | +│ └── Modules/ | |
| 65 | +│ ├── Users/ | |
| 66 | +│ │ ├── SplitApp.Modules.Users.Domain/ ← AppUser, AppRole, AppRefreshToken | |
| 67 | +│ │ ├── SplitApp.Modules.Users.Application/ ← IIdentityService, MediatR handlerid | |
| 68 | +│ │ ├── SplitApp.Modules.Users.Infrastructure/ ← UsersDbContext, repod, AddUsersModule | |
| 69 | +│ │ └── SplitApp.Modules.Users.Api/ ← /api/v1/identity/... | |
| 70 | +│ ├── Trips/ ← sama 4-projekti struktuur, schema "trips" | |
| 71 | +│ └── Expenses/ ← sama 4-projekti struktuur, schema "expenses" | |
| 72 | +└── tests/ | |
| 73 | + ├── SplitApp.Modules.Users.Tests/ | |
| 74 | + ├── SplitApp.Modules.Trips.Tests/ | |
| 75 | + ├── SplitApp.Modules.Expenses.Tests/ | |
| 76 | + └── SplitApp.WebApp.IntegrationTests/ ← architecture invariants + smoke | |
| 77 | +``` | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## 3. Viidete reeglid (compiler-enforced + arch-test verified) | |
| 82 | + | |
| 83 | +| Allikas | Lubatud sihtmärgid | Märkused | | |
| 84 | +|---------|-------------------|----------| | |
| 85 | +| `Modules/X/<Layer>` (Application/Infrastructure/Api) | sama mooduli teised projektid + `Shared.Kernel` + `Shared.Contracts` | **Mitte teise mooduli projektidele** | | |
| 86 | +| `Shared.*` | mitte ühelegi moodulile | | | |
| 87 | +| `WebApp` | kõik kolm moodulit (Api + Infrastructure) + Shared | composition root | | |
| 88 | + | |
| 89 | +**Erand Domain tasandil:** Phase 2 vaate-renderdamise paarsuse hoidmiseks (`Trip.CreatedBy.Email`, `Expense.PaidByUser.FirstName` jne) on entiteedid säilitanud cross-module nav-property'd, kuid annotatsiooniga `[NotMapped]`, et EF kunagi ei ületaks Postgres schema piiri. Vaata [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md) `[NotMapped]` lõiku. | |
| 90 | + | |
| 91 | +Architecture-testid jälgivad neid invariante (`tests/SplitApp.WebApp.IntegrationTests/Architecture/`): | |
| 92 | +- `ModuleBoundaryTests` — ükski mooduli `Application`/`Infrastructure`/`Api` ei viita teisele moodulile | |
| 93 | +- `DbContextSchemaIsolationTests` — iga DbContext sisaldab DbSet-e ainult oma mooduli entiteetidele | |
| 94 | +- `CrossModuleNavigationTests` — cross-module nav-property on lubatud ainult `[NotMapped]`-iga | |
| 95 | +- `HostBootSmokeTests` — `WebApplicationFactory<Program>` käivitab täis-host'i | |
| 96 | + | |
| 97 | +--- | |
| 98 | + | |
| 99 | +## 4. Moodulite-vahene suhtlus (MediatR) | |
| 100 | + | |
| 101 | +Kõik kõned üle mooduli piiri lähevad läbi MediatR. Lepingud (`IRequest<T>` või `INotification`) elavad `Shared.Contracts/<Moodul>/{Queries|Events|Commands}/`. Handlerid omanik-mooduli `Application` või `Infrastructure` kihis. | |
| 102 | + | |
| 103 | +Saadetakse hetkel: | |
| 104 | + | |
| 105 | +| Leping | Omanik | Otstarve | | |
| 106 | +|--------|--------|----------| | |
| 107 | +| `GetUserByIdQuery : IRequest<UserDto?>` | Users | Trips/Expenses kasutavad nime kuvamiseks | | |
| 108 | +| `GetUsersByIdsQuery : IRequest<IReadOnlyList<UserDto>>` | Users | Partii-päring | | |
| 109 | +| `UserDeletedEvent : INotification` | Users | Trips + Expenses tellivad — eemaldavad seotud kirjed | | |
| 110 | +| `GetTripByIdQuery : IRequest<TripSummaryDto?>` | Trips | Cross-module reisi-otsing | | |
| 111 | +| `GetTripParticipantsQuery : IRequest<IReadOnlyList<TripParticipantDto>>` | Trips | | | |
| 112 | +| `IsTripParticipantQuery : IRequest<bool>` | Trips | IDOR-i kaitse Expenses controller-is | | |
| 113 | +| `TripDeletedEvent : INotification` | Trips | Expenses tellib — kustutab kulud + arveldused | | |
| 114 | +| `GetTripExpenseTotalsQuery : IRequest<TripExpenseTotalsDto>` | Expenses | | | |
| 115 | +| `GetBudgetCategorySpentQuery : IRequest<...>` | Expenses | Eelarvekategooria kulutused | | |
| 116 | +| `SettlementPlanCompletedEvent : INotification` | Expenses | Trips tellib — märgib reisi "Settled" kui kõik makstud | | |
| 117 | + | |
| 118 | +--- | |
| 119 | + | |
| 120 | +## 5. Andmeisolatsioon | |
| 121 | + | |
| 122 | +Iga moodul omab oma `DbContext`-i ja Postgres schema: | |
| 123 | + | |
| 124 | +- `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` → schema `users` | |
| 125 | +- `TripsDbContext : DbContext` → schema `trips` | |
| 126 | +- `ExpensesDbContext : DbContext` → schema `expenses` | |
| 127 | + | |
| 128 | +Kõik kolm ühenduvad **samasse** Postgres andmebaasi (sama `ConnectionStrings:DefaultConnection`). Schemad — mitte eraldi DB-d — annavad isolatsiooni. **Cross-module SQL JOIN-id on keelatud**; cross-module andmed komponeerib WebApp facade läbi MediatR ja `CrossModuleNavigationLoader`-i. | |
| 129 | + | |
| 130 | +Cross-module entiteedi-viited on lihtsad `Guid` väljad **ilma** EF foreign-key piiranguta (nt `Trip.CreatedById : Guid` viitab kontseptuaalselt `users.AspNetUsers.Id`-le, aga mitte `FOREIGN KEY` kaudu). Andmete terviklikkus tagatakse: | |
| 131 | +- Eel-MediatR valideerimispäringutega (nt `IsTripParticipantQuery` enne expense split-i salvestamist) | |
| 132 | +- Domeeni-sündmustega kustutamisel (`UserDeletedEvent`, `TripDeletedEvent`) | |
| 133 | + | |
| 134 | +--- | |
| 135 | + | |
| 136 | +## 6. Sõltuvuste graaf (mooduli sees — Clean Architecture) | |
| 137 | + | |
| 138 | +Iga moodul on iseseisev mini-Clean-Architecture: | |
| 139 | + | |
| 140 | +``` | |
| 141 | + Shared.Kernel (BaseEntity, contracts) | |
| 142 | + ▲ | |
| 143 | + │ | |
| 144 | + Module.Domain ◄─── (entiteedid, enumid) | |
| 145 | + ▲ | |
| 146 | + ┌──────┴──────┐ | |
| 147 | + │ │ | |
| 148 | + Module.Application │ | |
| 149 | + ▲ │ | |
| 150 | + │ │ | |
| 151 | + Module.Infrastructure (DbContext, repod, EF migrations) | |
| 152 | + ▲ | |
| 153 | + │ | |
| 154 | + Module.Api (REST controllers, DTO-d) | |
| 155 | + ▲ | |
| 156 | + │ | |
| 157 | + WebApp (composition root) | |
| 158 | +``` | |
| 159 | + | |
| 160 | +Iga mooduli `Infrastructure` registreerib enda `AddXxxModule(IConfiguration)` extension-meetodi. `WebApp/Program.cs` kutsub kõik kolm: | |
| 161 | + | |
| 162 | +```csharp | |
| 163 | +builder.Services.AddUsersModule(builder.Configuration); | |
| 164 | +builder.Services.AddTripsModule(builder.Configuration); | |
| 165 | +builder.Services.AddExpensesModule(builder.Configuration); | |
| 166 | +``` | |
| 167 | + | |
| 168 | +--- | |
| 169 | + | |
| 170 | +## 7. Phase 3 ↔ Phase 2 vastendus | |
| 171 | + | |
| 172 | +| Phase 2 projekt (kustutatud) | Phase 3 sihtmärk | | |
| 173 | +|---|---| | |
| 174 | +| `Base.Domain`, `Base.Contracts` | `Shared.Kernel` | | |
| 175 | +| `Base.Helpers` (IdentityHelpers) | `Shared.Kernel.Auth` | | |
| 176 | +| `App.Domain.Identity.*` | `Modules/Users/SplitApp.Modules.Users.Domain/Entities/` | | |
| 177 | +| `App.Domain.{Trip, TripParticipant, ...}` | `Modules/Trips/SplitApp.Modules.Trips.Domain/Entities/` | | |
| 178 | +| `App.Domain.{Expense, SettlementPlan, ..., Currency}` | `Modules/Expenses/SplitApp.Modules.Expenses.Domain/Entities/` | | |
| 179 | +| `App.DAL.EF.AppDbContext` | jagatud 3-ks: `UsersDbContext`, `TripsDbContext`, `ExpensesDbContext` | | |
| 180 | +| `App.BLL.Services.Identity.*` | `Modules/Users/.../Application/Services/` | | |
| 181 | +| `App.BLL.Services.*` (Trip, Expense, Settlement, ...) | `WebApp/Application/Services/` (composition-root facade kõigi 3 mooduli UoW peal) | | |
| 182 | +| `App.BLL.{DTO, Mappers}` | `WebApp/Application/{DTO, Mappers}` | | |
| 183 | +| `App.Resources/Domain/*.resx` | (üks ühtne) `WebApp/Resources/Views/Shared.{resx,et.resx}` | | |
| 184 | +| `WebApp.ApiControllers.Identity.*` | `Modules/Users/.../Api/Controllers/` | | |
| 185 | +| `WebApp.ApiControllers.{Trips, ...}` | `Modules/Trips/.../Api/Controllers/` | | |
| 186 | +| `WebApp.ApiControllers.{Expenses, Currencies, Settlements, SplitPresets}` | `Modules/Expenses/.../Api/Controllers/` | | |
| 187 | +| `WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` | `SplitApp.WebApp/{Controllers, Areas/Admin, Areas/Identity, Views}` (struktuur sama; namespace re-rooted `SplitApp.WebApp.*`) | | |
| 188 | + | |
| 189 | +--- | |
| 190 | + | |
| 191 | +## 8. Käivitamine | |
| 192 | + | |
| 193 | +**Tootmine (deployd):** https://travel.rasmusj.com/ | |
| 194 | + | |
| 195 | +**Lokaalselt:** | |
| 196 | + | |
| 197 | +```bash | |
| 198 | +# Repo juurest | |
| 199 | +docker compose up --build | |
| 200 | +``` | |
| 201 | + | |
| 202 | +Tõuseb üles: | |
| 203 | +- `phase3` → http://localhost:90 (host port `90` → container port `8080`) | |
| 204 | +- `phase3-db` (PostgreSQL 16) — ainult Docker sisevõrgus, host port pole avatud | |
| 205 | + | |
| 206 | +Iga mooduli migratsioonid jooksevad automaatselt host-i käivitamisel (vt `*ModuleExtensions.UseXxxModule()`). | |
| 207 | + | |
| 208 | +```bash | |
| 209 | +# Testid (25 testi 4 projektis) | |
| 210 | +cd SplitApp.Modular | |
| 211 | +dotnet test | |
| 212 | +``` | |
| 213 | + | |
| 214 | +--- | |
| 215 | + | |
| 216 | +## 9. Miks modulaarne monoliit | |
| 217 | + | |
| 218 | +| Lähenemine | Probleem | | |
| 219 | +|------------|----------| | |
| 220 | +| Klassikaline monoliit | "Kõik viitab kõigele" — üks muudatus → kaskaad-mõju mujal | | |
| 221 | +| Mikroteenused | Hajusüsteemide põrgu — võrk, serialiseerimine, eventual consistency | | |
| 222 | +| **Modulaarne monoliit** | **Selged piirid (nagu mikroteenustel) + lihtne deployment (nagu monoliidil)** | | |
| 223 | + | |
| 224 | +Vaata pikemat juttu kursuse [modularmonolith.md](modularmonolith.md) failist või [SplitApp.Modular/docs/ARCHITECTURE.md](SplitApp.Modular/docs/ARCHITECTURE.md)-ist. | |
| 225 | + | |
| 226 | +--- | |
| 227 | + | |
| 228 | +## Kokkuvõte | |
| 229 | + | |
| 230 | +SplitApp Phase 3 on **modulaarne monoliit** kolme isoleeritud mooduliga (Users, Trips, Expenses). Iga moodul on iseseisev mini-Clean-Architecture oma `Domain`/`Application`/`Infrastructure`/`Api` projektidega ja oma Postgres schema. Cross-module suhtlus käib eranditult **MediatR-i** kaudu (`IRequest`/`INotification`), mitte otsesete `<ProjectReference>`-ite kaudu. Architecture-testid lukustavad need invariandid CI-ajal. |
added docker-compose.yml +32 −0
| @@ -0,0 +1,32 @@ | ||
| 1 | +services: | |
| 2 | + db: | |
| 3 | + image: postgres:16 | |
| 4 | + container_name: phase3-db | |
| 5 | + restart: unless-stopped | |
| 6 | + environment: | |
| 7 | + POSTGRES_DB: splitapp | |
| 8 | + POSTGRES_USER: postgres | |
| 9 | + POSTGRES_PASSWORD: postgres | |
| 10 | + volumes: | |
| 11 | + - phase3-pgdata:/var/lib/postgresql/data | |
| 12 | + | |
| 13 | + phase3: | |
| 14 | + build: | |
| 15 | + context: . | |
| 16 | + dockerfile: Dockerfile | |
| 17 | + container_name: phase3 | |
| 18 | + restart: unless-stopped | |
| 19 | + ports: | |
| 20 | + - "90:8080" | |
| 21 | + environment: | |
| 22 | + - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=splitapp;Username=postgres;Password=postgres | |
| 23 | + - JWT__Key=phase3_modular_monolith_signing_key_at_least_32_chars_long_for_hs256 | |
| 24 | + - JWT__Issuer=itcollege.taltech.ee | |
| 25 | + - JWT__Audience=itcollege.taltech.ee | |
| 26 | + - JWT__ExpiresInSeconds=1800 | |
| 27 | + - ASPNETCORE_URLS=http://+:8080 | |
| 28 | + depends_on: | |
| 29 | + - db | |
| 30 | + | |
| 31 | +volumes: | |
| 32 | + phase3-pgdata: |
added docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0
Line changes are not available for this file.
added docs/grouptravel.png +0 −0
Line changes are not available for this file.
added explanation.md +313 −0
| @@ -0,0 +1,313 @@ | ||
| 1 | +# SplitApp — Reisikulude haldamise rakendus (Phase 3 — Modulaarne Monoliit) | |
| 2 | + | |
| 3 | +## Ülevaade | |
| 4 | + | |
| 5 | +SplitApp on ASP.NET Core 10.0 veebirakendus grupireisi kulude jagamiseks ja haldamiseks. Kasutajad loovad reise, kutsuvad sõpru, lisavad kulusid paindliku jagamisega, haldavad eelarvet, peavad küsitlusi, soovinimekirju ja arveldavad võlgu optimeeritud algoritmiga. | |
| 6 | + | |
| 7 | +Phase 3 refaktorib Phase 2 Clean/Onion monoliidi **modulaarseks monoliidiks**: üks deployable, kolm sisemiselt isoleeritud moodulit (Users, Trips, Expenses), MediatR moodulite vaheliseks suhtluseks, schema-per-moodul Postgres-i isolatsioon. | |
| 8 | + | |
| 9 | +Projekt on tehtud TalTech kursuse "Web Applications with C#" **Personal Project — Phase 3** raames. | |
| 10 | + | |
| 11 | +--- | |
| 12 | + | |
| 13 | +## 0. Phase 3 nõuete täitmine | |
| 14 | + | |
| 15 | +`phase3.md` ütleb: | |
| 16 | +> *Implement your project in aspnet.core in modular monolith architecture (make copy of phase2, new repo). Split out into at least 3 modules (users, 2 of your own). Use mediator for communication between modules. No direct references between modules.* | |
| 17 | + | |
| 18 | +| # | Nõue | Staatus | Asukoht | | |
| 19 | +|---|------|---------|---------| | |
| 20 | +| 1 | ASP.NET Core modulaarne monoliit | ✅ | Kogu [`SplitApp.Modular/`](SplitApp.Modular/) — üks `WebApp` host, 3 moodulit | | |
| 21 | +| 2 | Vähemalt 3 moodulit (users + 2 omad) | ✅ | **Users**, **Trips**, **Expenses** [`SplitApp.Modular/src/Modules/`](SplitApp.Modular/src/Modules/) | | |
| 22 | +| 3 | MediatR moodulite-vaheliseks suhtluseks | ✅ | 11 lepingut [`Shared.Contracts/`](SplitApp.Modular/src/Shared/SplitApp.Shared.Contracts/) — `GetUserByIdQuery`, `TripDeletedEvent`, `IsTripParticipantQuery`, `SettlementPlanCompletedEvent` jne | | |
| 23 | +| 4 | Mitte mingeid otseseid viiteid moodulite vahel | ⚠️ Application/Infrastructure/Api kihil ✅, Domain kihil **kõrvalekalle** | Vaata [§3](#3-mooduli-piirid--viidete-reeglid) | | |
| 24 | + | |
| 25 | +Phase 2 nõuded on kõik säilitatud Phase 3-s: | |
| 26 | +- 19 entiteeti (3 Users + 9 Trips + 7 Expenses) | |
| 27 | +- REST API + versioneerimine + Swagger | |
| 28 | +- JWT auth (`Shared.Kernel.Auth.IdentityHelpers`) | |
| 29 | +- Klient-MVC + Admin Area + Identity Razor Pages | |
| 30 | +- i18n UI (resx) + i18n DB (LangStr) | |
| 31 | +- IDOR (kasutaja näeb ainult oma andmeid REST-is — `IsTripParticipantQuery` MediatR-i kaudu) | |
| 32 | +- CI/CD deploy (Dockerfile + docker-compose) | |
| 33 | +- Repositories + UoW + Services + BLL + Mappers (lifted phase 2 BLL `WebApp/Application/`-i) | |
| 34 | + | |
| 35 | +--- | |
| 36 | + | |
| 37 | +## 1. Arhitektuur — Modulaarne Monoliit | |
| 38 | + | |
| 39 | +``` | |
| 40 | + ┌────────────────────────────────────────────────────┐ | |
| 41 | + │ SplitApp.WebApp (Composition Root) │ | |
| 42 | + │ Program.cs · Controllers · Areas/Admin · Views │ | |
| 43 | + │ Application/{Services, DTO, Mappers, Persistence}│ | |
| 44 | + └────────────────────────────────────────────────────┘ | |
| 45 | + │ │ │ | |
| 46 | + ▼ ▼ ▼ | |
| 47 | + ┌─────────┐ ┌─────────┐ ┌─────────┐ | |
| 48 | + │ Users │ │ Trips │ │ Expenses│ | |
| 49 | + │ Domain │ │ Domain │ │ Domain │ | |
| 50 | + │ App │ │ App │ │ App │ | |
| 51 | + │ Infra │ │ Infra │ │ Infra │ | |
| 52 | + │ Api │ │ Api │ │ Api │ | |
| 53 | + │ schema: │ │ schema: │ │ schema: │ | |
| 54 | + │ users │ │ trips │ │ expenses│ | |
| 55 | + └─────────┘ └─────────┘ └─────────┘ | |
| 56 | + ▲ ▲ ▲ | |
| 57 | + └─MediatR───┴─MediatR───┘ | |
| 58 | + ┌──────────────────────┐ ┌──────────────────────┐ | |
| 59 | + │ Shared.Contracts │ │ Shared.Kernel │ | |
| 60 | + │ IRequest/INotification│ │ BaseEntity, LangStr │ | |
| 61 | + └──────────────────────┘ └──────────────────────┘ | |
| 62 | +``` | |
| 63 | + | |
| 64 | +**Põhimõte:** üks deployable, kolm isoleeritud moodulit. Iga moodul = mini-Clean-Architecture (Domain/Application/Infrastructure/Api). Cross-module kõned eranditult MediatR-iga. | |
| 65 | + | |
| 66 | +### Kursuse loengu võtmelaused (modular monolith — `modularmonolith.md`) | |
| 67 | + | |
| 68 | +> *"Modules never reference each other's internals. Module A doesn't touch Module B's entities, repositories, or DbContext."* | |
| 69 | + | |
| 70 | +> *"Communication goes through: contracts (interfaces in shared project) and domain events (in-process, loose coupling)."* | |
| 71 | + | |
| 72 | +> *"Each module has its own DbContext scoped to its tables — modules don't share database contexts."* | |
| 73 | + | |
| 74 | +Meie projekt järgib seda: | |
| 75 | +- `Modules/Users/Application` ei viita `Modules/Trips/*`-le ega `Modules/Expenses/*`-le | |
| 76 | +- Kui `Trips` vajab kasutaja-nime, saadab ta `GetUserByIdQuery` MediatR-i kaudu — Users module's handler vastab | |
| 77 | +- Iga moodul omab oma `DbContext`-i ja Postgres schema (cross-module SQL JOIN-id keelatud) | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## 2. Lahenduse struktuur | |
| 82 | + | |
| 83 | +``` | |
| 84 | +SplitApp.Modular/ | |
| 85 | +├── SplitApp.sln | |
| 86 | +├── Directory.Build.props | |
| 87 | +└── src/ | |
| 88 | + ├── SplitApp.WebApp/ ← composition root, host | |
| 89 | + │ ├── Program.cs ← DI wiring, AddXxxModule(...) | |
| 90 | + │ ├── Application/ ← Phase 2 BLL liigutatud | |
| 91 | + │ │ ├── Services/ (+ Admin/, Identity/) ← TripService, ExpenseService, ... | |
| 92 | + │ │ ├── DTO/ ← TripBllDto, ExpenseBllDto, ... | |
| 93 | + │ │ ├── Mappers/ ← Domain↔BllDto factory mapperid | |
| 94 | + │ │ ├── Persistence/AppUnitOfWork.cs ← agregeerib 3 mooduli DbContext-id | |
| 95 | + │ │ ├── Persistence/CrossModuleNavigationLoader.cs ← hüdreerib [NotMapped] cross-navsid | |
| 96 | + │ │ └── Contracts/IAppUnitOfWork.cs ← Phase 2 stiilis facade | |
| 97 | + │ ├── Areas/Admin/ ← admin UX | |
| 98 | + │ ├── Areas/Identity/ ← Razor Register | |
| 99 | + │ ├── Controllers/ ← klient MVC | |
| 100 | + │ ├── Views/ ← klient vaated | |
| 101 | + │ └── Resources/ ← i18n .resx | |
| 102 | + ├── Shared/ | |
| 103 | + │ ├── SplitApp.Shared.Kernel/ ← BaseEntity, IBaseRepo, IUoW, LangStr, IdentityHelpers | |
| 104 | + │ └── SplitApp.Shared.Contracts/ ← MediatR contracts | |
| 105 | + └── Modules/ | |
| 106 | + ├── Users/ ← AppUser, AppRole, AppRefreshToken; JWT issuance | |
| 107 | + ├── Trips/ ← Trip, TripParticipant, TripPoll, TripWishlistItem, BudgetCategory, ... | |
| 108 | + └── Expenses/ ← Expense, ExpenseSplit, SettlementPlan, SettlementPayment, Currency, SplitPreset, ... | |
| 109 | +``` | |
| 110 | + | |
| 111 | +`tests/` sisaldab: | |
| 112 | +- 3 mooduli unit-teste (CurrencyConverter, LangStr, IdentityHelpers) | |
| 113 | +- `WebApp.IntegrationTests/Architecture/` — `ModuleBoundaryTests`, `DbContextSchemaIsolationTests`, `CrossModuleNavigationTests` | |
| 114 | +- `WebApp.IntegrationTests/HostBootSmokeTests` + `HostFeatureTests` — `WebApplicationFactory<Program>` HTTP-smoke + ristlõikeliste nõuete testid | |
| 115 | + | |
| 116 | +Kokku **44 testi** — kõik green. | |
| 117 | + | |
| 118 | +--- | |
| 119 | + | |
| 120 | +## 3. Mooduli piirid — viidete reeglid | |
| 121 | + | |
| 122 | +Compiler-enforced + verifitseeritud architecture-testidega. | |
| 123 | + | |
| 124 | +| Allikas | Lubatud sihtmärgid | Märkus | | |
| 125 | +|---------|-------------------|--------| | |
| 126 | +| `Modules/X/Application` | sama mooduli `Domain` + `Shared.Kernel` + `Shared.Contracts` | | | |
| 127 | +| `Modules/X/Infrastructure` | sama mooduli `Domain` + `Application` + `Shared.Kernel` | | | |
| 128 | +| `Modules/X/Api` | sama mooduli `Application` + `Shared.Kernel` + `Shared.Contracts` | | | |
| 129 | +| `Shared.*` | mitte ühelegi moodulile | | | |
| 130 | +| `WebApp` | kõik 3 mooduli `Api` + `Infrastructure` + `Shared.*` | composition root | | |
| 131 | + | |
| 132 | +**Kõrvalekalle Domain tasandil:** entiteedi-klassidel on cross-module nav-property'd (`Trip.CreatedBy`, `Expense.PaidByUser`, `Trip.DefaultCurrency` jms), kõik `[NotMapped]`-iga märgitud. Et tüübid (`AppUser`, `Currency`, `Expense`) kompileeruksid, on Domain-csproj-idel viited: | |
| 133 | + | |
| 134 | +``` | |
| 135 | +Modules/Trips/Domain.csproj → Modules/Users/Domain + Modules/Expenses/Domain | |
| 136 | +Modules/Expenses/Domain.csproj → Modules/Users/Domain | |
| 137 | +``` | |
| 138 | + | |
| 139 | +**Miks see olemas on?** Iga moodul käib ainult oma DbContext-i kaudu — ehk `ExpensesDbContext` ei tea midagi `users` skeemist. Aga UI peab näitama "kulu 80€ — maksis Alice Johnson". WebApp-i fassaad ([`AppUnitOfWork`](SplitApp.Modular/src/SplitApp.WebApp/Application/Persistence/AppUnitOfWork.cs)) lahendab selle nii: tõmbab kõigepealt `Expense`-id Expenses-DB-st, siis päring Users-DB-st õigete `AppUser`-ite järgi, ja **käsitsi** C#-koodis paneb `expense.PaidByUser = user`. Et see omistamine oleks **tüübikindel** (kompilaator kontrollib, IDE pakub autocomplete'i, vead leitakse build-i ajal — mitte runtime'is), peab `Expense`-klass teadma `AppUser` tüüpi → siit Domain-csproj-viide. | |
| 140 | + | |
| 141 | +`[NotMapped]` tagab samal ajal, et **andmebaas jääb sellest täiesti puutumata** — EF ei tee veergu, ei tee JOIN-i, ei näe seda välja. Schema-isolatsioon säilib täielikult. | |
| 142 | + | |
| 143 | +**Kompromiss:** Application/Infrastructure/Api jäävad puhtaks ja kasutavad MediatR-i. Schema-isolatsioon + suhtluse-isolatsioon runtime-tasemel on 100% säilitatud. Ainult entiteedi-*tüübid* on jagatud — see on **C# tüübisüsteemi mugavus mälus-ühendamise jaoks**, mitte funktsionaalne sõltuvus. | |
| 144 | + | |
| 145 | +`CrossModuleNavigationTests` kindlustab: kui keegi proovib teha *mapped* (mitte-`[NotMapped]`) cross-module nav-i, siis test kukub. | |
| 146 | + | |
| 147 | +--- | |
| 148 | + | |
| 149 | +## 4. Moodulite-vaheline suhtlus (MediatR) | |
| 150 | + | |
| 151 | +Lepingud elavad `Shared.Contracts/<Moodul>/{Queries|Events|Commands}/`. Iga leping on `record` mis implementeerib kas: | |
| 152 | +- `IRequest<T>` — sünkroonne päring/käsk (üks vastus) | |
| 153 | +- `INotification` — fan-out sündmus (mitu tellijat) | |
| 154 | + | |
| 155 | +Saadetakse hetkel 11 lepingut: | |
| 156 | + | |
| 157 | +| Leping | Omanik | Otstarve | | |
| 158 | +|--------|--------|----------| | |
| 159 | +| `GetUserByIdQuery → UserDto?` | Users | Trips/Expenses kasutavad nime kuvamiseks | | |
| 160 | +| `GetUsersByIdsQuery → IReadOnlyList<UserDto>` | Users | Partii-päring | | |
| 161 | +| `UserDeletedEvent` (notification) | Users | Trips + Expenses tellivad — eemaldavad seotud kirjed | | |
| 162 | +| `GetTripByIdQuery → TripSummaryDto?` | Trips | Cross-module reisi-otsing | | |
| 163 | +| `GetTripParticipantsQuery → IReadOnlyList<TripParticipantDto>` | Trips | | | |
| 164 | +| `IsTripParticipantQuery → bool` | Trips | **IDOR-i kaitse**: ExpensesController kasutab seda enne expense-i salvestamist | | |
| 165 | +| `TripDeletedEvent` (notification) | Trips | Expenses tellib — kustutab kulud + arveldused | | |
| 166 | +| `GetTripExpenseTotalsQuery → TripExpenseTotalsDto` | Expenses | | | |
| 167 | +| `GetBudgetCategorySpentQuery → IReadOnlyDictionary<Guid, decimal>` | Expenses | Eelarve-kategooria kulutused | | |
| 168 | +| `ExpenseSettledEvent` (notification) | Expenses | Reserveeritud tulevikuks | | |
| 169 | +| `SettlementPlanCompletedEvent` (notification) | Expenses | Trips tellib — kui kõik maksed kinnitatud, märgib reisi "Settled" | | |
| 170 | + | |
| 171 | +**Näide voost:** kasutaja loob expense-i (`POST /api/v1/expenses`): | |
| 172 | +1. `ExpensesController` saadab `IsTripParticipantQuery(tripId, userId)` → MediatR | |
| 173 | +2. Trips mooduli `IsTripParticipantHandler` kontrollib `TripsDbContext`-st — tagastab `bool` | |
| 174 | +3. Kui `false` → 403 Forbidden (IDOR-i kaitse) | |
| 175 | +4. Kui `true` → expense salvestub `ExpensesDbContext`-i (schema `expenses`) | |
| 176 | + | |
| 177 | +--- | |
| 178 | + | |
| 179 | +## 5. Andmeisolatsioon | |
| 180 | + | |
| 181 | +Üks Postgres andmebaas, kolm schemat. Kolm `DbContext`-i ühenduvad sama `ConnectionStrings:DefaultConnection`-i kaudu, aga igaüks kasutab `b.HasDefaultSchema("...")`-d: | |
| 182 | + | |
| 183 | +| DbContext | Schema | Sisu | | |
| 184 | +|-----------|--------|------| | |
| 185 | +| `UsersDbContext : IdentityDbContext<AppUser, AppRole, Guid>` | `users` | AspNetUsers, AspNetRoles, RefreshTokens, DataProtectionKeys | | |
| 186 | +| `TripsDbContext : DbContext` | `trips` | Trips, TripParticipants, TripInvitations, TripPolls, TripWishlistItems, BudgetCategories | | |
| 187 | +| `ExpensesDbContext : DbContext` | `expenses` | Expenses, ExpenseSplits, SettlementPlans, SettlementPayments, Currencies, SplitPresets | | |
| 188 | + | |
| 189 | +**Cross-module SQL JOIN-id on keelatud.** EF kunagi ei lähe schema piirist üle, sest cross-module nav-property'd on `[NotMapped]`. Cross-module andmete komponeerimine toimub: | |
| 190 | +1. WebApp facade `AppUnitOfWork`-is — repod hüdreerivad cross-module navsid C#-is pärast põhipäringut (vt `CrossModuleHydration.HydrateUsersAsync`, `HydrateTripsAsync`) | |
| 191 | +2. Või MediatR-i kaudu — `ExpensesController` küsib Users-mooduli käest `GetUsersByIdsQuery`-iga | |
| 192 | + | |
| 193 | +**Andmete terviklikkus** (cross-module FK puudub) tagatakse: | |
| 194 | +- Eel-MediatR valideerimispäringutega (`IsTripParticipantQuery`) | |
| 195 | +- Domeeni-sündmustega kustutamisel (`UserDeletedEvent`, `TripDeletedEvent`) | |
| 196 | + | |
| 197 | +--- | |
| 198 | + | |
| 199 | +## 6. Phase 2 BLL "lifted" struktuur WebApp-is | |
| 200 | + | |
| 201 | +Phase 3 ei loo uut BLL-i; selle asemel **liigutab Phase 2 BLL koodi `WebApp/Application/`** alla. See on praktiline kompromiss, mis hoiab Phase 2 100% paarsust UX-iga ja säästab refaktori-aega. | |
| 202 | + | |
| 203 | +| Phase 2 projekt | Phase 3 sihtmärk | | |
| 204 | +|---|---| | |
| 205 | +| `App.BLL/Services/*` (Trip, Expense, Settlement, ...) | `WebApp/Application/Services/*` | | |
| 206 | +| `App.BLL/Services/Admin/*` (12 admin-teenust) | `WebApp/Application/Services/Admin/*` | | |
| 207 | +| `App.BLL/Services/Identity/*` | `Modules/Users/Application/Services/` (siiski liigutatud Users-moodulisse) | | |
| 208 | +| `App.BLL/DTO/*` | `WebApp/Application/DTO/*` | | |
| 209 | +| `App.BLL/Mappers/*BllDtoFactory.cs` | `WebApp/Application/Mappers/*` | | |
| 210 | +| `App.Domain/Contracts/IAppUnitOfWork.cs` | `WebApp/Application/Contracts/IAppUnitOfWork.cs` | | |
| 211 | +| `App.DAL.EF.AppUnitOfWork` | `WebApp/Application/Persistence/AppUnitOfWork.cs` (3 DbContext-i agregaator) | | |
| 212 | +| `App.DAL.EF.Repositories.*` | inline `WebApp/Application/Persistence/AppUnitOfWork.cs` (TripRepo, ExpenseRepo jne) | | |
| 213 | + | |
| 214 | +Lifted BLL teenused töötavad endiselt `IAppUnitOfWork`-i kaudu. Teenuse vaatest pole midagi muutunud — `_uow.Trips.GetByIdAsync(...)` käitub samamoodi nagu Phase 2-s. Tegelikkuses suunab `AppUnitOfWork.Trips` päringud `TripsDbContext`-le, ja `CrossModuleHydration` täidab cross-module nav-id (`Trip.CreatedBy` jms) tagantjärele eraldi päringuga. | |
| 215 | + | |
| 216 | +--- | |
| 217 | + | |
| 218 | +## 7. Käivitamine | |
| 219 | + | |
| 220 | +**Tootmine (deployd):** https://travel.rasmusj.com/ | |
| 221 | + | |
| 222 | +**Lokaalselt** repo juurest: | |
| 223 | + | |
| 224 | +```bash | |
| 225 | +docker compose up --build | |
| 226 | +``` | |
| 227 | + | |
| 228 | +Tõuseb üles: | |
| 229 | +- `phase3` → http://localhost:90 (host port `90` → container port `8080`) | |
| 230 | +- `phase3-db` (PostgreSQL 16) — ainult Docker sisevõrgus, host port pole avatud | |
| 231 | + | |
| 232 | +Iga mooduli migratsioonid jooksevad automaatselt host-i käivitumisel (vt `*ModuleExtensions.UseXxxModule(...)`-it). | |
| 233 | + | |
| 234 | +### Testid | |
| 235 | + | |
| 236 | +```bash | |
| 237 | +cd SplitApp.Modular | |
| 238 | +dotnet test | |
| 239 | +``` | |
| 240 | + | |
| 241 | +Roheliseks läheb **44 testi**: | |
| 242 | + | |
| 243 | +| Projekt | Testid | Mida katavad | | |
| 244 | +|---|---:|---| | |
| 245 | +| `SplitApp.Modules.Users.Tests` | 8 | `IdentityHelpers` — JWT genereerimine + valideerimine + tagasilükkamine vale issuer/audience/võtme/malformed-tokeni puhul | | |
| 246 | +| `SplitApp.Modules.Trips.Tests` | 12 | `LangStr` — mitme-keele tõlke teisendus + fallback + tühi/null/error piirjuhud | | |
| 247 | +| `SplitApp.Modules.Expenses.Tests` | 10 | `CurrencyConverter` — kursi-teisendused + null/negatiivsed summad + ümardamine + round-trip täpsus | | |
| 248 | +| `SplitApp.WebApp.IntegrationTests` | 14 | Arhitektuuri invariandid (mooduli piirid, schema-isolatsioon, `[NotMapped]` reegel) + `WebApplicationFactory` HTTP-smoke (Home/, Swagger UI + v1 doc, Admin auth-redirect, REST API JWT-nõue, lokaliseerimine `?culture=et`) | | |
| 249 | + | |
| 250 | +**Unit-testid** ei vaja andmebaasi — käivituvad millisekundites ja testivad puhast loogikat (kursi-teisendus, JWT-token, LangStr). **Integration-testid** käivitavad reaalse WebApp hosti mälus (`WebApplicationFactory<Program>` + `"Testing"` keskkond, kus migratsioonid skiipitakse) ja teevad HTTP-päringuid — see kinnitab, et iga ristlõikeline nõue (Swagger, JWT, Admin kaitse, i18n) on päriselt töökorras, mitte ainult konfiguratsioonis olemas. | |
| 251 | + | |
| 252 | +--- | |
| 253 | + | |
| 254 | +## 8. URL kaart | |
| 255 | + | |
| 256 | +| URL | Otstarve | | |
| 257 | +|-----|----------| | |
| 258 | +| `/` | Avalehe MVC | | |
| 259 | +| `/Trips`, `/Trips/Create`, `/Trips/Details/{id}`, ... | Reisi CRUD | | |
| 260 | +| `/Members?tripId={id}` ja `/Members/AcceptInvitation/{token}` | Osalejad + kutse | | |
| 261 | +| `/Expenses?tripId={id}` (Create/Edit/Delete) | Kulud reisi kohta | | |
| 262 | +| `/Budget?tripId={id}` (CreateCategory/EditCategory/DeleteCategory) | Eelarvekategooriad | | |
| 263 | +| `/Settlement?tripId={id}` | Bilanss + arveldusplaanid | | |
| 264 | +| `/PollsClient?tripId={id}` (Create/Details) | Reisi küsitlused | | |
| 265 | +| `/WishlistClient?tripId={id}` | Soovinimekiri | | |
| 266 | +| `/Identity/Account/Register` | Cookie-põhine registreerumine | | |
| 267 | +| `/Admin/Dashboard` | Admin avaleht (`admin` roll) | | |
| 268 | +| `/Admin/{Users, Trips, Expenses, BudgetCategories, Currencies, Invitations, Polls, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Wishlist}` | Admin CRUD | | |
| 269 | +| `/swagger` | Swagger UI | | |
| 270 | + | |
| 271 | +### REST API | |
| 272 | + | |
| 273 | +| Moodul | Endpoint-id | | |
| 274 | +|--------|-------------| | |
| 275 | +| Users | `/api/v1/identity/account/{register, login, logout, refreshtokendata}` | | |
| 276 | +| Trips | `/api/v1/trips`, `/api/v1/budgetcategories`, `/api/v1/invitations`, `/api/v1/polls`, `/api/v1/wishlist` | | |
| 277 | +| Expenses | `/api/v1/expenses`, `/api/v1/currencies`, `/api/v1/settlements`, `/api/v1/splitpresets` | | |
| 278 | + | |
| 279 | +--- | |
| 280 | + | |
| 281 | +## 9. Architecture-testid (mooduli piiride lukustamine) | |
| 282 | + | |
| 283 | +`tests/SplitApp.WebApp.IntegrationTests/Architecture/`: | |
| 284 | + | |
| 285 | +1. **`ModuleBoundaryTests`** — ükski mooduli `Application`/`Infrastructure`/`Api` `<ProjectReference>` ei viita teisele moodulile; `Shared.*` ei viita ühelegi moodulile. | |
| 286 | +2. **`DbContextSchemaIsolationTests`** — iga `DbContext` sisaldab `DbSet<T>`-e ainult oma mooduli `Domain` projektist. | |
| 287 | +3. **`CrossModuleNavigationTests`** — cross-module nav-property on lubatud ainult `[NotMapped]`-iga. | |
| 288 | +4. **`HostBootSmokeTests`** — `WebApplicationFactory<Program>` käivitab täis-host'i `Testing` keskkonnas (skiipib migratsioonid), `/`, `/Home/Index` ja autentimata API-päring tagastab 401. | |
| 289 | +5. **`HostFeatureTests`** — ristlõikeliste nõuete HTTP-tasandil kontroll: Swagger v1 doc + UI serveeritakse, `/Admin/*` suunab autentimata kasutaja Identity login-lehele, `/api/v1/expenses/*` nõuab JWT-d (teine moodul, sama reegel), `?culture=et` ei riku request-localization ahelat. | |
| 290 | + | |
| 291 | +Kui mõni neist langeb, on keegi rikkunud modulaarmonoliidi invariandi või ristlõikelise nõude. | |
| 292 | + | |
| 293 | +--- | |
| 294 | + | |
| 295 | +## 10. Miks modulaarne monoliit? | |
| 296 | + | |
| 297 | +| Lähenemine | Probleem | | |
| 298 | +|------------|----------| | |
| 299 | +| Klassikaline monoliit | Kõik viitab kõigele — üks muudatus → kaskaad-mõju | | |
| 300 | +| Mikroteenused | Hajusüsteemide põrgu — võrk, serialiseerimine, eventual consistency, deployment-keerukus | | |
| 301 | +| **Modulaarne monoliit** | **Selged piirid (nagu mikroteenustel) + lihtne deployment (nagu monoliidil)** | | |
| 302 | + | |
| 303 | +Ekstraheerimise tee tulevikus, kui peaks vaja minema: | |
| 304 | +- *Klassikaline monoliit → Modulaarne monoliit → Mikroteenused* | |
| 305 | +- Iga moodul juba omab oma schema, oma lepingud, oma `DbContext`-i. Mooduli eraldamine teenuseks tähendab in-process MediatR-kõnete asendamist HTTP/gRPC-ga ning sündmuste viimist message brokeri peale. Koodi struktuur ei muutu — ainult transport-kiht. | |
| 306 | + | |
| 307 | +Vaata kursuse [modularmonolith.md](modularmonolith.md) faili pikemaks aruteluks. | |
| 308 | + | |
| 309 | +--- | |
| 310 | + | |
| 311 | +## Kokkuvõte | |
| 312 | + | |
| 313 | +**SplitApp Phase 3 on modulaarne monoliit kolme isoleeritud mooduliga (Users, Trips, Expenses).** Iga moodul on iseseisev mini-Clean-Architecture oma `Domain`/`Application`/`Infrastructure`/`Api` projektidega ja oma Postgres schema. Cross-module suhtlus käib ainult **MediatR**-i kaudu (`IRequest`/`INotification`), mitte otseste `<ProjectReference>`-ite kaudu. Architecture-testid lukustavad need invariandid CI ajal. Kõigil Phase 2 nõuetel (REST API + versioning + Swagger, JWT, MVC + Admin Area, i18n, IDOR, Repos/UoW/Services/BLL/Mappers, CI/CD, testid) on Phase 3-s täielik kate. |
added modularmonolith.md +328 −0
| @@ -0,0 +1,328 @@ | ||
| 1 | +Modular Monolith | |
| 2 | +One deployable, but internally split into self-contained modules. Each module owns its domain, data, and services. Modules communicate through well-defined interfaces — not by reaching into each other's guts. | |
| 3 | + | |
| 4 | +Think of it as: Clean Architecture applied per feature/domain, all living in one process. | |
| 5 | + | |
| 6 | +Why | |
| 7 | + | |
| 8 | +Approach Problem | |
| 9 | +Classic monolith Everything references everything. One change -> cascade everywhere | |
| 10 | +Microservices Distributed systems hell. Network, serialization, eventual consistency. Overkill for most teams | |
| 11 | +Modular monolith Clean boundaries like microservices, deployment simplicity of a monolith. Split later if you actually need to | |
| 12 | +Structure | |
| 13 | + | |
| 14 | +MyApp.sln | |
| 15 | +├── MyApp.Web // Composition root, routing, DI wiring | |
| 16 | +│ | |
| 17 | +├── Modules/ | |
| 18 | +│ ├── MyApp.Modules.Persons/ | |
| 19 | +│ │ ├── Domain/ // Entities, interfaces | |
| 20 | +│ │ ├── Application/ // Services, DTOs | |
| 21 | +│ │ ├── Infrastructure/ // EF configs, repos | |
| 22 | +│ │ └── Api/ // Controllers or endpoints | |
| 23 | +│ │ | |
| 24 | +│ ├── MyApp.Modules.Orders/ | |
| 25 | +│ │ ├── Domain/ | |
| 26 | +│ │ ├── Application/ | |
| 27 | +│ │ ├── Infrastructure/ | |
| 28 | +│ │ └── Api/ | |
| 29 | +│ │ | |
| 30 | +│ └── MyApp.Modules.Notifications/ | |
| 31 | +│ ├── Domain/ | |
| 32 | +│ ├── Application/ | |
| 33 | +│ ├── Infrastructure/ | |
| 34 | +│ └── Api/ | |
| 35 | +│ | |
| 36 | +├── MyApp.Shared.Contracts/ // Cross-module interfaces, shared DTOs | |
| 37 | +└── MyApp.Shared.Infrastructure/ // Common utilities, base classes | |
| 38 | + | |
| 39 | +Each module is a mini Clean Architecture. Each module has its own DbContext scoped to its tables — modules don't share database contexts. | |
| 40 | + | |
| 41 | +The golden rule | |
| 42 | + | |
| 43 | +Modules never reference each other's internals. Module A doesn't touch Module B's entities, repositories, or DbContext. | |
| 44 | + | |
| 45 | +Communication goes through: | |
| 46 | + | |
| 47 | +Contracts (interfaces in shared project): | |
| 48 | +Domain events (in-process, loose coupling): | |
| 49 | +// Shared | |
| 50 | +public record PersonDeletedEvent(int PersonId); | |
| 51 | + | |
| 52 | +// Persons module publishes (replaced with messaging in microservices) | |
| 53 | +await _mediator.Publish(new PersonDeletedEvent(id)); | |
| 54 | + | |
| 55 | +// Orders module handles | |
| 56 | +public class PersonDeletedHandler : INotificationHandler<PersonDeletedEvent> | |
| 57 | +{ | |
| 58 | + public async Task Handle(PersonDeletedEvent e, CancellationToken ct) | |
| 59 | + { | |
| 60 | + // cancel pending orders, clean up references | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +Separate DbContexts per module | |
| 65 | + | |
| 66 | +This is what enforces the boundary at the data level: | |
| 67 | + | |
| 68 | +// Persons module — only sees Person tables | |
| 69 | +public class PersonDbContext : DbContext | |
| 70 | +{ | |
| 71 | + public DbSet<Person> Persons => Set<Person>(); | |
| 72 | + public DbSet<Address> Addresses => Set<Address>(); | |
| 73 | + | |
| 74 | + protected override void OnModelCreating(ModelBuilder b) | |
| 75 | + { | |
| 76 | + b.HasDefaultSchema("persons"); // schema isolation | |
| 77 | + } | |
| 78 | +} | |
| 79 | + | |
| 80 | +// Orders module — only sees Order tables | |
| 81 | +public class OrderDbContext : DbContext | |
| 82 | +{ | |
| 83 | + public DbSet<Order> Orders => Set<Order>(); | |
| 84 | + public DbSet<OrderLine> OrderLines => Set<OrderLine>(); | |
| 85 | + | |
| 86 | + protected override void OnModelCreating(ModelBuilder b) | |
| 87 | + { | |
| 88 | + b.HasDefaultSchema("orders"); | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +Same database server, different schemas. No cross-module joins. If Orders needs person data, it goes through IPersonModuleApi, not a SQL join. | |
| 93 | + | |
| 94 | +Coupling vs Cohesion — the two metrics that matter | |
| 95 | + | |
| 96 | +Coupling = how much modules depend on each other's internals. | |
| 97 | + | |
| 98 | +High Coupling Low Coupling | |
| 99 | +How Direct references, shared DB context, calling internal methods Contracts, events, shared DTOs only | |
| 100 | +Change impact Touch one module -> break three others Touch one module -> others don't notice | |
| 101 | +Testing Need to spin up half the app Test module in isolation | |
| 102 | +Cohesion = how related the stuff inside a module is. | |
| 103 | + | |
| 104 | +High Cohesion Low Cohesion | |
| 105 | +How Everything in the module serves one domain concept Grab-bag of unrelated utilities | |
| 106 | +Example PersonService + PersonRepository + PersonValidator UtilityService with email + tax + image resize | |
| 107 | +Symptom Module name describes exactly what's inside Module name is vague ("Helpers", "Common", "Utils") | |
| 108 | +The goal: low coupling between modules, high cohesion within modules. | |
| 109 | + | |
| 110 | +"How do I know if my module boundaries are right?" — the answer is: look at how often a change in one module forces a change in another. If it's frequent, your boundary is in the wrong place. The communication patterns (contracts, events) should cross boundaries rarely, not on every request. | |
| 111 | + | |
| 112 | +The migration path — why this matters practically | |
| 113 | + | |
| 114 | +Classic Monolith -> Modular Monolith -> Microservices (if you ever actually need to) | |
| 115 | + | |
| 116 | +Each module already has its own schema, its own contracts, its own DbContext. Extracting a module into a separate service later means replacing in-process IPersonModuleApi calls with HTTP/gRPC calls, and domain events with a message broker. The code structure barely changes — only the transport layer does. | |
| 117 | + | |
| 118 | +Most teams never need that last step. The modular monolith gives you 90% of the organizational benefits of microservices with none of the distributed systems pain. | |
| 119 | + | |
| 120 | +MyApp — Single Deployment | |
| 121 | + | |
| 122 | +Shared Contracts | |
| 123 | + | |
| 124 | +Notifications Module | |
| 125 | + | |
| 126 | +Orders Module | |
| 127 | + | |
| 128 | +Persons Module | |
| 129 | + | |
| 130 | +Web / Composition Root | |
| 131 | + | |
| 132 | +uses contract | |
| 133 | + | |
| 134 | +implements | |
| 135 | + | |
| 136 | +subscribes to | |
| 137 | + | |
| 138 | +publishes | |
| 139 | + | |
| 140 | +IPersonModuleApi | |
| 141 | + | |
| 142 | +Api | |
| 143 | + | |
| 144 | +Application | |
| 145 | + | |
| 146 | +Domain | |
| 147 | + | |
| 148 | +Infrastructure | |
| 149 | + | |
| 150 | +persons schema | |
| 151 | + | |
| 152 | +Api | |
| 153 | + | |
| 154 | +Application | |
| 155 | + | |
| 156 | +Domain | |
| 157 | + | |
| 158 | +Infrastructure | |
| 159 | + | |
| 160 | +orders schema | |
| 161 | + | |
| 162 | +Api | |
| 163 | + | |
| 164 | +Application | |
| 165 | + | |
| 166 | +Domain | |
| 167 | + | |
| 168 | +Infrastructure | |
| 169 | + | |
| 170 | +notifications schema | |
| 171 | + | |
| 172 | +Routing | |
| 173 | + | |
| 174 | +Domain Events | |
| 175 | + | |
| 176 | +IOrderModuleApi | |
| 177 | + | |
| 178 | +DI Wiring | |
| 179 | + | |
| 180 | +OK Low Coupling (modular monolith) | |
| 181 | + | |
| 182 | +publishes event | |
| 183 | + | |
| 184 | +subscribes | |
| 185 | + | |
| 186 | +calls contract | |
| 187 | + | |
| 188 | +implements | |
| 189 | + | |
| 190 | +subscribes | |
| 191 | + | |
| 192 | +Person Module | |
| 193 | + | |
| 194 | +Event Bus | |
| 195 | + | |
| 196 | +Order Module | |
| 197 | + | |
| 198 | +IPersonModuleApi | |
| 199 | + | |
| 200 | +Notification Module | |
| 201 | + | |
| 202 | +BAD High Coupling (classic monolith) | |
| 203 | + | |
| 204 | +direct DB access | |
| 205 | + | |
| 206 | +references | |
| 207 | + | |
| 208 | +direct DB access | |
| 209 | + | |
| 210 | +references | |
| 211 | + | |
| 212 | +calls internal method | |
| 213 | + | |
| 214 | +references everything | |
| 215 | + | |
| 216 | +Person Service | |
| 217 | + | |
| 218 | +Order Tables | |
| 219 | + | |
| 220 | +Order Entities | |
| 221 | + | |
| 222 | +Order Service | |
| 223 | + | |
| 224 | +Person Tables | |
| 225 | + | |
| 226 | +Person Entities | |
| 227 | + | |
| 228 | +Notification Service | |
| 229 | + | |
| 230 | +BAD Low Cohesion (grab bag service) | |
| 231 | + | |
| 232 | +UtilityService | |
| 233 | + | |
| 234 | +SendEmail | |
| 235 | + | |
| 236 | +CalculateTax | |
| 237 | + | |
| 238 | +ResizeImage | |
| 239 | + | |
| 240 | +ValidatePerson | |
| 241 | + | |
| 242 | +GenerateReport | |
| 243 | + | |
| 244 | +OK High Cohesion (inside a module) | |
| 245 | + | |
| 246 | +PersonService | |
| 247 | + | |
| 248 | +PersonRepository | |
| 249 | + | |
| 250 | +PersonValidator | |
| 251 | + | |
| 252 | +PersonMapper | |
| 253 | + | |
| 254 | +Person Entity | |
| 255 | + | |
| 256 | +Eventual Consistency | |
| 257 | +In a monolith with one database, you get immediate consistency — save data, read it back, it's there. One transaction, one commit, done. | |
| 258 | + | |
| 259 | +The moment you split into modules, services, or separate databases — that guarantee breaks. You get eventual consistency instead: changes propagate, but not instantly. There's a window where different parts of the system see different data. | |
| 260 | + | |
| 261 | +The classic example | |
| 262 | + | |
| 263 | +User places an order. Three things need to happen: | |
| 264 | + | |
| 265 | +Save the order (Orders module) | |
| 266 | +Reserve inventory (Inventory module) | |
| 267 | +Send confirmation email (Notifications module) | |
| 268 | +Immediate consistency approach — distributed transaction: | |
| 269 | + | |
| 270 | +BEGIN TRANSACTION | |
| 271 | + Insert order → Orders DB | |
| 272 | + Decrement stock → Inventory DB | |
| 273 | + Queue email → Notifications DB | |
| 274 | +COMMIT | |
| 275 | + | |
| 276 | +This is a distributed transaction (2PC). All three succeed or all three roll back. Sounds clean. In practice: slow, fragile, doesn't scale, most modern databases and message brokers don't even support it properly. | |
| 277 | + | |
| 278 | +Eventual consistency approach — events: | |
| 279 | + | |
| 280 | +1. Orders module saves order → commits to its own DB | |
| 281 | +2. Orders module publishes OrderPlacedEvent | |
| 282 | +3. Inventory module handles event → reserves stock in its own DB | |
| 283 | +4. Notifications module handles event → sends email | |
| 284 | + | |
| 285 | +Each step is a local transaction — fast, reliable. But between steps 1 and 3, the order exists without reserved inventory. That's the consistency window. | |
| 286 | + | |
| 287 | +What can go wrong | |
| 288 | + | |
| 289 | +Timeline: | |
| 290 | + T0: Order saved → Orders DB has the order | |
| 291 | + T1: Event published → on the bus | |
| 292 | + T2: Inventory handler → starts processing | |
| 293 | + ------- consistency window ------- | |
| 294 | + T3: Stock reserved → Inventory DB updated | |
| 295 | + | |
| 296 | + Between T0 and T3, the system is "inconsistent": | |
| 297 | + - Orders says: "order exists" | |
| 298 | + - Inventory says: "stock not reserved yet" | |
| 299 | + | |
| 300 | +If someone queries inventory at T1, they see stale data. If the inventory handler crashes at T2, the order exists but stock is never reserved. | |
| 301 | + | |
| 302 | +In the modular monolith context | |
| 303 | + | |
| 304 | +In-process with MediatR, eventual consistency happens between SaveChangesAsync calls in different modules. The window is small (milliseconds), but it exists. | |
| 305 | + | |
| 306 | +// Persons module | |
| 307 | +await _personRepo.DeleteAsync(id); | |
| 308 | +await _personUow.SaveChangesAsync(); // committed to persons schema | |
| 309 | +await _mediator.Publish(new PersonDeletedEvent(id)); | |
| 310 | + | |
| 311 | +// Orders module handler — runs after, separate DbContext | |
| 312 | +public async Task Handle(PersonDeletedEvent e, CancellationToken ct) | |
| 313 | +{ | |
| 314 | + await _orderRepo.CancelOrdersForPerson(e.PersonId); | |
| 315 | + await _orderUow.SaveChangesAsync(); // committed to orders schema | |
| 316 | + // if this fails, person is deleted but orders still exist — inconsistent | |
| 317 | +} | |
| 318 | + | |
| 319 | +Why not just use one database transaction | |
| 320 | + | |
| 321 | +Scenario One transaction works? Notes | |
| 322 | +Monolith, one DbContext Yes Just use SaveChangesAsync() | |
| 323 | +Modular monolith, same DB server Maybe TransactionScope works but couples modules | |
| 324 | +Modular monolith, separate DBs No Need events + outbox | |
| 325 | +Microservices No Need events + outbox + compensations | |
| 326 | +Third-party APIs (email, payment) No Can't roll back an email | |
| 327 | +The pragmatic answer: use immediate consistency as long as you can. One DbContext, one SaveChangesAsync. Only go eventual when the architecture forces it — separate modules, separate databases, external services. Don't adopt eventual consistency for the aesthetics. | |
| 328 | +Look into saga pattern for compensating transactions. | |
| No newline at end of file |