Commit
Initial commit: SplitApp REST API, Clean/Onion architecture
commit
453a840
429 changed files with +39269 and −0
Jump to a changed file
- .dockerignore +20 −0
- .env.example +13 −0
- .github/workflows/deploy.yml +53 −0
- .gitignore +88 −0
- .gitlab-ci.yml +11 −0
- DEPLOY.md +139 −0
- Dockerfile +28 −0
- LICENSE +21 −0
- README.md +310 −0
- SplitApp/App.BLL/App.BLL.csproj +20 −0
- SplitApp/App.BLL/DTO/AppUserBllDto.cs +11 −0
- SplitApp/App.BLL/DTO/BalanceBllDto.cs +10 −0
- SplitApp/App.BLL/DTO/BudgetCategoryBllDto.cs +21 −0
- SplitApp/App.BLL/DTO/CurrencyBllDto.cs +14 −0
- SplitApp/App.BLL/DTO/ExpenseBllDto.cs +33 −0
- SplitApp/App.BLL/DTO/ExpenseSplitBllDto.cs +18 −0
- SplitApp/App.BLL/DTO/SettlementPaymentBllDto.cs +26 −0
- SplitApp/App.BLL/DTO/SettlementPlanBllDto.cs +25 −0
- SplitApp/App.BLL/DTO/SplitPresetBllDto.cs +37 −0
- SplitApp/App.BLL/DTO/TripBllDto.cs +34 −0
- SplitApp/App.BLL/DTO/TripInvitationBllDto.cs +25 −0
- SplitApp/App.BLL/DTO/TripParticipantBllDto.cs +25 −0
- SplitApp/App.BLL/DTO/TripPollBllDto.cs +25 −0
- SplitApp/App.BLL/DTO/TripPollOptionBllDto.cs +17 −0
- SplitApp/App.BLL/DTO/TripWishlistItemBllDto.cs +33 −0
- SplitApp/App.BLL/Helpers/CurrencyConverter.cs +30 −0
- SplitApp/App.BLL/Mappers/BudgetCategoryBllDtoFactory.cs +35 −0
- SplitApp/App.BLL/Mappers/CurrencyBllDtoFactory.cs +28 −0
- SplitApp/App.BLL/Mappers/ExpenseBllDtoFactory.cs +80 −0
- SplitApp/App.BLL/Mappers/InvitationBllDtoFactory.cs +42 −0
- SplitApp/App.BLL/Mappers/PollBllDtoFactory.cs +75 −0
- SplitApp/App.BLL/Mappers/SettlementBllDtoFactory.cs +89 −0
- SplitApp/App.BLL/Mappers/SplitPresetBllDtoFactory.cs +67 −0
- SplitApp/App.BLL/Mappers/TripBllDtoFactory.cs +105 −0
- SplitApp/App.BLL/Mappers/WishlistBllDtoFactory.cs +56 −0
- SplitApp/App.BLL/Services/Admin/AdminDashboardData.cs +66 −0
- SplitApp/App.BLL/Services/Admin/AdminStatsService.cs +199 −0
- SplitApp/App.BLL/Services/Admin/BudgetCategoryAdminService.cs +76 −0
- SplitApp/App.BLL/Services/Admin/CurrencyAdminService.cs +68 −0
- SplitApp/App.BLL/Services/Admin/ExpenseAdminService.cs +95 −0
- SplitApp/App.BLL/Services/Admin/IAdminStatsService.cs +6 −0
- SplitApp/App.BLL/Services/Admin/IBudgetCategoryAdminService.cs +14 −0
- SplitApp/App.BLL/Services/Admin/ICurrencyAdminService.cs +13 −0
- SplitApp/App.BLL/Services/Admin/IExpenseAdminService.cs +18 −0
- SplitApp/App.BLL/Services/Admin/IInvitationAdminService.cs +15 −0
- SplitApp/App.BLL/Services/Admin/IPollAdminService.cs +16 −0
- SplitApp/App.BLL/Services/Admin/ISettlementPaymentAdminService.cs +15 −0
- SplitApp/App.BLL/Services/Admin/ISettlementPlanAdminService.cs +16 −0
- SplitApp/App.BLL/Services/Admin/ISplitPresetAdminService.cs +14 −0
- SplitApp/App.BLL/Services/Admin/ITripAdminService.cs +14 −0
- SplitApp/App.BLL/Services/Admin/ITripParticipantAdminService.cs +16 −0
- SplitApp/App.BLL/Services/Admin/IWishlistAdminService.cs +16 −0
- SplitApp/App.BLL/Services/Admin/InvitationAdminService.cs +77 −0
- SplitApp/App.BLL/Services/Admin/PollAdminService.cs +77 −0
- SplitApp/App.BLL/Services/Admin/SettlementPaymentAdminService.cs +78 −0
- SplitApp/App.BLL/Services/Admin/SettlementPlanAdminService.cs +76 −0
- SplitApp/App.BLL/Services/Admin/SplitPresetAdminService.cs +61 −0
- SplitApp/App.BLL/Services/Admin/TripAdminService.cs +67 −0
- SplitApp/App.BLL/Services/Admin/TripParticipantAdminService.cs +86 −0
- SplitApp/App.BLL/Services/Admin/WishlistAdminService.cs +83 −0
- SplitApp/App.BLL/Services/BudgetCategoryService.cs +80 −0
- SplitApp/App.BLL/Services/ExpenseService.cs +344 −0
- SplitApp/App.BLL/Services/IBudgetCategoryService.cs +14 −0
- SplitApp/App.BLL/Services/IExpenseService.cs +40 −0
- SplitApp/App.BLL/Services/IInvitationService.cs +26 −0
- SplitApp/App.BLL/Services/IPollService.cs +27 −0
- SplitApp/App.BLL/Services/ISettlementService.cs +42 −0
- SplitApp/App.BLL/Services/ISplitPresetService.cs +16 −0
- SplitApp/App.BLL/Services/ITripService.cs +38 −0
- SplitApp/App.BLL/Services/IWishlistService.cs +16 −0
- SplitApp/App.BLL/Services/Identity/IIdentityService.cs +9 −0
- SplitApp/App.BLL/Services/Identity/IdentityService.cs +266 −0
- SplitApp/App.BLL/Services/Identity/IdentityServiceResult.cs +63 −0
- SplitApp/App.BLL/Services/InvitationService.cs +189 −0
- SplitApp/App.BLL/Services/PollService.cs +203 −0
- SplitApp/App.BLL/Services/SettlementService.cs +331 −0
- SplitApp/App.BLL/Services/SplitPresetService.cs +120 −0
- SplitApp/App.BLL/Services/TripService.cs +236 −0
- SplitApp/App.BLL/Services/WishlistService.cs +145 −0
- SplitApp/App.DAL.EF/App.DAL.EF.csproj +20 −0
- SplitApp/App.DAL.EF/AppDbContext.cs +127 −0
- SplitApp/App.DAL.EF/AppUnitOfWork.cs +46 −0
- SplitApp/App.DAL.EF/Migrations/20260328145416_Initial.Designer.cs +1267 −0
- SplitApp/App.DAL.EF/Migrations/20260328145416_Initial.cs +961 −0
- SplitApp/App.DAL.EF/Migrations/20260328161224_AddBaseEntityTimestamps.Designer.cs +1375 −0
- SplitApp/App.DAL.EF/Migrations/20260328161224_AddBaseEntityTimestamps.cs +415 −0
- SplitApp/App.DAL.EF/Migrations/20260329141138_CurrencyNameToLangStr.Designer.cs +1375 −0
- SplitApp/App.DAL.EF/Migrations/20260329141138_CurrencyNameToLangStr.cs +38 −0
- SplitApp/App.DAL.EF/Migrations/20260402104505_RemoveUnusedBudgetCategoryTranslations.Designer.cs +1330 −0
- SplitApp/App.DAL.EF/Migrations/20260402104505_RemoveUnusedBudgetCategoryTranslations.cs +49 −0
- SplitApp/App.DAL.EF/Migrations/20260410202112_BudgetCategoryNameToLangStr.Designer.cs +1330 −0
- SplitApp/App.DAL.EF/Migrations/20260410202112_BudgetCategoryNameToLangStr.cs +38 −0
- SplitApp/App.DAL.EF/Migrations/AppDbContextModelSnapshot.cs +1327 −0
- SplitApp/App.DAL.EF/Repositories/BaseRepository.cs +28 −0
- SplitApp/App.DAL.EF/Repositories/BudgetCategoryRepository.cs +36 −0
- SplitApp/App.DAL.EF/Repositories/ExpenseRepository.cs +55 −0
- SplitApp/App.DAL.EF/Repositories/RefreshTokenRepository.cs +42 −0
- SplitApp/App.DAL.EF/Repositories/SettlementPaymentRepository.cs +30 −0
- SplitApp/App.DAL.EF/Repositories/SettlementPlanRepository.cs +55 −0
- SplitApp/App.DAL.EF/Repositories/SplitPresetRepository.cs +43 −0
- SplitApp/App.DAL.EF/Repositories/TripInvitationRepository.cs +42 −0
- SplitApp/App.DAL.EF/Repositories/TripParticipantRepository.cs +50 −0
- SplitApp/App.DAL.EF/Repositories/TripPollRepository.cs +52 −0
- SplitApp/App.DAL.EF/Repositories/TripRepository.cs +153 −0
- SplitApp/App.DAL.EF/Repositories/TripWishlistItemRepository.cs +40 −0
- SplitApp/App.DAL.EF/Repositories/UserRepository.cs +32 −0
- SplitApp/App.DAL.EF/Seeding/AppDataInit.cs +642 −0
- SplitApp/App.DAL.EF/Seeding/InitialData.cs +65 −0
- SplitApp/App.DAL.EF/ServiceCollectionExtensions.cs +33 −0
- SplitApp/App.DAL.EF/UtcDateTimeConverter.cs +14 −0
- SplitApp/App.DTO/App.DTO.csproj +14 −0
- SplitApp/App.DTO/Mappers/BudgetCategoryMapper.cs +21 −0
- SplitApp/App.DTO/Mappers/CurrencyMapper.cs +18 −0
- SplitApp/App.DTO/Mappers/ExpenseMapper.cs +35 −0
- SplitApp/App.DTO/Mappers/InvitationMapper.cs +21 −0
- SplitApp/App.DTO/Mappers/PollMapper.cs +29 −0
- SplitApp/App.DTO/Mappers/SettlementMapper.cs +36 −0
- SplitApp/App.DTO/Mappers/SplitPresetMapper.cs +27 −0
- SplitApp/App.DTO/Mappers/TripMapper.cs +52 −0
- SplitApp/App.DTO/Mappers/WishlistMapper.cs +29 −0
- SplitApp/App.DTO/v1/BalanceDto.cs +8 −0
- SplitApp/App.DTO/v1/BudgetCategoryCreateDto.cs +10 −0
- SplitApp/App.DTO/v1/BudgetCategoryDto.cs +12 −0
- SplitApp/App.DTO/v1/CurrencyDto.cs +9 −0
- SplitApp/App.DTO/v1/ExpenseCreateDto.cs +14 −0
- SplitApp/App.DTO/v1/ExpenseDto.cs +20 −0
- SplitApp/App.DTO/v1/ExpenseSplitCreateDto.cs +8 −0
- SplitApp/App.DTO/v1/ExpenseSplitDto.cs +10 −0
- SplitApp/App.DTO/v1/Identity/JWTResponse.cs +9 −0
- SplitApp/App.DTO/v1/Identity/LoginInfo.cs +7 −0
- SplitApp/App.DTO/v1/Identity/LogoutInfo.cs +6 −0
- SplitApp/App.DTO/v1/Identity/RegisterInfo.cs +9 −0
- SplitApp/App.DTO/v1/Identity/TokenRefreshInfo.cs +7 −0
- SplitApp/App.DTO/v1/InvitationCreateDto.cs +6 −0
- SplitApp/App.DTO/v1/InvitationDto.cs +12 −0
- SplitApp/App.DTO/v1/PollCreateDto.cs +10 −0
- SplitApp/App.DTO/v1/PollDto.cs +13 −0
- SplitApp/App.DTO/v1/PollOptionDto.cs +10 −0
- SplitApp/App.DTO/v1/RestApiErrorResponse.cs +9 −0
- SplitApp/App.DTO/v1/SettlementPaymentDto.cs +14 −0
- SplitApp/App.DTO/v1/SettlementPlanDto.cs +11 −0
- SplitApp/App.DTO/v1/SettlementSummaryDto.cs +7 −0
- SplitApp/App.DTO/v1/SplitPresetCreateDto.cs +16 −0
- SplitApp/App.DTO/v1/SplitPresetDto.cs +11 −0
- SplitApp/App.DTO/v1/SplitPresetMemberDto.cs +10 −0
- SplitApp/App.DTO/v1/TripCreateDto.cs +11 −0
- SplitApp/App.DTO/v1/TripDto.cs +18 −0
- SplitApp/App.DTO/v1/TripParticipantDto.cs +14 −0
- SplitApp/App.DTO/v1/TripUpdateDto.cs +13 −0
- SplitApp/App.DTO/v1/WishlistItemCreateDto.cs +13 −0
- SplitApp/App.DTO/v1/WishlistItemDto.cs +20 −0
- SplitApp/App.Domain/App.Domain.csproj +19 −0
- SplitApp/App.Domain/BudgetCategory.cs +25 −0
- SplitApp/App.Domain/Contracts/IAppUnitOfWork.cs +20 −0
- SplitApp/App.Domain/Contracts/IBudgetCategoryRepository.cs +8 −0
- SplitApp/App.Domain/Contracts/IExpenseRepository.cs +9 −0
- SplitApp/App.Domain/Contracts/IRefreshTokenRepository.cs +12 −0
- SplitApp/App.Domain/Contracts/ISettlementPaymentRepository.cs +7 −0
- SplitApp/App.Domain/Contracts/ISettlementPlanRepository.cs +14 −0
- SplitApp/App.Domain/Contracts/ISplitPresetRepository.cs +8 −0
- SplitApp/App.Domain/Contracts/ITripInvitationRepository.cs +9 −0
- SplitApp/App.Domain/Contracts/ITripParticipantRepository.cs +10 −0
- SplitApp/App.Domain/Contracts/ITripPollRepository.cs +9 −0
- SplitApp/App.Domain/Contracts/ITripRepository.cs +9 −0
- SplitApp/App.Domain/Contracts/ITripWishlistItemRepository.cs +8 −0
- SplitApp/App.Domain/Contracts/IUserRepository.cs +11 −0
- SplitApp/App.Domain/Currency.cs +19 −0
- SplitApp/App.Domain/EInvitationStatus.cs +10 −0
- SplitApp/App.Domain/EParticipantRole.cs +7 −0
- SplitApp/App.Domain/EPaymentStatus.cs +8 −0
- SplitApp/App.Domain/ESettlementStatus.cs +8 −0
- SplitApp/App.Domain/ESplitMethod.cs +9 −0
- SplitApp/App.Domain/ETripStatus.cs +9 −0
- SplitApp/App.Domain/EWishlistCategory.cs +9 −0
- SplitApp/App.Domain/EWishlistPriority.cs +8 −0
- SplitApp/App.Domain/Expense.cs +35 −0
- SplitApp/App.Domain/ExpenseSplit.cs +16 −0
- SplitApp/App.Domain/Identity/AppRefreshToken.cs +20 −0
- SplitApp/App.Domain/Identity/AppRole.cs +8 −0
- SplitApp/App.Domain/Identity/AppUser.cs +17 −0
- SplitApp/App.Domain/SettlementPayment.cs +29 −0
- SplitApp/App.Domain/SettlementPlan.cs +25 −0
- SplitApp/App.Domain/SplitPreset.cs +21 −0
- SplitApp/App.Domain/SplitPresetMember.cs +16 −0
- SplitApp/App.Domain/Trip.cs +54 −0
- SplitApp/App.Domain/TripInvitation.cs +22 −0
- SplitApp/App.Domain/TripParticipant.cs +30 −0
- SplitApp/App.Domain/TripPoll.cs +29 −0
- SplitApp/App.Domain/TripPollOption.cs +19 −0
- SplitApp/App.Domain/TripPollVote.cs +13 −0
- SplitApp/App.Domain/TripWishlistItem.cs +47 −0
- SplitApp/App.Domain/TripWishlistVote.cs +15 −0
- SplitApp/App.Resources/App.Resources.csproj +132 −0
- SplitApp/App.Resources/Common.Designer.cs +72 −0
- SplitApp/App.Resources/Common.et.resx +46 −0
- SplitApp/App.Resources/Common.resx +46 −0
- SplitApp/App.Resources/Domain/BudgetCategory.Designer.cs +72 −0
- SplitApp/App.Resources/Domain/BudgetCategory.et.resx +46 −0
- SplitApp/App.Resources/Domain/BudgetCategory.resx +46 −0
- SplitApp/App.Resources/Domain/Currency.Designer.cs +66 −0
- SplitApp/App.Resources/Domain/Currency.et.resx +45 −0
- SplitApp/App.Resources/Domain/Currency.resx +45 −0
- SplitApp/App.Resources/Domain/Enums.Designer.cs +216 −0
- SplitApp/App.Resources/Domain/Enums.et.resx +70 −0
- SplitApp/App.Resources/Domain/Enums.resx +70 −0
- SplitApp/App.Resources/Domain/Expense.Designer.cs +90 −0
- SplitApp/App.Resources/Domain/Expense.et.resx +49 −0
- SplitApp/App.Resources/Domain/Expense.resx +49 −0
- SplitApp/App.Resources/Domain/SettlementPayment.Designer.cs +72 −0
- SplitApp/App.Resources/Domain/SettlementPayment.et.resx +46 −0
- SplitApp/App.Resources/Domain/SettlementPayment.resx +46 −0
- SplitApp/App.Resources/Domain/SettlementPlan.Designer.cs +66 −0
- SplitApp/App.Resources/Domain/SettlementPlan.et.resx +45 −0
- SplitApp/App.Resources/Domain/SettlementPlan.resx +45 −0
- SplitApp/App.Resources/Domain/Trip.Designer.cs +96 −0
- SplitApp/App.Resources/Domain/Trip.et.resx +50 −0
- SplitApp/App.Resources/Domain/Trip.resx +50 −0
- SplitApp/App.Resources/Domain/TripParticipant.Designer.cs +78 −0
- SplitApp/App.Resources/Domain/TripParticipant.et.resx +47 −0
- SplitApp/App.Resources/Domain/TripParticipant.resx +47 −0
- SplitApp/App.Resources/Domain/TripPoll.Designer.cs +72 −0
- SplitApp/App.Resources/Domain/TripPoll.et.resx +46 −0
- SplitApp/App.Resources/Domain/TripPoll.resx +46 −0
- SplitApp/App.Resources/Domain/TripPollOption.Designer.cs +60 −0
- SplitApp/App.Resources/Domain/TripPollOption.et.resx +44 −0
- SplitApp/App.Resources/Domain/TripPollOption.resx +44 −0
- SplitApp/App.Resources/Domain/TripWishlistItem.Designer.cs +102 −0
- SplitApp/App.Resources/Domain/TripWishlistItem.et.resx +51 −0
- SplitApp/App.Resources/Domain/TripWishlistItem.resx +51 −0
- SplitApp/App.Resources/Views/Shared.Designer.cs +240 −0
- SplitApp/App.Resources/Views/Shared.et.resx +516 −0
- SplitApp/App.Resources/Views/Shared.resx +516 −0
- SplitApp/App.Tests/App.Tests.csproj +32 −0
- SplitApp/App.Tests/BLL/BudgetCategoryServiceTests.cs +75 −0
- SplitApp/App.Tests/BLL/CurrencyConverterTests.cs +31 −0
- SplitApp/App.Tests/BLL/ExpenseServiceTests.cs +84 −0
- SplitApp/App.Tests/BLL/SettlementServiceTests.cs +70 −0
- SplitApp/App.Tests/BLL/TripServiceTests.cs +111 −0
- SplitApp/App.Tests/BLL/WishlistServiceTests.cs +72 −0
- SplitApp/App.Tests/DAL/BudgetCategoryRepositoryTests.cs +31 −0
- SplitApp/App.Tests/DAL/ExpenseRepositoryTests.cs +36 −0
- SplitApp/App.Tests/DAL/TripInvitationRepositoryTests.cs +44 −0
- SplitApp/App.Tests/DAL/TripParticipantRepositoryTests.cs +56 −0
- SplitApp/App.Tests/DAL/TripRepositoryTests.cs +41 −0
- SplitApp/App.Tests/Domain/LangStrTests.cs +58 −0
- SplitApp/App.Tests/Domain/TripValidationTests.cs +46 −0
- SplitApp/App.Tests/E2E/MvcPagesE2ETests.cs +52 −0
- SplitApp/App.Tests/GlobalTestInit.cs +24 −0
- SplitApp/App.Tests/Integration/AccountApiIntegrationTests.cs +111 −0
- SplitApp/App.Tests/Integration/WebApiTestFactory.cs +100 −0
- SplitApp/App.Tests/Mappers/BudgetCategoryBllDtoFactoryTests.cs +56 −0
- SplitApp/App.Tests/Mappers/CurrencyBllDtoFactoryTests.cs +33 −0
- SplitApp/App.Tests/Mappers/ExpenseBllDtoFactoryTests.cs +37 −0
- SplitApp/App.Tests/Mappers/TripBllDtoFactoryTests.cs +65 −0
- SplitApp/App.Tests/RepositoryTestBase.cs +71 −0
- SplitApp/App.Tests/SanityTest.cs +12 −0
- SplitApp/Base.Contracts/Base.Contracts.csproj +9 −0
- SplitApp/Base.Contracts/IBaseEntity.cs +6 −0
- SplitApp/Base.Contracts/IBaseRepository.cs +11 −0
- SplitApp/Base.Contracts/IUnitOfWork.cs +6 −0
- SplitApp/Base.Domain/Base.Domain.csproj +13 −0
- SplitApp/Base.Domain/BaseEntity.cs +10 −0
- SplitApp/Base.Domain/LangStr.cs +80 −0
- SplitApp/Base.Helpers/Base.Helpers.csproj +13 −0
- SplitApp/Base.Helpers/IdentityHelpers.cs +59 −0
- SplitApp/Directory.Build.Props +7 −0
- SplitApp/SplitApp.sln +157 −0
- SplitApp/WebApp/ApiControllers/BudgetCategoriesController.cs +131 −0
- SplitApp/WebApp/ApiControllers/CurrenciesController.cs +35 −0
- SplitApp/WebApp/ApiControllers/ExpensesController.cs +187 −0
- SplitApp/WebApp/ApiControllers/Identity/AccountController.cs +145 −0
- SplitApp/WebApp/ApiControllers/InvitationsController.cs +137 −0
- SplitApp/WebApp/ApiControllers/PollsController.cs +181 −0
- SplitApp/WebApp/ApiControllers/SettlementsController.cs +153 −0
- SplitApp/WebApp/ApiControllers/SplitPresetsController.cs +144 −0
- SplitApp/WebApp/ApiControllers/TripsController.cs +243 −0
- SplitApp/WebApp/ApiControllers/WishlistController.cs +188 −0
- SplitApp/WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +143 −0
- SplitApp/WebApp/Areas/Admin/Controllers/CurrenciesController.cs +130 −0
- SplitApp/WebApp/Areas/Admin/Controllers/DashboardController.cs +88 −0
- SplitApp/WebApp/Areas/Admin/Controllers/ExpensesController.cs +145 −0
- SplitApp/WebApp/Areas/Admin/Controllers/InvitationsController.cs +154 −0
- SplitApp/WebApp/Areas/Admin/Controllers/PollsController.cs +125 −0
- SplitApp/WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +149 −0
- SplitApp/WebApp/Areas/Admin/Controllers/SettlementPlansController.cs +183 −0
- SplitApp/WebApp/Areas/Admin/Controllers/SplitPresetsController.cs +111 −0
- SplitApp/WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +184 −0
- SplitApp/WebApp/Areas/Admin/Controllers/TripsController.cs +136 −0
- SplitApp/WebApp/Areas/Admin/Controllers/UsersController.cs +197 −0
- SplitApp/WebApp/Areas/Admin/Controllers/WishlistController.cs +125 −0
- SplitApp/WebApp/Areas/Admin/Models/AdminViewModels.cs +287 −0
- SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Create.cshtml +55 −0
- SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Delete.cshtml +26 −0
- SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Details.cshtml +29 −0
- SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Edit.cshtml +56 −0
- SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Index.cshtml +78 −0
- SplitApp/WebApp/Areas/Admin/Views/Currencies/Create.cshtml +45 −0
- SplitApp/WebApp/Areas/Admin/Views/Currencies/Delete.cshtml +26 −0
- SplitApp/WebApp/Areas/Admin/Views/Currencies/Details.cshtml +23 −0
- SplitApp/WebApp/Areas/Admin/Views/Currencies/Edit.cshtml +46 −0
- SplitApp/WebApp/Areas/Admin/Views/Currencies/Index.cshtml +67 −0
- SplitApp/WebApp/Areas/Admin/Views/Dashboard/Index.cshtml +373 −0
- SplitApp/WebApp/Areas/Admin/Views/Expenses/Create.cshtml +71 −0
- SplitApp/WebApp/Areas/Admin/Views/Expenses/Delete.cshtml +32 −0
- SplitApp/WebApp/Areas/Admin/Views/Expenses/Details.cshtml +38 −0
- SplitApp/WebApp/Areas/Admin/Views/Expenses/Edit.cshtml +72 −0
- SplitApp/WebApp/Areas/Admin/Views/Expenses/Index.cshtml +82 −0
- SplitApp/WebApp/Areas/Admin/Views/Invitations/Create.cshtml +58 −0
- SplitApp/WebApp/Areas/Admin/Views/Invitations/Delete.cshtml +40 −0
- SplitApp/WebApp/Areas/Admin/Views/Invitations/Details.cshtml +41 −0
- SplitApp/WebApp/Areas/Admin/Views/Invitations/Edit.cshtml +59 −0
- SplitApp/WebApp/Areas/Admin/Views/Invitations/Index.cshtml +67 −0
- SplitApp/WebApp/Areas/Admin/Views/Polls/Create.cshtml +48 −0
- SplitApp/WebApp/Areas/Admin/Views/Polls/Delete.cshtml +14 −0
- SplitApp/WebApp/Areas/Admin/Views/Polls/Details.cshtml +38 −0
- SplitApp/WebApp/Areas/Admin/Views/Polls/Edit.cshtml +45 −0
- SplitApp/WebApp/Areas/Admin/Views/Polls/Index.cshtml +75 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Create.cshtml +55 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Delete.cshtml +40 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Details.cshtml +44 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Edit.cshtml +56 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Index.cshtml +69 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Create.cshtml +52 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Delete.cshtml +29 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Details.cshtml +29 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Edit.cshtml +53 −0
- SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Index.cshtml +75 −0
- SplitApp/WebApp/Areas/Admin/Views/Shared/_Layout.cshtml +128 −0
- SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Create.cshtml +52 −0
- SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Delete.cshtml +29 −0
- SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Details.cshtml +51 −0
- SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Index.cshtml +66 −0
- SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Create.cshtml +53 −0
- SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Delete.cshtml +29 −0
- SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Details.cshtml +35 −0
- SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Edit.cshtml +54 −0
- SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Index.cshtml +80 −0
- SplitApp/WebApp/Areas/Admin/Views/Trips/Create.cshtml +62 −0
- SplitApp/WebApp/Areas/Admin/Views/Trips/Delete.cshtml +29 −0
- SplitApp/WebApp/Areas/Admin/Views/Trips/Details.cshtml +38 −0
- SplitApp/WebApp/Areas/Admin/Views/Trips/Edit.cshtml +64 −0
- SplitApp/WebApp/Areas/Admin/Views/Trips/Index.cshtml +71 −0
- SplitApp/WebApp/Areas/Admin/Views/Users/Delete.cshtml +44 −0
- SplitApp/WebApp/Areas/Admin/Views/Users/Details.cshtml +44 −0
- SplitApp/WebApp/Areas/Admin/Views/Users/Edit.cshtml +44 −0
- SplitApp/WebApp/Areas/Admin/Views/Users/EditRoles.cshtml +29 −0
- SplitApp/WebApp/Areas/Admin/Views/Users/Index.cshtml +46 −0
- SplitApp/WebApp/Areas/Admin/Views/Wishlist/Create.cshtml +64 −0
- SplitApp/WebApp/Areas/Admin/Views/Wishlist/Delete.cshtml +14 −0
- SplitApp/WebApp/Areas/Admin/Views/Wishlist/Details.cshtml +36 −0
- SplitApp/WebApp/Areas/Admin/Views/Wishlist/Edit.cshtml +57 −0
- SplitApp/WebApp/Areas/Admin/Views/Wishlist/Index.cshtml +73 −0
- SplitApp/WebApp/Areas/Admin/Views/_ViewImports.cshtml +8 −0
- SplitApp/WebApp/Areas/Admin/Views/_ViewStart.cshtml +3 −0
- SplitApp/WebApp/Areas/Identity/Pages/Account/Register.cshtml +59 −0
- SplitApp/WebApp/Areas/Identity/Pages/Account/Register.cshtml.cs +93 −0
- SplitApp/WebApp/Areas/Identity/Pages/_ViewImports.cshtml +5 −0
- SplitApp/WebApp/Areas/Identity/Pages/_ViewStart.cshtml +3 −0
- SplitApp/WebApp/ConfigureSwaggerOptions.cs +64 −0
- SplitApp/WebApp/Controllers/BudgetController.cs +208 −0
- SplitApp/WebApp/Controllers/ExpensesController.cs +266 −0
- SplitApp/WebApp/Controllers/HomeController.cs +35 −0
- SplitApp/WebApp/Controllers/MembersController.cs +177 −0
- SplitApp/WebApp/Controllers/PollsClientController.cs +144 −0
- SplitApp/WebApp/Controllers/SettlementController.cs +190 −0
- SplitApp/WebApp/Controllers/TripsController.cs +258 −0
- SplitApp/WebApp/Controllers/WishlistClientController.cs +277 −0
- SplitApp/WebApp/Helpers/CurrencyConverter.cs +30 −0
- SplitApp/WebApp/Helpers/EnumHelper.cs +15 −0
- SplitApp/WebApp/InvariantDecimalModelBinderProvider.cs +56 −0
- SplitApp/WebApp/Models/ErrorViewModel.cs +8 −0
- SplitApp/WebApp/Program.cs +295 −0
- SplitApp/WebApp/Properties/launchSettings.json +23 −0
- SplitApp/WebApp/Views/Budget/CreateCategory.cshtml +71 −0
- SplitApp/WebApp/Views/Budget/DeleteCategory.cshtml +51 −0
- SplitApp/WebApp/Views/Budget/EditCategory.cshtml +72 −0
- SplitApp/WebApp/Views/Budget/Index.cshtml +155 −0
- SplitApp/WebApp/Views/Expenses/Create.cshtml +365 −0
- SplitApp/WebApp/Views/Expenses/Delete.cshtml +51 −0
- SplitApp/WebApp/Views/Expenses/Edit.cshtml +84 −0
- SplitApp/WebApp/Views/Expenses/Index.cshtml +111 −0
- SplitApp/WebApp/Views/Home/Index.cshtml +116 −0
- SplitApp/WebApp/Views/Home/Privacy.cshtml +6 −0
- SplitApp/WebApp/Views/Members/AcceptInvitation.cshtml +60 −0
- SplitApp/WebApp/Views/Members/Index.cshtml +125 −0
- SplitApp/WebApp/Views/Members/InvitationInvalid.cshtml +24 −0
- SplitApp/WebApp/Views/Members/Invite.cshtml +34 −0
- SplitApp/WebApp/Views/Members/InviteGenerated.cshtml +40 −0
- SplitApp/WebApp/Views/PollsClient/Create.cshtml +83 −0
- SplitApp/WebApp/Views/PollsClient/Details.cshtml +126 −0
- SplitApp/WebApp/Views/PollsClient/Index.cshtml +99 −0
- SplitApp/WebApp/Views/Settlement/Index.cshtml +232 −0
- SplitApp/WebApp/Views/Shared/Error.cshtml +29 −0
- SplitApp/WebApp/Views/Shared/_LanguageSelection.cshtml +24 −0
- SplitApp/WebApp/Views/Shared/_Layout.cshtml +116 −0
- SplitApp/WebApp/Views/Shared/_Layout.cshtml.css +48 −0
- SplitApp/WebApp/Views/Shared/_LoginPartial.cshtml +50 −0
- SplitApp/WebApp/Views/Shared/_ValidationScriptsPartial.cshtml +2 −0
- SplitApp/WebApp/Views/Trips/Create.cshtml +82 −0
- SplitApp/WebApp/Views/Trips/Delete.cshtml +56 −0
- SplitApp/WebApp/Views/Trips/Details.cshtml +291 −0
- SplitApp/WebApp/Views/Trips/Edit.cshtml +87 −0
- SplitApp/WebApp/Views/Trips/Index.cshtml +104 −0
- SplitApp/WebApp/Views/WishlistClient/Create.cshtml +85 −0
- SplitApp/WebApp/Views/WishlistClient/Delete.cshtml +45 −0
- SplitApp/WebApp/Views/WishlistClient/Edit.cshtml +77 −0
- SplitApp/WebApp/Views/WishlistClient/Index.cshtml +147 −0
- SplitApp/WebApp/Views/_ViewImports.cshtml +8 −0
- SplitApp/WebApp/Views/_ViewStart.cshtml +3 −0
- SplitApp/WebApp/WebApp.csproj +32 −0
- SplitApp/WebApp/appsettings.json +27 −0
- SplitApp/WebApp/wwwroot/css/admin.css +439 −0
- SplitApp/WebApp/wwwroot/css/site.css +31 −0
- SplitApp/WebApp/wwwroot/css/splitapp-design.css +1605 −0
- SplitApp/WebApp/wwwroot/favicon.ico +0 −0
- SplitApp/WebApp/wwwroot/js/site.js +4 −0
- SplitApp/WebApp/wwwroot/js/splitapp.js +323 −0
- SplitApp/WebApp/wwwroot/lib/bootstrap/LICENSE +22 −0
- SplitApp/WebApp/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt +23 −0
- SplitApp/WebApp/wwwroot/lib/jquery-validation/LICENSE.md +22 −0
- SplitApp/WebApp/wwwroot/lib/jquery/LICENSE.txt +21 −0
- architecture.md +137 −0
- arhitektuur.md +181 −0
- docker-compose.prod.yml +57 −0
- docker-compose.yml +27 −0
- docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0
- docs/grouptravel.png +0 −0
- explanation.md +729 −0
- testing-plan.md +188 −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 .env.example +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +# Copy to .env and fill with strong values. NEVER commit .env. | |
| 2 | +# On the server, create .env in the repo root next to docker-compose.prod.yml. | |
| 3 | + | |
| 4 | +# Postgres password (db is not publicly exposed, but use a strong value anyway). | |
| 5 | +POSTGRES_PASSWORD=change-me-strong-db-password | |
| 6 | + | |
| 7 | +# JWT signing key — overrides JWT:Key in appsettings.json. Generate fresh: | |
| 8 | +# openssl rand -base64 48 | |
| 9 | +JWT_KEY=change-me-base64-key | |
| 10 | + | |
| 11 | +# Password for the seeded admin@taltech.ee account (first boot only). | |
| 12 | +# Must satisfy: upper + lower + digit + non-alphanumeric, length >= 6. | |
| 13 | +SEED_ADMIN_PASSWORD=Change.Me123! |
added .github/workflows/deploy.yml +53 −0
| @@ -0,0 +1,53 @@ | ||
| 1 | +name: deploy | |
| 2 | + | |
| 3 | +on: | |
| 4 | + push: | |
| 5 | + branches: [main] | |
| 6 | + workflow_dispatch: {} # allow manual runs from the Actions tab | |
| 7 | + | |
| 8 | +concurrency: | |
| 9 | + group: deploy | |
| 10 | + cancel-in-progress: false | |
| 11 | + | |
| 12 | +env: | |
| 13 | + IMAGE: ghcr.io/rasmusjy/cswebtravel | |
| 14 | + | |
| 15 | +jobs: | |
| 16 | + build-and-push: | |
| 17 | + runs-on: ubuntu-latest | |
| 18 | + permissions: | |
| 19 | + contents: read | |
| 20 | + packages: write # push to GHCR via GITHUB_TOKEN | |
| 21 | + steps: | |
| 22 | + - uses: actions/checkout@v4 | |
| 23 | + | |
| 24 | + - uses: docker/setup-buildx-action@v3 | |
| 25 | + | |
| 26 | + - uses: docker/login-action@v3 | |
| 27 | + with: | |
| 28 | + registry: ghcr.io | |
| 29 | + username: ${{ github.actor }} | |
| 30 | + password: ${{ secrets.GITHUB_TOKEN }} | |
| 31 | + | |
| 32 | + - uses: docker/build-push-action@v6 | |
| 33 | + with: | |
| 34 | + context: . | |
| 35 | + push: true | |
| 36 | + tags: | | |
| 37 | + ${{ env.IMAGE }}:latest | |
| 38 | + ${{ env.IMAGE }}:${{ github.sha }} | |
| 39 | + cache-from: type=gha | |
| 40 | + cache-to: type=gha,mode=max | |
| 41 | + | |
| 42 | + deploy: | |
| 43 | + needs: build-and-push | |
| 44 | + runs-on: ubuntu-latest | |
| 45 | + steps: | |
| 46 | + - name: Pull & restart on the VPS | |
| 47 | + run: | | |
| 48 | + mkdir -p ~/.ssh | |
| 49 | + echo "${{ secrets.SSH_KEY }}" | base64 -d > ~/.ssh/id_deploy | |
| 50 | + chmod 600 ~/.ssh/id_deploy | |
| 51 | + ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ | |
| 52 | + -i ~/.ssh/id_deploy "${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }}" \ | |
| 53 | + 'cd /opt/projects/csweb-travel && git pull --ff-only && docker compose -f docker-compose.prod.yml pull app && docker compose -f docker-compose.prod.yml up -d && docker image prune -f' |
added .gitignore +88 −0
| @@ -0,0 +1,88 @@ | ||
| 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 | +!.env.example | |
| 78 | +appsettings.Development.json | |
| 79 | + | |
| 80 | +## Docker | |
| 81 | +docker-compose.override.yml | |
| 82 | + | |
| 83 | +## Node (if applicable) | |
| 84 | +node_modules/ | |
| 85 | +npm-debug.log* | |
| 86 | + | |
| 87 | +## Publish output | |
| 88 | +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 backend up --build --remove-orphans --detach |
added DEPLOY.md +139 −0
| @@ -0,0 +1,139 @@ | ||
| 1 | +# Deploy — travel.rasmusj.com | |
| 2 | + | |
| 3 | +Production deploy on the Hetzner VPS. Caddy (already running in `/opt/caddy/`) terminates | |
| 4 | +HTTPS and reverse-proxies to this app's container over the shared `web` Docker network. | |
| 5 | + | |
| 6 | +``` | |
| 7 | +Internet → Caddy (:80/:443, HTTPS) → [web network] → csweb-travel (:8080) | |
| 8 | + ↓ [internal network] | |
| 9 | + db (Postgres, no public port) | |
| 10 | +``` | |
| 11 | + | |
| 12 | +## Files in this repo | |
| 13 | + | |
| 14 | +| File | Purpose | | |
| 15 | +|------|---------| | |
| 16 | +| `Dockerfile` | .NET 10 SDK build → aspnet runtime. Source stays under `SplitApp/`, so COPY paths are unchanged. | | |
| 17 | +| `docker-compose.prod.yml` | Production stack. No public ports; secrets from `.env`. | | |
| 18 | +| `.env.example` | Template. Copy to `.env` and fill with strong values. | | |
| 19 | +| `.env` | Real secrets — **gitignored, never commit.** Create on the server. | | |
| 20 | + | |
| 21 | +## Stage 1 — manual first deploy | |
| 22 | + | |
| 23 | +### 1. Deploy key (read-only access to the private repo) | |
| 24 | + | |
| 25 | +```bash | |
| 26 | +ssh <user>@<server-ip> | |
| 27 | +ssh-keygen -t ed25519 -C "csweb-travel-deploy" -f ~/.ssh/csweb-travel -N "" | |
| 28 | +cat ~/.ssh/csweb-travel.pub | |
| 29 | +``` | |
| 30 | + | |
| 31 | +Add the printed public key to the GitHub repo → **Settings → Deploy keys → Add deploy key** | |
| 32 | +(read-only is enough). Then make git use it: | |
| 33 | + | |
| 34 | +```bash | |
| 35 | +cat >> ~/.ssh/config <<'EOF' | |
| 36 | + | |
| 37 | +Host github-csweb-travel | |
| 38 | + HostName github.com | |
| 39 | + User git | |
| 40 | + IdentityFile ~/.ssh/csweb-travel | |
| 41 | + IdentitiesOnly yes | |
| 42 | +EOF | |
| 43 | +``` | |
| 44 | + | |
| 45 | +### 2. Clone | |
| 46 | + | |
| 47 | +```bash | |
| 48 | +cd /opt/projects | |
| 49 | +git clone git@github-csweb-travel:<your-user>/<repo>.git csweb-travel | |
| 50 | +cd csweb-travel | |
| 51 | +``` | |
| 52 | + | |
| 53 | +### 3. Secrets | |
| 54 | + | |
| 55 | +Create `.env` in the repo root. Use the values prepared locally, or regenerate: | |
| 56 | + | |
| 57 | +```bash | |
| 58 | +cat > .env <<EOF | |
| 59 | +POSTGRES_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-32) | |
| 60 | +JWT_KEY=$(openssl rand -base64 48 | tr -d '\n') | |
| 61 | +SEED_ADMIN_PASSWORD=Adm!n.$(openssl rand -base64 9 | tr -d '/+=')9 | |
| 62 | +EOF | |
| 63 | +chmod 600 .env | |
| 64 | +cat .env # note the SEED_ADMIN_PASSWORD — that's the admin@taltech.ee login | |
| 65 | +``` | |
| 66 | + | |
| 67 | +### 4. Build & start | |
| 68 | + | |
| 69 | +```bash | |
| 70 | +docker compose -f docker-compose.prod.yml up -d --build | |
| 71 | +docker compose -f docker-compose.prod.yml logs -f app # watch migrate + seed | |
| 72 | +``` | |
| 73 | + | |
| 74 | +> ⚠️ The .NET build is the heaviest step. On a 4 GB server it should fit, but if the build | |
| 75 | +> gets OOM-killed, add temporary swap: | |
| 76 | +> `sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile` | |
| 77 | + | |
| 78 | +Confirm the container is on the `web` network and healthy: | |
| 79 | + | |
| 80 | +```bash | |
| 81 | +docker network inspect web --format '{{range .Containers}}{{.Name}} {{end}}' | |
| 82 | +# should list: caddy ... csweb-travel | |
| 83 | +``` | |
| 84 | + | |
| 85 | +### 5. Caddy | |
| 86 | + | |
| 87 | +Append to `/opt/caddy/Caddyfile`: | |
| 88 | + | |
| 89 | +``` | |
| 90 | +travel.rasmusj.com { | |
| 91 | + reverse_proxy csweb-travel:8080 | |
| 92 | +} | |
| 93 | +``` | |
| 94 | + | |
| 95 | +> Note: target is `csweb-travel` (the container_name), not `web`. A unique name avoids | |
| 96 | +> DNS collisions once other apps join the shared `web` network. | |
| 97 | + | |
| 98 | +Reload (no downtime for other sites): | |
| 99 | + | |
| 100 | +```bash | |
| 101 | +cd /opt/caddy | |
| 102 | +docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile | |
| 103 | +# or, if the Caddyfile is bind-mounted and that fails: | |
| 104 | +docker compose restart caddy | |
| 105 | +``` | |
| 106 | + | |
| 107 | +### 6. Test | |
| 108 | + | |
| 109 | +```bash | |
| 110 | +curl -I https://travel.rasmusj.com # expect 200/302/308, valid Let's Encrypt cert | |
| 111 | +``` | |
| 112 | + | |
| 113 | +Open https://travel.rasmusj.com and log in: `admin@taltech.ee` / `<SEED_ADMIN_PASSWORD from .env>`. | |
| 114 | + | |
| 115 | +## Updating later | |
| 116 | + | |
| 117 | +```bash | |
| 118 | +cd /opt/projects/csweb-travel | |
| 119 | +git pull | |
| 120 | +docker compose -f docker-compose.prod.yml up -d --build | |
| 121 | +``` | |
| 122 | + | |
| 123 | +Migrations run automatically on boot (`MigrateDatabase=true`). Seeding is idempotent | |
| 124 | +(guards on existing rows), so it won't duplicate data on restart. | |
| 125 | + | |
| 126 | +## Security notes | |
| 127 | + | |
| 128 | +- **DB password & JWT key** live only in `.env` (gitignored) and are injected as env vars. | |
| 129 | + `JWT__Key` overrides the placeholder in `appsettings.json`. | |
| 130 | +- **Seed admin password** is read from `SEED_ADMIN_PASSWORD` on first boot | |
| 131 | + (`AppDataInit.SeedIdentity`). This replaces the well-known course default `Kala.12345`, | |
| 132 | + so the public admin login is strong from the very first deploy. | |
| 133 | +- The other seeded demo accounts (`user@`, `alice@`, …) still use `Kala.12345`. They are | |
| 134 | + non-admin demo users. Remove them from `InitialData.cs` if you don't want demo logins. | |
| 135 | +- Postgres has **no published port** — it's reachable only by the app over `internal`. | |
| 136 | +- To rotate any secret: edit `.env`, then `docker compose -f docker-compose.prod.yml up -d`. | |
| 137 | + (Changing `SEED_ADMIN_PASSWORD` after first boot has no effect — the user already exists; | |
| 138 | + change that password through the app instead.) | |
| 139 | +``` |
added Dockerfile +28 −0
| @@ -0,0 +1,28 @@ | ||
| 1 | +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build | |
| 2 | +WORKDIR /src | |
| 3 | + | |
| 4 | +# Copy solution and project files for restore | |
| 5 | +COPY SplitApp/*.sln . | |
| 6 | +COPY SplitApp/Directory.Build.Props . | |
| 7 | +COPY SplitApp/Base.Contracts/*.csproj Base.Contracts/ | |
| 8 | +COPY SplitApp/Base.Domain/*.csproj Base.Domain/ | |
| 9 | +COPY SplitApp/Base.Helpers/*.csproj Base.Helpers/ | |
| 10 | +COPY SplitApp/App.Domain/*.csproj App.Domain/ | |
| 11 | +COPY SplitApp/App.DAL.EF/*.csproj App.DAL.EF/ | |
| 12 | +COPY SplitApp/App.DTO/*.csproj App.DTO/ | |
| 13 | +COPY SplitApp/App.BLL/*.csproj App.BLL/ | |
| 14 | +COPY SplitApp/App.Resources/*.csproj App.Resources/ | |
| 15 | +COPY SplitApp/WebApp/*.csproj WebApp/ | |
| 16 | +COPY SplitApp/App.Tests/*.csproj App.Tests/ | |
| 17 | + | |
| 18 | +RUN dotnet restore | |
| 19 | + | |
| 20 | +# Copy everything else and publish | |
| 21 | +COPY SplitApp/ . | |
| 22 | +RUN dotnet publish WebApp/WebApp.csproj -c Release -o /app/publish | |
| 23 | + | |
| 24 | +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime | |
| 25 | +WORKDIR /app | |
| 26 | +COPY --from=build /app/publish . | |
| 27 | +EXPOSE 8080 | |
| 28 | +ENTRYPOINT ["dotnet", "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 +310 −0
| @@ -0,0 +1,310 @@ | ||
| 1 | +# SplitApp — Trip Expense Management | |
| 2 | + | |
| 3 | +URL: https://travel.rasmusj.com/ | |
| 4 | +Front: https://travel.rasmusj.com/ | |
| 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, manage budgets, run polls, maintain a wishlist, and settle debts via an optimized algorithm. | |
| 6 | + | |
| 7 | +Built as a **Personal Project — Phase 2** for the TalTech "Web Applications with C#" course (Phase 1 + full Clean/Onion architecture compliance with mandatory Repositories, UoW, Services, BLL DTOs, and Mappers). | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## Architecture at a glance | |
| 12 | + | |
| 13 | +**Clean / Onion Architecture with strict 3-tier DTOs.** Dependencies point inward toward `App.Domain`. Interfaces live in the Domain layer (`App.Domain/Contracts/`), and `App.DAL.EF` is a plugin that implements them. `App.BLL` (application services) depends only on Domain abstractions and exposes **BLL DTOs** at its boundary — controllers never see Domain entities. `App.DTO` (Public DTOs) sits at the outer edge and maps from BLL DTOs to versioned API contracts. | |
| 14 | + | |
| 15 | +``` | |
| 16 | +WebApp (MVC + API + Admin) | |
| 17 | + │ uses only App.BLL services + App.DTO public mappers | |
| 18 | + │ ── controllers see ONLY BLL DTOs and Public DTOs, NEVER Domain entities | |
| 19 | + ▼ | |
| 20 | +App.DTO (v1 Public DTOs + Mappers/ — BLL DTO ↔ Public DTO) | |
| 21 | + │ references App.BLL (so it can map BllDto → public DTO) | |
| 22 | + ▼ | |
| 23 | +App.BLL (Services: Trip, Expense, Settlement, Invitation, Poll, BudgetCategory, | |
| 24 | + │ Wishlist, SplitPreset, Identity, + 12 Admin services) | |
| 25 | + │ (DTO/ — TripBllDto, ExpenseBllDto, AppUserBllDto, …) | |
| 26 | + │ (Mappers/ — Domain ↔ BLL DTO factory mappers) | |
| 27 | + │ depends on App.Domain contracts only | |
| 28 | + ▼ | |
| 29 | +App.Domain — POCO entities + Contracts/ (IAppUnitOfWork + 12 repository interfaces) | |
| 30 | + ▲ ▲ | |
| 31 | + │ implements │ | |
| 32 | + │ │ | |
| 33 | +App.DAL.EF (AppDbContext, AppUnitOfWork, Repositories, Migrations) | |
| 34 | + Plugin — sits OUTSIDE Domain; Program.cs wires it via AddDalServices() | |
| 35 | +``` | |
| 36 | + | |
| 37 | +**Key Phase 2 properties:** | |
| 38 | + | |
| 39 | +- **Three DTO tiers** as required by the BLL lecture: | |
| 40 | + 1. **Domain entity** (`App.Domain/Trip.cs`) — POCO, EF-friendly, owns business validation | |
| 41 | + 2. **BLL DTO** (`App.BLL/DTO/TripBllDto.cs`) — internal application boundary | |
| 42 | + 3. **Public DTO** (`App.DTO/v1/TripDto.cs`) — versioned external API contract | |
| 43 | +- **Two mapping layers** at each boundary: | |
| 44 | + - `App.BLL/Mappers/*BllDtoFactory.cs` — Domain ↔ BLL DTO (factory pattern as recommended by lecture) | |
| 45 | + - `App.DTO/Mappers/*Mapper.cs` — BLL DTO ↔ Public DTO | |
| 46 | +- `App.BLL.csproj` does **not** reference `App.DAL.EF` — dependency inversion via Domain contracts | |
| 47 | +- `App.BLL.csproj` does **not** reference `App.DTO` — public DTO mapping is App.DTO's responsibility (App.DTO references App.BLL, not the other way around) | |
| 48 | +- `WebApp.csproj` references `App.DAL.EF` **only** for `Program.cs` composition-root wiring (`builder.Services.AddDalServices(...)`); no controller uses DAL or DbContext directly | |
| 49 | +- Every repository interface (`IAppUnitOfWork`, `ITripRepository`, `IRefreshTokenRepository`, `IUserRepository`, …) is defined in `App.Domain/Contracts/` and implemented in `App.DAL.EF/` | |
| 50 | + | |
| 51 | +See [explanation.md](explanation.md) for a full architectural walkthrough, flow examples, and defense cheat sheet. | |
| 52 | + | |
| 53 | +--- | |
| 54 | + | |
| 55 | +## Phase 2 changes — what was added on top of Phase 1 | |
| 56 | + | |
| 57 | +### Onion strictness — 2 critical violations fixed | |
| 58 | +- **`AccountController` no longer injects `AppDbContext`** — replaced direct EF calls with new `App.BLL.Services.Identity.IIdentityService` (Register, Login, RefreshToken, Logout) backed by `IRefreshTokenRepository` and `IUserRepository` in the UoW. | |
| 59 | +- **`AdminStatsService` no longer depends on `Microsoft.AspNetCore.*`** — `UserManager` replaced with `IUserRepository`; `IStringLocalizer` removed (service now emits `MessageKey + MessageArgs`, controller resolves localization at projection time). | |
| 60 | +- The only `Microsoft.AspNetCore.Identity` import remaining in `App.BLL` is in `IdentityService` (auth abstraction by design — `AppUser` already inherits `IdentityUser` in Domain). | |
| 61 | + | |
| 62 | +### BLL DTO + Factory Mapper layer (Lecture: *"Controllers should never see domain entities"*) | |
| 63 | + | |
| 64 | +Added across all 8 client services and 12 admin services: | |
| 65 | + | |
| 66 | +| App.BLL/DTO/ (15 BLL DTOs) | App.BLL/Mappers/ (9 Factories) | | |
| 67 | +|---|---| | |
| 68 | +| TripBllDto, TripParticipantBllDto, AppUserBllDto | TripBllDtoFactory | | |
| 69 | +| ExpenseBllDto, ExpenseSplitBllDto | ExpenseBllDtoFactory | | |
| 70 | +| SettlementPlanBllDto, SettlementPaymentBllDto | SettlementBllDtoFactory | | |
| 71 | +| TripInvitationBllDto | InvitationBllDtoFactory | | |
| 72 | +| TripPollBllDto, TripPollOptionBllDto | PollBllDtoFactory | | |
| 73 | +| TripWishlistItemBllDto | WishlistBllDtoFactory | | |
| 74 | +| BudgetCategoryBllDto | BudgetCategoryBllDtoFactory | | |
| 75 | +| CurrencyBllDto | CurrencyBllDtoFactory | | |
| 76 | +| SplitPresetBllDto, SplitPresetMemberBllDto | SplitPresetBllDtoFactory | | |
| 77 | +| BalanceBllDto | (settlement helper) | | |
| 78 | + | |
| 79 | +Every service interface now uses BLL DTOs at its public surface (`Task<TripBllDto> CreateTripAsync(TripBllDto dto, Guid userId)`). All ~30 controllers (API + MVC + Admin) and Razor views were updated to use BLL DTO types instead of Domain entities. | |
| 80 | + | |
| 81 | +### Full Admin UX completed | |
| 82 | + | |
| 83 | +New views and controller actions added in `WebApp/Areas/Admin/`: | |
| 84 | + | |
| 85 | +| Area | Added | | |
| 86 | +|---|---| | |
| 87 | +| `SplitPresets` | `Create.cshtml` + `Create` action + `AdminSplitPresetFormViewModel` + `CreateAsync` service method | | |
| 88 | +| `Invitations` | `Create.cshtml`, `Edit.cshtml` + actions + `AdminInvitationFormViewModel` + `CreateAsync`/`UpdateAsync` service methods | | |
| 89 | +| `SettlementPayments` | `Create.cshtml`, `Edit.cshtml` + actions + `AdminSettlementPaymentFormViewModel` + `CreateAsync`/`UpdateAsync` service methods | | |
| 90 | +| `Users` | `Details.cshtml`, `Edit.cshtml`, `Delete.cshtml` + actions + `AdminUserDetailsViewModel` + `AdminUserEditViewModel` | | |
| 91 | + | |
| 92 | +All 13 admin controllers now have full CRUD coverage with **0 ViewBag/ViewData usage** — strict ViewModel-only views as required. | |
| 93 | + | |
| 94 | +--- | |
| 95 | + | |
| 96 | +## Feature overview | |
| 97 | + | |
| 98 | +- **Trips** — create, manage, and archive group trips with Organizer / Participant roles | |
| 99 | +- **Expenses** — four split methods: `EqualAll`, `EqualSubset`, `ExactAmounts`, `Percentages` | |
| 100 | +- **Split presets** — reusable splitting templates | |
| 101 | +- **Budgets** — per-trip categories with real-time progress tracking | |
| 102 | +- **Invitations** — token-based invite links (Pending → Accepted / Declined / Expired / Revoked) | |
| 103 | +- **Settlement** — real-time balance tracking; trip lifecycle is `Active → Finalizing → Settled`: organizer clicks **Finalize Trip** to lock expenses and generate an optimized settlement plan (greedy algorithm minimizing payment count), trip enters `Finalizing`; two-sided confirmation flow (debtor marks paid → creditor confirms) — trip auto-advances to `Settled` only once every payment has been confirmed by its recipient. While in `Finalizing`, the organizer can still **Reopen** the trip (blocked once any payment is confirmed). | |
| 104 | +- **Wishlist** — places, activities, restaurants with voting and priority | |
| 105 | +- **Polls** — group decision-making with single/multi-vote support | |
| 106 | +- **Multi-currency** — EUR, USD, GBP, SEK, NOK (hardcoded rates) | |
| 107 | +- **Localization** — English + Estonian (UI via `.resx`; dynamic system data via `LangStr` JSON in DB) | |
| 108 | +- **Auth** — JWT Bearer for API (+ refresh token rotation), Cookie auth for MVC, role-based authorization (system roles + trip roles), IDOR protection | |
| 109 | + | |
| 110 | +--- | |
| 111 | + | |
| 112 | +## Tech Stack | |
| 113 | + | |
| 114 | +- **Runtime:** ASP.NET Core 10.0 (MVC + REST API) | |
| 115 | +- **Database:** PostgreSQL 16 via Npgsql EF Core provider | |
| 116 | +- **Identity:** ASP.NET Identity with JWT Bearer + refresh token rotation, wrapped in `IIdentityService` | |
| 117 | +- **API docs:** Swagger / OpenAPI (with versioning and JWT auth integrated) | |
| 118 | +- **Deployment:** Docker + docker-compose, GitLab CI auto-deploy on `main` | |
| 119 | + | |
| 120 | +--- | |
| 121 | + | |
| 122 | +## Phase 2 assignment requirements — mapping | |
| 123 | + | |
| 124 | +| Requirement | Status | Where to see it | | |
| 125 | +|---|---|---| | |
| 126 | +| **CLEAN/ONION architecture** | ✅ | Inverted dependencies, Domain-owned interfaces, BLL DTO + Factory pattern | | |
| 127 | +| Domain design: min 10 meaningful entities | ✅ 16 entities | `App.Domain/` | | |
| 128 | +| REST API: controllers + versioning + public DTOs | ✅ | `WebApp/ApiControllers/`, `/api/v1/`, `App.DTO/v1/` | | |
| 129 | +| Swagger | ✅ | `/swagger`, `ConfigureSwaggerOptions.cs` | | |
| 130 | +| Auth (JWT + refresh tokens) | ✅ | `IIdentityService` in BLL, `AccountController` thin wrapper | | |
| 131 | +| Client UX (MVC) | ✅ | `WebApp/Controllers/*Controller.cs` (uses BLL DTOs) | | |
| 132 | +| Admin UX (MVC, Area, ViewModels, no ViewBag/ViewData) | ✅ Full CRUD on all 13 controllers | `WebApp/Areas/Admin/`, `AdminViewModels.cs` | | |
| 133 | +| **Full Admin UX** | ✅ | All entities have Index / Details / Create / Edit / Delete (where meaningful) | | |
| 134 | +| UI translations (i18n, .resx) | ✅ EN + ET | `App.Resources/` | | |
| 135 | +| DB translations (LangStr) | ✅ | `Currency.Name`, `BudgetCategory.Name` use `LangStr` | | |
| 136 | +| IDOR protection | ✅ | `_uow.TripParticipants.IsParticipantAsync()` / `IsOrganizerAsync()` checks centralized in BLL services | | |
| 137 | +| **Repositories, UoW, Services, BLL, Mappers — mandatory** | ✅ All present | `App.Domain/Contracts/`, `App.DAL.EF/Repositories/`, `App.BLL/Services/`, `App.BLL/Mappers/`, `App.DTO/Mappers/` | | |
| 138 | +| CI/CD deploy (app + DB) | ✅ | `.gitlab-ci.yml`, `Dockerfile`, `docker-compose.yml` | | |
| 139 | +| Test coverage | ⏳ deferred to next iteration | — | | |
| 140 | + | |
| 141 | +--- | |
| 142 | + | |
| 143 | +## Getting Started | |
| 144 | + | |
| 145 | +### Prerequisites | |
| 146 | + | |
| 147 | +- .NET 10.0 SDK | |
| 148 | +- PostgreSQL 16 (or Docker) | |
| 149 | + | |
| 150 | +### Run with Docker (recommended) | |
| 151 | + | |
| 152 | +```bash | |
| 153 | +docker compose up --build | |
| 154 | +``` | |
| 155 | + | |
| 156 | +The app listens on **http://localhost:84** (host port 84 → container port 8080). Migrations and seed data are applied automatically on startup. PostgreSQL data persists in a named volume (`pgdata`). | |
| 157 | + | |
| 158 | +For a clean reset (drop DB volume + reseed): | |
| 159 | + | |
| 160 | +```bash | |
| 161 | +docker compose down -v && docker compose up --build | |
| 162 | +``` | |
| 163 | + | |
| 164 | +### Run locally (without Docker) | |
| 165 | + | |
| 166 | +```bash | |
| 167 | +cd SplitApp | |
| 168 | +dotnet restore | |
| 169 | +dotnet ef database update --project App.DAL.EF --startup-project WebApp | |
| 170 | +dotnet run --project WebApp | |
| 171 | +``` | |
| 172 | + | |
| 173 | +On first launch, seed data creates: default users, roles, currencies, and 4 example trips with expenses, polls, wishlist items. | |
| 174 | + | |
| 175 | +### Default seed users | |
| 176 | + | |
| 177 | +The demo accounts are `user@`, `alice@`, `bob@`, `charlie@` and `diana@taltech.ee`, | |
| 178 | +all with the password `Kala.12345`. That is in the source on purpose: this is a | |
| 179 | +demo, the data is invented, and anyone reading the code is meant to be able to | |
| 180 | +sign in and look around. | |
| 181 | + | |
| 182 | +The administrator is not seeded at all unless `SEED_ADMIN_PASSWORD` is set, and | |
| 183 | +there is no default. See `DEPLOY.md`. | |
| 184 | + | |
| 185 | +--- | |
| 186 | + | |
| 187 | +## REST API | |
| 188 | + | |
| 189 | +Versioned under `/api/v1/`. All protected endpoints require a JWT Bearer token. | |
| 190 | + | |
| 191 | +| Controller | Endpoints | Auth | | |
| 192 | +|---|---|---| | |
| 193 | +| `AccountController` (Identity) | register, login, refreshtoken, logout | partial (login/register public) — backed by `IIdentityService` | | |
| 194 | +| `TripsController` | trip CRUD, participant info | JWT + participant/organizer check | | |
| 195 | +| `ExpensesController` | expense CRUD with splits | JWT + participant check | | |
| 196 | +| `BudgetCategoriesController` | per-trip budget categories | JWT + participant check | | |
| 197 | +| `InvitationsController` | create, info, accept, decline, revoke | JWT + organizer check | | |
| 198 | +| `WishlistController` | wishlist CRUD, voting, completion | JWT + participant check | | |
| 199 | +| `PollsController` | poll CRUD, voting, closing | JWT + participant check | | |
| 200 | +| `SettlementsController` | balances, calculation, mark-paid, confirm | JWT + participant check | | |
| 201 | +| `SplitPresetsController` | split preset CRUD | JWT + organizer check | | |
| 202 | +| `CurrenciesController` | currency reference data | JWT | | |
| 203 | + | |
| 204 | +Swagger UI exposes the Bearer-auth flow — log in, paste the JWT, and all protected endpoints become callable from the browser. | |
| 205 | + | |
| 206 | +--- | |
| 207 | + | |
| 208 | +## MVC Client UX | |
| 209 | + | |
| 210 | +Standard MVC controllers — functional, focused on proving the domain logic works through the BLL DTO layer: | |
| 211 | + | |
| 212 | +- **Home** (public), **Trips** (CRUD + details), **Expenses** (CRUD + 4 split methods), **Budget** (categories + progress), **Members** (invite links), **Settlement** (balances + payments), **PollsClient**, **WishlistClient** | |
| 213 | + | |
| 214 | +All views use BLL DTO types (`@model App.BLL.DTO.TripBllDto`) — no Domain entity leaks into Razor. | |
| 215 | + | |
| 216 | +--- | |
| 217 | + | |
| 218 | +## Admin Panel | |
| 219 | + | |
| 220 | +Admin-only area at `/Admin`, protected by `[Authorize(Roles = "admin")]`. Designed, not pure scaffold: | |
| 221 | + | |
| 222 | +- **Custom sidebar layout** (`Areas/Admin/Views/Shared/_Layout.cshtml`) with Bootstrap Icons | |
| 223 | +- **Admin.css** — dedicated styling (sidebar, metric cards, status badges, timeline feed, empty states) | |
| 224 | +- **Dashboard** with custom statistics: Top Active Trips, Biggest Expenses, User activity (7d/30d), Top Active Users, chronological Activity Feed | |
| 225 | +- **13 admin controllers** with **full CRUD** coverage (Trips, Expenses, BudgetCategories, Currencies, Polls, Wishlist, Invitations, SettlementPlans, SettlementPayments, SplitPresets, TripParticipants, Users, Dashboard) | |
| 226 | +- **Strict ViewModels** — every view uses typed ViewModel inheriting `AdminPageViewModel`; **0 `ViewData`/`ViewBag` usage** | |
| 227 | +- **Generic wrappers** — `AdminDetailsViewModel<T>` and `AdminDeleteViewModel<T>` parameterized over BLL DTO types — keep domain entities out of views | |
| 228 | +- **User management** — Details / Edit (FirstName + LastName) / EditRoles / Delete actions | |
| 229 | + | |
| 230 | +--- | |
| 231 | + | |
| 232 | +## Authorization Model | |
| 233 | + | |
| 234 | +- **System roles** (ASP.NET Identity): `admin`, `user` — enforced via `[Authorize(Roles = "admin")]` | |
| 235 | +- **Trip roles** (domain): `Organizer`, `Participant` — enforced via `TripParticipantRepository.IsOrganizerAsync()` / `IsParticipantAsync()` | |
| 236 | +- **IDOR protection** — every trip-scoped operation verifies the caller is a participant; check is centralized in BLL services (each query/mutation method takes `Guid userId` and validates it internally), so controllers cannot accidentally bypass the check | |
| 237 | +- **Trip lifecycle enforcement** — expenses cannot be created/edited/deleted while trip is outside `Active` (i.e. `Finalizing`, `Settled`, or `Archived`); settlement plan actions (Mark Paid / Confirm Receipt) are available during `Finalizing` and `Settled`; payer-only Mark Paid and payee-only Confirm buttons are enforced both in the BLL guards (`MarkPaidGuardedAsync` / `ConfirmPaymentGuardedAsync`) and in the views (button hidden for other users) | |
| 238 | +- **Creator-based access** — wishlist items editable/deletable only by creator; expenses editable/deletable by creator or trip organizer | |
| 239 | + | |
| 240 | +--- | |
| 241 | + | |
| 242 | +## Project Structure | |
| 243 | + | |
| 244 | +``` | |
| 245 | +SplitApp/ | |
| 246 | +├── Base.Contracts/ Generic interfaces (IBaseEntity, IBaseRepository, IUnitOfWork) | |
| 247 | +├── Base.Domain/ BaseEntity, LangStr | |
| 248 | +├── Base.Helpers/ JWT generation/validation helpers | |
| 249 | +├── App.Domain/ 16 domain entities + 8 enums | |
| 250 | +│ └── Contracts/ IAppUnitOfWork + 12 repository interfaces | |
| 251 | +│ (ITripRepository, IRefreshTokenRepository, IUserRepository, …) | |
| 252 | +├── App.DAL.EF/ EF Core DbContext, UnitOfWork + repository implementations, | |
| 253 | +│ migrations, ServiceCollectionExtensions.AddDalServices() | |
| 254 | +├── App.BLL/ Application services — depends only on App.Domain | |
| 255 | +│ ├── DTO/ BLL DTOs — internal application boundary (TripBllDto, ExpenseBllDto, | |
| 256 | +│ │ AppUserBllDto, … 15 DTOs) | |
| 257 | +│ ├── Mappers/ Domain ↔ BLL DTO factory mappers (9 factory classes) | |
| 258 | +│ └── Services/ | |
| 259 | +│ ├── Identity/ IIdentityService + IdentityService (Register/Login/Refresh/Logout) | |
| 260 | +│ ├── *.cs Core services (Trip, Expense, Settlement, Invitation, Poll, | |
| 261 | +│ │ BudgetCategory, Wishlist, SplitPreset) | |
| 262 | +│ └── Admin/ 12 admin services + AdminStatsService + AdminDashboardData | |
| 263 | +├── App.DTO/ Public API DTOs (versioned) + Mappers/ | |
| 264 | +│ (BLL DTO ↔ Public DTO — TripMapper, ExpenseMapper, … 9 mappers) | |
| 265 | +├── App.Resources/ .resx localization files (EN + ET) | |
| 266 | +├── WebApp/ MVC views, API controllers, admin area, Program.cs composition root | |
| 267 | +│ ├── ApiControllers/ REST API (use BLL DTO + Public DTO mappers) | |
| 268 | +│ ├── Controllers/ MVC client (use BLL DTO + ViewModels) | |
| 269 | +│ ├── Areas/Admin/ Admin area (Controllers, Views, Models) | |
| 270 | +│ └── Models/ MVC ViewModels | |
| 271 | +├── Dockerfile | |
| 272 | +├── docker-compose.yml | |
| 273 | +└── SplitApp.sln | |
| 274 | +``` | |
| 275 | + | |
| 276 | +**Dependency graph (Phase 2):** | |
| 277 | + | |
| 278 | +- `Base.Contracts` — no deps | |
| 279 | +- `Base.Domain` → `Base.Contracts` | |
| 280 | +- `Base.Helpers` — JWT (System.IdentityModel.Tokens.Jwt) | |
| 281 | +- `App.Domain` → `Base.Domain`, `Base.Contracts`, `App.Resources` (for Display attributes) | |
| 282 | +- `App.DAL.EF` → `App.Domain`, `Base.Contracts` (implements Domain contracts) | |
| 283 | +- `App.BLL` → `App.Domain`, `Base.Helpers` (**does NOT reference App.DAL.EF or App.DTO** — Clean inversion) | |
| 284 | +- `App.DTO` → `App.Domain`, `App.BLL` (Public DTO layer maps from BLL DTOs) | |
| 285 | +- `WebApp` → `App.BLL`, `App.DTO`, `App.Resources`, `App.DAL.EF` (DAL ref only for `Program.cs` `AddDalServices(...)`; no controller uses DAL) | |
| 286 | + | |
| 287 | +--- | |
| 288 | + | |
| 289 | +## Defense cheat sheet (Phase 2 architecture questions) | |
| 290 | + | |
| 291 | +| Question | Answer / file | | |
| 292 | +|---|---| | |
| 293 | +| Where do interfaces live? | `App.Domain/Contracts/` — Domain owns the interfaces (Onion) | | |
| 294 | +| Why doesn't BLL reference DAL? | Dependency inversion via Domain contracts; see `App.BLL.csproj` | | |
| 295 | +| Show me the 3 DTO tiers | Domain `Trip` → BLL `TripBllDto` → Public `TripDto` | | |
| 296 | +| How do you map between layers? | Factory pattern: `App.BLL.Mappers.TripBllDtoFactory` (Domain↔BLL DTO) and `App.DTO.Mappers.TripMapper` (BLL DTO↔Public DTO) | | |
| 297 | +| How does a controller talk to the database? | Controller → BLL service interface → `IAppUnitOfWork` → `IRepository<T>` → `DbContext` (4-layer indirection, all abstractions) | | |
| 298 | +| Why no DbContext in controllers? | `IIdentityService` is the example — Identity flow moved entirely to BLL | | |
| 299 | +| Why no ViewBag/ViewData? | Every view has a typed `ViewModel`; verified by grep across `WebApp/Views/` and `WebApp/Areas/Admin/Views/` | | |
| 300 | +| How do you protect against IDOR? | Every trip-scoped service method validates `userId` against `TripParticipants.IsParticipantAsync` / `IsOrganizerAsync` before returning data | | |
| 301 | +| What's in the BLL DTO that's not in Domain entity? | Computed flat fields like `UserFullName`, `TripName`, `VoteCount`, `SpentAmount` — view-friendly, framework-agnostic | | |
| 302 | +| What's in the Public DTO that's not in BLL DTO? | String-based enums (versionable), flat denormalized fields (`DefaultCurrencyCode` instead of nested), no nav collections in list views | | |
| 303 | + | |
| 304 | +--- | |
| 305 | + | |
| 306 | +## License | |
| 307 | + | |
| 308 | +Course project — TalTech "Web Applications with C#". | |
| 309 | + | |
| 310 | +See [explanation.md](explanation.md) for architectural decisions, the settlement algorithm, and layer-by-layer walkthrough. |
added SplitApp/App.BLL/App.BLL.csproj +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <ItemGroup> | |
| 4 | + <ProjectReference Include="..\App.Domain\App.Domain.csproj" /> | |
| 5 | + <ProjectReference Include="..\Base.Helpers\Base.Helpers.csproj" /> | |
| 6 | + </ItemGroup> | |
| 7 | + | |
| 8 | + <ItemGroup> | |
| 9 | + <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.5" /> | |
| 10 | + <PackageReference Include="Microsoft.Extensions.Localization.Abstractions" Version="10.0.5" /> | |
| 11 | + <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" /> | |
| 12 | + </ItemGroup> | |
| 13 | + | |
| 14 | + <PropertyGroup> | |
| 15 | + <TargetFramework>net10.0</TargetFramework> | |
| 16 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 17 | + <Nullable>enable</Nullable> | |
| 18 | + </PropertyGroup> | |
| 19 | + | |
| 20 | +</Project> |
added SplitApp/App.BLL/DTO/AppUserBllDto.cs +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +namespace App.BLL.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/App.BLL/DTO/BalanceBllDto.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +namespace App.BLL.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/App.BLL/DTO/BudgetCategoryBllDto.cs +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +using Base.Domain; | |
| 2 | + | |
| 3 | +namespace App.BLL.DTO; | |
| 4 | + | |
| 5 | +public class BudgetCategoryBllDto | |
| 6 | +{ | |
| 7 | + public Guid Id { get; set; } | |
| 8 | + public DateTime CreatedAt { get; set; } | |
| 9 | + public DateTime UpdatedAt { get; set; } | |
| 10 | + | |
| 11 | + public Guid TripId { get; set; } | |
| 12 | + public TripBllDto? Trip { get; set; } | |
| 13 | + public string? TripName => Trip?.Name; | |
| 14 | + | |
| 15 | + public LangStr Name { get; set; } = new(); | |
| 16 | + public string? IconName { get; set; } | |
| 17 | + public decimal? PlannedAmount { get; set; } | |
| 18 | + public int DisplayOrder { get; set; } | |
| 19 | + public int ExpenseCount { get; set; } | |
| 20 | + public decimal SpentAmount { get; set; } | |
| 21 | +} |
added SplitApp/App.BLL/DTO/CurrencyBllDto.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +using Base.Domain; | |
| 2 | + | |
| 3 | +namespace App.BLL.DTO; | |
| 4 | + | |
| 5 | +public class CurrencyBllDto | |
| 6 | +{ | |
| 7 | + public Guid Id { get; set; } | |
| 8 | + public DateTime CreatedAt { get; set; } | |
| 9 | + public DateTime UpdatedAt { get; set; } | |
| 10 | + | |
| 11 | + public string Code { get; set; } = default!; | |
| 12 | + public LangStr Name { get; set; } = new(); | |
| 13 | + public string Symbol { get; set; } = default!; | |
| 14 | +} |
added SplitApp/App.BLL/DTO/ExpenseBllDto.cs +33 −0
| @@ -0,0 +1,33 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.DTO; | |
| 5 | + | |
| 6 | +public class ExpenseBllDto | |
| 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 PaidByUserId { get; set; } | |
| 17 | + public AppUserBllDto? PaidByUser { get; set; } | |
| 18 | + public string? PaidByUserFullName => PaidByUser?.FullName; | |
| 19 | + public string? PaidByUserEmail => PaidByUser?.Email; | |
| 20 | + | |
| 21 | + public Guid? BudgetCategoryId { get; set; } | |
| 22 | + public BudgetCategoryBllDto? BudgetCategory { get; set; } | |
| 23 | + | |
| 24 | + public Guid? CurrencyId { get; set; } | |
| 25 | + public CurrencyBllDto? Currency { get; set; } | |
| 26 | + | |
| 27 | + public decimal Amount { get; set; } | |
| 28 | + public string? Description { get; set; } | |
| 29 | + public DateTime ExpenseDate { get; set; } | |
| 30 | + public ESplitMethod SplitMethod { get; set; } | |
| 31 | + | |
| 32 | + public ICollection<ExpenseSplitBllDto>? Splits { get; set; } | |
| 33 | +} |
added SplitApp/App.BLL/DTO/ExpenseSplitBllDto.cs +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +using Base.Domain; | |
| 2 | + | |
| 3 | +namespace App.BLL.DTO; | |
| 4 | + | |
| 5 | +public class ExpenseSplitBllDto | |
| 6 | +{ | |
| 7 | + public Guid Id { get; set; } | |
| 8 | + public DateTime CreatedAt { get; set; } | |
| 9 | + public DateTime UpdatedAt { get; set; } | |
| 10 | + | |
| 11 | + public Guid ExpenseId { get; set; } | |
| 12 | + public Guid UserId { get; set; } | |
| 13 | + public string? UserFullName { get; set; } | |
| 14 | + public string? UserEmail { get; set; } | |
| 15 | + | |
| 16 | + public decimal Amount { get; set; } | |
| 17 | + public decimal? Percentage { get; set; } | |
| 18 | +} |
added SplitApp/App.BLL/DTO/SettlementPaymentBllDto.cs +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.DTO; | |
| 5 | + | |
| 6 | +public class SettlementPaymentBllDto | |
| 7 | +{ | |
| 8 | + public Guid Id { get; set; } | |
| 9 | + public DateTime CreatedAt { get; set; } | |
| 10 | + public DateTime UpdatedAt { get; set; } | |
| 11 | + | |
| 12 | + public Guid SettlementPlanId { get; set; } | |
| 13 | + | |
| 14 | + public Guid FromUserId { get; set; } | |
| 15 | + public AppUserBllDto? FromUser { get; set; } | |
| 16 | + public string? FromUserFullName => FromUser?.FullName; | |
| 17 | + | |
| 18 | + public Guid ToUserId { get; set; } | |
| 19 | + public AppUserBllDto? ToUser { get; set; } | |
| 20 | + public string? ToUserFullName => ToUser?.FullName; | |
| 21 | + | |
| 22 | + public decimal Amount { get; set; } | |
| 23 | + public EPaymentStatus Status { get; set; } = EPaymentStatus.Pending; | |
| 24 | + public DateTime? MarkedPaidAt { get; set; } | |
| 25 | + public DateTime? ConfirmedAt { get; set; } | |
| 26 | +} |
added SplitApp/App.BLL/DTO/SettlementPlanBllDto.cs +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.DTO; | |
| 5 | + | |
| 6 | +public class SettlementPlanBllDto | |
| 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 decimal TotalAmount { get; set; } | |
| 21 | + public ESettlementStatus Status { get; set; } = ESettlementStatus.Pending; | |
| 22 | + public DateTime? CompletedAt { get; set; } | |
| 23 | + | |
| 24 | + public ICollection<SettlementPaymentBllDto>? Payments { get; set; } | |
| 25 | +} |
added SplitApp/App.BLL/DTO/SplitPresetBllDto.cs +37 −0
| @@ -0,0 +1,37 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.DTO; | |
| 5 | + | |
| 6 | +public class SplitPresetBllDto | |
| 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 string Name { get; set; } = default!; | |
| 17 | + public ESplitMethod SplitMethod { get; set; } | |
| 18 | + | |
| 19 | + public Guid CreatedById { get; set; } | |
| 20 | + public AppUserBllDto? CreatedBy { get; set; } | |
| 21 | + public string? CreatedByFullName => CreatedBy?.FullName; | |
| 22 | + | |
| 23 | + public ICollection<SplitPresetMemberBllDto>? Members { get; set; } | |
| 24 | +} | |
| 25 | + | |
| 26 | +public class SplitPresetMemberBllDto | |
| 27 | +{ | |
| 28 | + public Guid Id { get; set; } | |
| 29 | + public DateTime CreatedAt { get; set; } | |
| 30 | + public DateTime UpdatedAt { get; set; } | |
| 31 | + | |
| 32 | + public Guid SplitPresetId { get; set; } | |
| 33 | + public Guid UserId { get; set; } | |
| 34 | + public string? UserFullName { get; set; } | |
| 35 | + public decimal? ShareWeight { get; set; } | |
| 36 | + public decimal? Percentage { get; set; } | |
| 37 | +} |
added SplitApp/App.BLL/DTO/TripBllDto.cs +34 −0
| @@ -0,0 +1,34 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.DTO; | |
| 5 | + | |
| 6 | +public class TripBllDto | |
| 7 | +{ | |
| 8 | + public Guid Id { get; set; } | |
| 9 | + public DateTime CreatedAt { get; set; } | |
| 10 | + public DateTime UpdatedAt { get; set; } | |
| 11 | + | |
| 12 | + public string Name { get; set; } = default!; | |
| 13 | + public string? Description { get; set; } | |
| 14 | + public string? Destination { get; set; } | |
| 15 | + public DateTime? StartDate { get; set; } | |
| 16 | + public DateTime? EndDate { get; set; } | |
| 17 | + public ETripStatus Status { get; set; } = ETripStatus.Active; | |
| 18 | + | |
| 19 | + public Guid DefaultCurrencyId { get; set; } | |
| 20 | + public CurrencyBllDto? DefaultCurrency { get; set; } | |
| 21 | + | |
| 22 | + public Guid CreatedById { get; set; } | |
| 23 | + public AppUserBllDto? CreatedBy { get; set; } | |
| 24 | + public string? CreatedByFullName => CreatedBy?.FullName; | |
| 25 | + public string? CreatedByEmail => CreatedBy?.Email; | |
| 26 | + | |
| 27 | + public ICollection<TripParticipantBllDto>? Participants { get; set; } | |
| 28 | + public ICollection<ExpenseBllDto>? Expenses { get; set; } | |
| 29 | + public ICollection<BudgetCategoryBllDto>? BudgetCategories { get; set; } | |
| 30 | + public ICollection<TripWishlistItemBllDto>? WishlistItems { get; set; } | |
| 31 | + public ICollection<TripPollBllDto>? Polls { get; set; } | |
| 32 | + public ICollection<TripInvitationBllDto>? Invitations { get; set; } | |
| 33 | + public ICollection<SettlementPlanBllDto>? SettlementPlans { get; set; } | |
| 34 | +} |
added SplitApp/App.BLL/DTO/TripInvitationBllDto.cs +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.DTO; | |
| 5 | + | |
| 6 | +public class TripInvitationBllDto | |
| 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 InvitedByUserId { get; set; } | |
| 17 | + public AppUserBllDto? InvitedByUser { get; set; } | |
| 18 | + public string? InvitedByUserFullName => InvitedByUser?.FullName; | |
| 19 | + public string? InvitedByUserEmail => InvitedByUser?.Email; | |
| 20 | + | |
| 21 | + public string Token { get; set; } = default!; | |
| 22 | + public EInvitationStatus Status { get; set; } = EInvitationStatus.Pending; | |
| 23 | + public DateTime ExpiresAt { get; set; } | |
| 24 | + public DateTime? RespondedAt { get; set; } | |
| 25 | +} |
added SplitApp/App.BLL/DTO/TripParticipantBllDto.cs +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.DTO; | |
| 5 | + | |
| 6 | +public class TripParticipantBllDto | |
| 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 Guid UserId { get; set; } | |
| 15 | + public AppUserBllDto? User { get; set; } | |
| 16 | + public string? UserFirstName => User?.FirstName; | |
| 17 | + public string? UserLastName => User?.LastName; | |
| 18 | + public string? UserEmail => User?.Email; | |
| 19 | + | |
| 20 | + public EParticipantRole Role { get; set; } = EParticipantRole.Participant; | |
| 21 | + public string? Nickname { get; set; } | |
| 22 | + public DateTime JoinedAt { get; set; } | |
| 23 | + public DateTime? LeftAt { get; set; } | |
| 24 | + public bool IsActive { get; set; } = true; | |
| 25 | +} |
added SplitApp/App.BLL/DTO/TripPollBllDto.cs +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +using Base.Domain; | |
| 2 | + | |
| 3 | +namespace App.BLL.DTO; | |
| 4 | + | |
| 5 | +public class TripPollBllDto | |
| 6 | +{ | |
| 7 | + public Guid Id { get; set; } | |
| 8 | + public DateTime CreatedAt { get; set; } | |
| 9 | + public DateTime UpdatedAt { get; set; } | |
| 10 | + | |
| 11 | + public Guid TripId { get; set; } | |
| 12 | + public TripBllDto? Trip { get; set; } | |
| 13 | + public string? TripName => Trip?.Name; | |
| 14 | + | |
| 15 | + public Guid CreatedByUserId { get; set; } | |
| 16 | + public AppUserBllDto? CreatedByUser { get; set; } | |
| 17 | + public string? CreatedByUserFullName => CreatedByUser?.FullName; | |
| 18 | + | |
| 19 | + public string Question { get; set; } = default!; | |
| 20 | + public bool AllowMultipleVotes { get; set; } | |
| 21 | + public bool IsAnonymous { get; set; } | |
| 22 | + public DateTime? ClosedAt { get; set; } | |
| 23 | + | |
| 24 | + public ICollection<TripPollOptionBllDto>? Options { get; set; } | |
| 25 | +} |
added SplitApp/App.BLL/DTO/TripPollOptionBllDto.cs +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +using Base.Domain; | |
| 2 | + | |
| 3 | +namespace App.BLL.DTO; | |
| 4 | + | |
| 5 | +public class TripPollOptionBllDto | |
| 6 | +{ | |
| 7 | + public Guid Id { get; set; } | |
| 8 | + public DateTime CreatedAt { get; set; } | |
| 9 | + public DateTime UpdatedAt { get; set; } | |
| 10 | + | |
| 11 | + public Guid PollId { get; set; } | |
| 12 | + public string Text { get; set; } = default!; | |
| 13 | + public int DisplayOrder { get; set; } | |
| 14 | + public int VoteCount { get; set; } | |
| 15 | + public List<Guid> VoterUserIds { get; set; } = new(); | |
| 16 | + public List<AppUserBllDto> Voters { get; set; } = new(); | |
| 17 | +} |
added SplitApp/App.BLL/DTO/TripWishlistItemBllDto.cs +33 −0
| @@ -0,0 +1,33 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.DTO; | |
| 5 | + | |
| 6 | +public class TripWishlistItemBllDto | |
| 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 AddedByUserId { get; set; } | |
| 17 | + public AppUserBllDto? AddedByUser { get; set; } | |
| 18 | + public string? AddedByUserFullName => AddedByUser?.FullName; | |
| 19 | + | |
| 20 | + public string Title { get; set; } = default!; | |
| 21 | + public string? Description { get; set; } | |
| 22 | + public EWishlistCategory Category { get; set; } | |
| 23 | + public EWishlistPriority Priority { get; set; } | |
| 24 | + public decimal? EstimatedCost { get; set; } | |
| 25 | + public string? Url { get; set; } | |
| 26 | + public string? Location { get; set; } | |
| 27 | + public bool IsCompleted { get; set; } | |
| 28 | + public DateTime? CompletedAt { get; set; } | |
| 29 | + public int DisplayOrder { get; set; } | |
| 30 | + | |
| 31 | + public int VoteCount { get; set; } | |
| 32 | + public List<Guid> VoterUserIds { get; set; } = new(); | |
| 33 | +} |
added SplitApp/App.BLL/Helpers/CurrencyConverter.cs +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +namespace App.BLL.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/App.BLL/Mappers/BudgetCategoryBllDtoFactory.cs +35 −0
| @@ -0,0 +1,35 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Mappers; | |
| 5 | + | |
| 6 | +public static class BudgetCategoryBllDtoFactory | |
| 7 | +{ | |
| 8 | + public static BudgetCategoryBllDto Create(BudgetCategory 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 | + Name = entity.Name, | |
| 16 | + IconName = entity.IconName, | |
| 17 | + PlannedAmount = entity.PlannedAmount, | |
| 18 | + DisplayOrder = entity.DisplayOrder, | |
| 19 | + ExpenseCount = entity.Expenses?.Count ?? 0, | |
| 20 | + SpentAmount = entity.Expenses?.Sum(e => e.Amount) ?? 0 | |
| 21 | + }; | |
| 22 | + | |
| 23 | + public static List<BudgetCategoryBllDto> CreateList(IEnumerable<BudgetCategory> entities) | |
| 24 | + => entities.Select(Create).ToList(); | |
| 25 | + | |
| 26 | + public static BudgetCategory ToEntity(BudgetCategoryBllDto dto) => new() | |
| 27 | + { | |
| 28 | + Id = dto.Id, | |
| 29 | + TripId = dto.TripId, | |
| 30 | + Name = dto.Name, | |
| 31 | + IconName = dto.IconName, | |
| 32 | + PlannedAmount = dto.PlannedAmount, | |
| 33 | + DisplayOrder = dto.DisplayOrder | |
| 34 | + }; | |
| 35 | +} |
added SplitApp/App.BLL/Mappers/CurrencyBllDtoFactory.cs +28 −0
| @@ -0,0 +1,28 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Mappers; | |
| 5 | + | |
| 6 | +public static class CurrencyBllDtoFactory | |
| 7 | +{ | |
| 8 | + public static CurrencyBllDto Create(Currency entity) => new() | |
| 9 | + { | |
| 10 | + Id = entity.Id, | |
| 11 | + CreatedAt = entity.CreatedAt, | |
| 12 | + UpdatedAt = entity.UpdatedAt, | |
| 13 | + Code = entity.Code, | |
| 14 | + Name = entity.Name, | |
| 15 | + Symbol = entity.Symbol | |
| 16 | + }; | |
| 17 | + | |
| 18 | + public static List<CurrencyBllDto> CreateList(IEnumerable<Currency> entities) | |
| 19 | + => entities.Select(Create).ToList(); | |
| 20 | + | |
| 21 | + public static Currency ToEntity(CurrencyBllDto dto) => new() | |
| 22 | + { | |
| 23 | + Id = dto.Id, | |
| 24 | + Code = dto.Code, | |
| 25 | + Name = dto.Name, | |
| 26 | + Symbol = dto.Symbol | |
| 27 | + }; | |
| 28 | +} |
added SplitApp/App.BLL/Mappers/ExpenseBllDtoFactory.cs +80 −0
| @@ -0,0 +1,80 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Mappers; | |
| 5 | + | |
| 6 | +public static class ExpenseBllDtoFactory | |
| 7 | +{ | |
| 8 | + public static ExpenseBllDto Create(Expense entity, bool includeSplits = 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 | + PaidByUserId = entity.PaidByUserId, | |
| 16 | + PaidByUser = entity.PaidByUser != null ? new AppUserBllDto | |
| 17 | + { | |
| 18 | + Id = entity.PaidByUser.Id, | |
| 19 | + FirstName = entity.PaidByUser.FirstName, | |
| 20 | + LastName = entity.PaidByUser.LastName, | |
| 21 | + Email = entity.PaidByUser.Email | |
| 22 | + } : null, | |
| 23 | + BudgetCategoryId = entity.BudgetCategoryId, | |
| 24 | + BudgetCategory = entity.BudgetCategory != null | |
| 25 | + ? BudgetCategoryBllDtoFactory.Create(entity.BudgetCategory) | |
| 26 | + : null, | |
| 27 | + CurrencyId = entity.CurrencyId, | |
| 28 | + Currency = entity.Currency != null ? CurrencyBllDtoFactory.Create(entity.Currency) : null, | |
| 29 | + Amount = entity.Amount, | |
| 30 | + Description = entity.Description, | |
| 31 | + ExpenseDate = entity.ExpenseDate, | |
| 32 | + SplitMethod = entity.SplitMethod, | |
| 33 | + Splits = includeSplits && entity.Splits != null | |
| 34 | + ? entity.Splits.Select(ExpenseSplitBllDtoFactory.Create).ToList() | |
| 35 | + : null | |
| 36 | + }; | |
| 37 | + | |
| 38 | + public static List<ExpenseBllDto> CreateList(IEnumerable<Expense> entities, bool includeSplits = false) | |
| 39 | + => entities.Select(e => Create(e, includeSplits)).ToList(); | |
| 40 | + | |
| 41 | + public static Expense ToEntity(ExpenseBllDto dto) => new() | |
| 42 | + { | |
| 43 | + Id = dto.Id, | |
| 44 | + TripId = dto.TripId, | |
| 45 | + PaidByUserId = dto.PaidByUserId, | |
| 46 | + BudgetCategoryId = dto.BudgetCategoryId, | |
| 47 | + CurrencyId = dto.CurrencyId, | |
| 48 | + Amount = dto.Amount, | |
| 49 | + Description = dto.Description, | |
| 50 | + ExpenseDate = dto.ExpenseDate, | |
| 51 | + SplitMethod = dto.SplitMethod | |
| 52 | + }; | |
| 53 | +} | |
| 54 | + | |
| 55 | +public static class ExpenseSplitBllDtoFactory | |
| 56 | +{ | |
| 57 | + public static ExpenseSplitBllDto Create(ExpenseSplit entity) => new() | |
| 58 | + { | |
| 59 | + Id = entity.Id, | |
| 60 | + CreatedAt = entity.CreatedAt, | |
| 61 | + UpdatedAt = entity.UpdatedAt, | |
| 62 | + ExpenseId = entity.ExpenseId, | |
| 63 | + UserId = entity.UserId, | |
| 64 | + UserFullName = entity.User != null | |
| 65 | + ? $"{entity.User.FirstName} {entity.User.LastName}".Trim() | |
| 66 | + : null, | |
| 67 | + UserEmail = entity.User?.Email, | |
| 68 | + Amount = entity.Amount, | |
| 69 | + Percentage = entity.Percentage | |
| 70 | + }; | |
| 71 | + | |
| 72 | + public static ExpenseSplit ToEntity(ExpenseSplitBllDto dto) => new() | |
| 73 | + { | |
| 74 | + Id = dto.Id, | |
| 75 | + ExpenseId = dto.ExpenseId, | |
| 76 | + UserId = dto.UserId, | |
| 77 | + Amount = dto.Amount, | |
| 78 | + Percentage = dto.Percentage | |
| 79 | + }; | |
| 80 | +} |
added SplitApp/App.BLL/Mappers/InvitationBllDtoFactory.cs +42 −0
| @@ -0,0 +1,42 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.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 = entity.InvitedByUser != null ? new AppUserBllDto | |
| 17 | + { | |
| 18 | + Id = entity.InvitedByUser.Id, | |
| 19 | + FirstName = entity.InvitedByUser.FirstName, | |
| 20 | + LastName = entity.InvitedByUser.LastName, | |
| 21 | + Email = entity.InvitedByUser.Email | |
| 22 | + } : null, | |
| 23 | + Token = entity.Token, | |
| 24 | + Status = entity.Status, | |
| 25 | + ExpiresAt = entity.ExpiresAt, | |
| 26 | + RespondedAt = entity.RespondedAt | |
| 27 | + }; | |
| 28 | + | |
| 29 | + public static List<TripInvitationBllDto> CreateList(IEnumerable<TripInvitation> entities) | |
| 30 | + => entities.Select(Create).ToList(); | |
| 31 | + | |
| 32 | + public static TripInvitation ToEntity(TripInvitationBllDto dto) => new() | |
| 33 | + { | |
| 34 | + Id = dto.Id, | |
| 35 | + TripId = dto.TripId, | |
| 36 | + InvitedByUserId = dto.InvitedByUserId, | |
| 37 | + Token = dto.Token, | |
| 38 | + Status = dto.Status, | |
| 39 | + ExpiresAt = dto.ExpiresAt, | |
| 40 | + RespondedAt = dto.RespondedAt | |
| 41 | + }; | |
| 42 | +} |
added SplitApp/App.BLL/Mappers/PollBllDtoFactory.cs +75 −0
| @@ -0,0 +1,75 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.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 = entity.CreatedByUser != null ? new AppUserBllDto | |
| 17 | + { | |
| 18 | + Id = entity.CreatedByUser.Id, | |
| 19 | + FirstName = entity.CreatedByUser.FirstName, | |
| 20 | + LastName = entity.CreatedByUser.LastName, | |
| 21 | + Email = entity.CreatedByUser.Email | |
| 22 | + } : null, | |
| 23 | + Question = entity.Question, | |
| 24 | + AllowMultipleVotes = entity.AllowMultipleVotes, | |
| 25 | + IsAnonymous = entity.IsAnonymous, | |
| 26 | + ClosedAt = entity.ClosedAt, | |
| 27 | + Options = includeOptions && entity.Options != null | |
| 28 | + ? entity.Options.OrderBy(o => o.DisplayOrder).Select(PollOptionBllDtoFactory.Create).ToList() | |
| 29 | + : null | |
| 30 | + }; | |
| 31 | + | |
| 32 | + public static List<TripPollBllDto> CreateList(IEnumerable<TripPoll> entities, bool includeOptions = false) | |
| 33 | + => entities.Select(p => Create(p, includeOptions)).ToList(); | |
| 34 | + | |
| 35 | + public static TripPoll ToEntity(TripPollBllDto dto) => new() | |
| 36 | + { | |
| 37 | + Id = dto.Id, | |
| 38 | + TripId = dto.TripId, | |
| 39 | + CreatedByUserId = dto.CreatedByUserId, | |
| 40 | + Question = dto.Question, | |
| 41 | + AllowMultipleVotes = dto.AllowMultipleVotes, | |
| 42 | + IsAnonymous = dto.IsAnonymous, | |
| 43 | + ClosedAt = dto.ClosedAt | |
| 44 | + }; | |
| 45 | +} | |
| 46 | + | |
| 47 | +public static class PollOptionBllDtoFactory | |
| 48 | +{ | |
| 49 | + public static TripPollOptionBllDto Create(TripPollOption entity) => new() | |
| 50 | + { | |
| 51 | + Id = entity.Id, | |
| 52 | + CreatedAt = entity.CreatedAt, | |
| 53 | + UpdatedAt = entity.UpdatedAt, | |
| 54 | + PollId = entity.PollId, | |
| 55 | + Text = entity.Text, | |
| 56 | + DisplayOrder = entity.DisplayOrder, | |
| 57 | + VoteCount = entity.Votes?.Count ?? 0, | |
| 58 | + VoterUserIds = entity.Votes?.Select(v => v.UserId).ToList() ?? new List<Guid>(), | |
| 59 | + Voters = entity.Votes?.Where(v => v.User != null).Select(v => new AppUserBllDto | |
| 60 | + { | |
| 61 | + Id = v.User!.Id, | |
| 62 | + FirstName = v.User.FirstName, | |
| 63 | + LastName = v.User.LastName, | |
| 64 | + Email = v.User.Email | |
| 65 | + }).ToList() ?? new List<AppUserBllDto>() | |
| 66 | + }; | |
| 67 | + | |
| 68 | + public static TripPollOption ToEntity(TripPollOptionBllDto dto) => new() | |
| 69 | + { | |
| 70 | + Id = dto.Id, | |
| 71 | + PollId = dto.PollId, | |
| 72 | + Text = dto.Text, | |
| 73 | + DisplayOrder = dto.DisplayOrder | |
| 74 | + }; | |
| 75 | +} |
added SplitApp/App.BLL/Mappers/SettlementBllDtoFactory.cs +89 −0
| @@ -0,0 +1,89 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Mappers; | |
| 5 | + | |
| 6 | +public static class SettlementBllDtoFactory | |
| 7 | +{ | |
| 8 | + public static SettlementPlanBllDto Create(SettlementPlan entity, bool includePayments = 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 = entity.CreatedByUser != null ? new AppUserBllDto | |
| 17 | + { | |
| 18 | + Id = entity.CreatedByUser.Id, | |
| 19 | + FirstName = entity.CreatedByUser.FirstName, | |
| 20 | + LastName = entity.CreatedByUser.LastName, | |
| 21 | + Email = entity.CreatedByUser.Email | |
| 22 | + } : null, | |
| 23 | + TotalAmount = entity.TotalAmount, | |
| 24 | + Status = entity.Status, | |
| 25 | + CompletedAt = entity.CompletedAt, | |
| 26 | + Payments = includePayments && entity.Payments != null | |
| 27 | + ? entity.Payments.Select(SettlementPaymentBllDtoFactory.Create).ToList() | |
| 28 | + : null | |
| 29 | + }; | |
| 30 | + | |
| 31 | + public static List<SettlementPlanBllDto> CreateList(IEnumerable<SettlementPlan> entities, bool includePayments = false) | |
| 32 | + => entities.Select(p => Create(p, includePayments)).ToList(); | |
| 33 | + | |
| 34 | + public static SettlementPlan ToEntity(SettlementPlanBllDto dto) => new() | |
| 35 | + { | |
| 36 | + Id = dto.Id, | |
| 37 | + TripId = dto.TripId, | |
| 38 | + CreatedByUserId = dto.CreatedByUserId, | |
| 39 | + TotalAmount = dto.TotalAmount, | |
| 40 | + Status = dto.Status, | |
| 41 | + CompletedAt = dto.CompletedAt | |
| 42 | + }; | |
| 43 | +} | |
| 44 | + | |
| 45 | +public static class SettlementPaymentBllDtoFactory | |
| 46 | +{ | |
| 47 | + public static SettlementPaymentBllDto Create(SettlementPayment entity) => new() | |
| 48 | + { | |
| 49 | + Id = entity.Id, | |
| 50 | + CreatedAt = entity.CreatedAt, | |
| 51 | + UpdatedAt = entity.UpdatedAt, | |
| 52 | + SettlementPlanId = entity.SettlementPlanId, | |
| 53 | + FromUserId = entity.FromUserId, | |
| 54 | + FromUser = entity.FromUser != null ? new AppUserBllDto | |
| 55 | + { | |
| 56 | + Id = entity.FromUser.Id, | |
| 57 | + FirstName = entity.FromUser.FirstName, | |
| 58 | + LastName = entity.FromUser.LastName, | |
| 59 | + Email = entity.FromUser.Email | |
| 60 | + } : null, | |
| 61 | + ToUserId = entity.ToUserId, | |
| 62 | + ToUser = entity.ToUser != null ? new AppUserBllDto | |
| 63 | + { | |
| 64 | + Id = entity.ToUser.Id, | |
| 65 | + FirstName = entity.ToUser.FirstName, | |
| 66 | + LastName = entity.ToUser.LastName, | |
| 67 | + Email = entity.ToUser.Email | |
| 68 | + } : null, | |
| 69 | + Amount = entity.Amount, | |
| 70 | + Status = entity.Status, | |
| 71 | + MarkedPaidAt = entity.MarkedPaidAt, | |
| 72 | + ConfirmedAt = entity.ConfirmedAt | |
| 73 | + }; | |
| 74 | + | |
| 75 | + public static List<SettlementPaymentBllDto> CreateList(IEnumerable<SettlementPayment> entities) | |
| 76 | + => entities.Select(Create).ToList(); | |
| 77 | + | |
| 78 | + public static SettlementPayment ToEntity(SettlementPaymentBllDto dto) => new() | |
| 79 | + { | |
| 80 | + Id = dto.Id, | |
| 81 | + SettlementPlanId = dto.SettlementPlanId, | |
| 82 | + FromUserId = dto.FromUserId, | |
| 83 | + ToUserId = dto.ToUserId, | |
| 84 | + Amount = dto.Amount, | |
| 85 | + Status = dto.Status, | |
| 86 | + MarkedPaidAt = dto.MarkedPaidAt, | |
| 87 | + ConfirmedAt = dto.ConfirmedAt | |
| 88 | + }; | |
| 89 | +} |
added SplitApp/App.BLL/Mappers/SplitPresetBllDtoFactory.cs +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Mappers; | |
| 5 | + | |
| 6 | +public static class SplitPresetBllDtoFactory | |
| 7 | +{ | |
| 8 | + public static SplitPresetBllDto Create(SplitPreset entity, bool includeMembers = 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 | + Name = entity.Name, | |
| 16 | + SplitMethod = entity.SplitMethod, | |
| 17 | + CreatedById = entity.CreatedById, | |
| 18 | + CreatedBy = entity.CreatedBy != null ? new AppUserBllDto | |
| 19 | + { | |
| 20 | + Id = entity.CreatedBy.Id, | |
| 21 | + FirstName = entity.CreatedBy.FirstName, | |
| 22 | + LastName = entity.CreatedBy.LastName, | |
| 23 | + Email = entity.CreatedBy.Email | |
| 24 | + } : null, | |
| 25 | + Members = includeMembers && entity.Members != null | |
| 26 | + ? entity.Members.Select(SplitPresetMemberBllDtoFactory.Create).ToList() | |
| 27 | + : null | |
| 28 | + }; | |
| 29 | + | |
| 30 | + public static List<SplitPresetBllDto> CreateList(IEnumerable<SplitPreset> entities, bool includeMembers = false) | |
| 31 | + => entities.Select(p => Create(p, includeMembers)).ToList(); | |
| 32 | + | |
| 33 | + public static SplitPreset ToEntity(SplitPresetBllDto dto) => new() | |
| 34 | + { | |
| 35 | + Id = dto.Id, | |
| 36 | + TripId = dto.TripId, | |
| 37 | + Name = dto.Name, | |
| 38 | + SplitMethod = dto.SplitMethod, | |
| 39 | + CreatedById = dto.CreatedById | |
| 40 | + }; | |
| 41 | +} | |
| 42 | + | |
| 43 | +public static class SplitPresetMemberBllDtoFactory | |
| 44 | +{ | |
| 45 | + public static SplitPresetMemberBllDto Create(SplitPresetMember entity) => new() | |
| 46 | + { | |
| 47 | + Id = entity.Id, | |
| 48 | + CreatedAt = entity.CreatedAt, | |
| 49 | + UpdatedAt = entity.UpdatedAt, | |
| 50 | + SplitPresetId = entity.SplitPresetId, | |
| 51 | + UserId = entity.UserId, | |
| 52 | + UserFullName = entity.User != null | |
| 53 | + ? $"{entity.User.FirstName} {entity.User.LastName}".Trim() | |
| 54 | + : null, | |
| 55 | + ShareWeight = entity.ShareWeight, | |
| 56 | + Percentage = entity.Percentage | |
| 57 | + }; | |
| 58 | + | |
| 59 | + public static SplitPresetMember ToEntity(SplitPresetMemberBllDto dto) => new() | |
| 60 | + { | |
| 61 | + Id = dto.Id, | |
| 62 | + SplitPresetId = dto.SplitPresetId, | |
| 63 | + UserId = dto.UserId, | |
| 64 | + ShareWeight = dto.ShareWeight, | |
| 65 | + Percentage = dto.Percentage | |
| 66 | + }; | |
| 67 | +} |
added SplitApp/App.BLL/Mappers/TripBllDtoFactory.cs +105 −0
| @@ -0,0 +1,105 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.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 = entity.CreatedBy != null ? new AppUserBllDto | |
| 25 | + { | |
| 26 | + Id = entity.CreatedBy.Id, | |
| 27 | + FirstName = entity.CreatedBy.FirstName, | |
| 28 | + LastName = entity.CreatedBy.LastName, | |
| 29 | + Email = entity.CreatedBy.Email | |
| 30 | + } : null | |
| 31 | + }; | |
| 32 | + | |
| 33 | + if (includeParticipants && entity.Participants != null) | |
| 34 | + { | |
| 35 | + dto.Participants = entity.Participants | |
| 36 | + .Select(TripParticipantBllDtoFactory.Create) | |
| 37 | + .ToList(); | |
| 38 | + } | |
| 39 | + | |
| 40 | + if (includeExpenses && entity.Expenses != null) | |
| 41 | + { | |
| 42 | + dto.Expenses = entity.Expenses | |
| 43 | + .Select(e => ExpenseBllDtoFactory.Create(e)) | |
| 44 | + .ToList(); | |
| 45 | + } | |
| 46 | + | |
| 47 | + return dto; | |
| 48 | + } | |
| 49 | + | |
| 50 | + public static List<TripBllDto> CreateList(IEnumerable<Trip> entities, bool includeParticipants = false) | |
| 51 | + => entities.Select(t => Create(t, includeParticipants)).ToList(); | |
| 52 | + | |
| 53 | + public static Trip ToEntity(TripBllDto dto) => new() | |
| 54 | + { | |
| 55 | + Id = dto.Id, | |
| 56 | + Name = dto.Name, | |
| 57 | + Description = dto.Description, | |
| 58 | + Destination = dto.Destination, | |
| 59 | + StartDate = dto.StartDate, | |
| 60 | + EndDate = dto.EndDate, | |
| 61 | + Status = dto.Status, | |
| 62 | + DefaultCurrencyId = dto.DefaultCurrencyId, | |
| 63 | + CreatedById = dto.CreatedById | |
| 64 | + }; | |
| 65 | +} | |
| 66 | + | |
| 67 | +public static class TripParticipantBllDtoFactory | |
| 68 | +{ | |
| 69 | + public static TripParticipantBllDto Create(TripParticipant entity) => new() | |
| 70 | + { | |
| 71 | + Id = entity.Id, | |
| 72 | + CreatedAt = entity.CreatedAt, | |
| 73 | + UpdatedAt = entity.UpdatedAt, | |
| 74 | + TripId = entity.TripId, | |
| 75 | + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null, | |
| 76 | + UserId = entity.UserId, | |
| 77 | + User = entity.User != null ? new AppUserBllDto | |
| 78 | + { | |
| 79 | + Id = entity.User.Id, | |
| 80 | + FirstName = entity.User.FirstName, | |
| 81 | + LastName = entity.User.LastName, | |
| 82 | + Email = entity.User.Email | |
| 83 | + } : null, | |
| 84 | + Role = entity.Role, | |
| 85 | + Nickname = entity.Nickname, | |
| 86 | + JoinedAt = entity.JoinedAt, | |
| 87 | + LeftAt = entity.LeftAt, | |
| 88 | + IsActive = entity.IsActive | |
| 89 | + }; | |
| 90 | + | |
| 91 | + public static List<TripParticipantBllDto> CreateList(IEnumerable<TripParticipant> entities) | |
| 92 | + => entities.Select(Create).ToList(); | |
| 93 | + | |
| 94 | + public static TripParticipant ToEntity(TripParticipantBllDto dto) => new() | |
| 95 | + { | |
| 96 | + Id = dto.Id, | |
| 97 | + TripId = dto.TripId, | |
| 98 | + UserId = dto.UserId, | |
| 99 | + Role = dto.Role, | |
| 100 | + Nickname = dto.Nickname, | |
| 101 | + JoinedAt = dto.JoinedAt, | |
| 102 | + LeftAt = dto.LeftAt, | |
| 103 | + IsActive = dto.IsActive | |
| 104 | + }; | |
| 105 | +} |
added SplitApp/App.BLL/Mappers/WishlistBllDtoFactory.cs +56 −0
| @@ -0,0 +1,56 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Mappers; | |
| 5 | + | |
| 6 | +public static class WishlistBllDtoFactory | |
| 7 | +{ | |
| 8 | + public static TripWishlistItemBllDto Create(TripWishlistItem entity) => new() | |
| 9 | + { | |
| 10 | + Id = entity.Id, | |
| 11 | + CreatedAt = entity.CreatedAt, | |
| 12 | + UpdatedAt = entity.UpdatedAt, | |
| 13 | + TripId = entity.TripId, | |
| 14 | + Trip = entity.Trip != null ? TripBllDtoFactory.Create(entity.Trip) : null, | |
| 15 | + AddedByUserId = entity.AddedByUserId, | |
| 16 | + AddedByUser = entity.AddedByUser != null ? new AppUserBllDto | |
| 17 | + { | |
| 18 | + Id = entity.AddedByUser.Id, | |
| 19 | + FirstName = entity.AddedByUser.FirstName, | |
| 20 | + LastName = entity.AddedByUser.LastName, | |
| 21 | + Email = entity.AddedByUser.Email | |
| 22 | + } : null, | |
| 23 | + Title = entity.Title, | |
| 24 | + Description = entity.Description, | |
| 25 | + Category = entity.Category, | |
| 26 | + Priority = entity.Priority, | |
| 27 | + EstimatedCost = entity.EstimatedCost, | |
| 28 | + Url = entity.Url, | |
| 29 | + Location = entity.Location, | |
| 30 | + IsCompleted = entity.IsCompleted, | |
| 31 | + CompletedAt = entity.CompletedAt, | |
| 32 | + DisplayOrder = entity.DisplayOrder, | |
| 33 | + VoteCount = entity.Votes?.Count(v => v.IsInterested) ?? 0, | |
| 34 | + VoterUserIds = entity.Votes?.Where(v => v.IsInterested).Select(v => v.UserId).ToList() ?? new List<Guid>() | |
| 35 | + }; | |
| 36 | + | |
| 37 | + public static List<TripWishlistItemBllDto> CreateList(IEnumerable<TripWishlistItem> entities) | |
| 38 | + => entities.Select(Create).ToList(); | |
| 39 | + | |
| 40 | + public static TripWishlistItem ToEntity(TripWishlistItemBllDto dto) => new() | |
| 41 | + { | |
| 42 | + Id = dto.Id, | |
| 43 | + TripId = dto.TripId, | |
| 44 | + AddedByUserId = dto.AddedByUserId, | |
| 45 | + Title = dto.Title, | |
| 46 | + Description = dto.Description, | |
| 47 | + Category = dto.Category, | |
| 48 | + Priority = dto.Priority, | |
| 49 | + EstimatedCost = dto.EstimatedCost, | |
| 50 | + Url = dto.Url, | |
| 51 | + Location = dto.Location, | |
| 52 | + IsCompleted = dto.IsCompleted, | |
| 53 | + CompletedAt = dto.CompletedAt, | |
| 54 | + DisplayOrder = dto.DisplayOrder | |
| 55 | + }; | |
| 56 | +} |
added SplitApp/App.BLL/Services/Admin/AdminDashboardData.cs +66 −0
| @@ -0,0 +1,66 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/AdminStatsService.cs +199 −0
| @@ -0,0 +1,199 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | + | |
| 6 | +namespace App.BLL.Services.Admin; | |
| 7 | + | |
| 8 | +public class AdminStatsService : IAdminStatsService | |
| 9 | +{ | |
| 10 | + private readonly IAppUnitOfWork _uow; | |
| 11 | + | |
| 12 | + public AdminStatsService(IAppUnitOfWork uow) | |
| 13 | + { | |
| 14 | + _uow = uow; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task<AdminDashboardData> GetDashboardStatsAsync() | |
| 18 | + { | |
| 19 | + var trips = (await _uow.Trips.GetAllAsync()).ToList(); | |
| 20 | + var expenses = (await _uow.Expenses.GetAllAsync()).ToList(); | |
| 21 | + var budgetCategories = (await _uow.BudgetCategories.GetAllAsync()).ToList(); | |
| 22 | + var settlementPlans = (await _uow.SettlementPlans.GetAllAsync()).ToList(); | |
| 23 | + var wishlistItems = (await _uow.TripWishlistItems.GetAllAsync()).ToList(); | |
| 24 | + var polls = (await _uow.TripPolls.GetAllAsync()).ToList(); | |
| 25 | + var invitations = (await _uow.TripInvitations.GetAllAsync()).ToList(); | |
| 26 | + var currencies = (await _uow.GetRepository<Currency>().GetAllAsync()).ToList(); | |
| 27 | + var participants = (await _uow.TripParticipants.GetAllAsync()).ToList(); | |
| 28 | + var settlementPayments = (await _uow.SettlementPayments.GetAllAsync()).ToList(); | |
| 29 | + var allUsers = (await _uow.Users.GetAllAsync()).ToList(); | |
| 30 | + | |
| 31 | + // === Counts === | |
| 32 | + var tripCount = trips.Count; | |
| 33 | + var userCount = allUsers.Count; | |
| 34 | + var expenseCount = expenses.Count; | |
| 35 | + | |
| 36 | + // === Trip status breakdown === | |
| 37 | + var activeTrips = trips.Count(t => t.Status == ETripStatus.Active); | |
| 38 | + var settledTrips = trips.Count(t => t.Status == ETripStatus.Settled); | |
| 39 | + var archivedTrips = trips.Count(t => t.Status == ETripStatus.Archived); | |
| 40 | + | |
| 41 | + var totalExpenseAmount = expenses.Sum(e => e.Amount); | |
| 42 | + | |
| 43 | + var pendingSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.Pending); | |
| 44 | + var inProgressSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.InProgress); | |
| 45 | + var completedSettlements = settlementPlans.Count(s => s.Status == ESettlementStatus.Completed); | |
| 46 | + | |
| 47 | + var recentTrips = TripBllDtoFactory.CreateList(trips.OrderByDescending(t => t.CreatedAt).Take(5)); | |
| 48 | + var recentExpenses = ExpenseBllDtoFactory.CreateList(expenses.OrderByDescending(e => e.ExpenseDate).Take(8)); | |
| 49 | + var recentUsers = (await _uow.Users.GetRecentAsync(5)).Select(u => new AppUserBllDto | |
| 50 | + { | |
| 51 | + Id = u.Id, | |
| 52 | + FirstName = u.FirstName, | |
| 53 | + LastName = u.LastName, | |
| 54 | + Email = u.Email | |
| 55 | + }).ToList(); | |
| 56 | + | |
| 57 | + var pendingInvitations = invitations.Count(i => i.Status == EInvitationStatus.Pending); | |
| 58 | + var pendingPayments = settlementPayments.Count(p => p.Status == EPaymentStatus.Pending); | |
| 59 | + var markedPaidPayments = settlementPayments.Count(p => p.Status == EPaymentStatus.MarkedPaid); | |
| 60 | + | |
| 61 | + // === Top Active Trips === | |
| 62 | + var topActiveTrips = trips | |
| 63 | + .Where(t => t.Status == ETripStatus.Active) | |
| 64 | + .Select(t => | |
| 65 | + { | |
| 66 | + var tripExpenses = expenses.Where(e => e.TripId == t.Id).ToList(); | |
| 67 | + return new AdminTopActiveTripItem | |
| 68 | + { | |
| 69 | + Trip = TripBllDtoFactory.Create(t), | |
| 70 | + ParticipantCount = participants.Count(p => p.TripId == t.Id), | |
| 71 | + ExpenseSum = tripExpenses.Sum(e => e.Amount), | |
| 72 | + ExpenseCount = tripExpenses.Count | |
| 73 | + }; | |
| 74 | + }) | |
| 75 | + .OrderByDescending(x => x.ExpenseSum) | |
| 76 | + .ThenByDescending(x => x.ParticipantCount) | |
| 77 | + .Take(5) | |
| 78 | + .ToList(); | |
| 79 | + | |
| 80 | + // === Top 10 biggest expenses === | |
| 81 | + var biggestExpensesEntities = expenses | |
| 82 | + .OrderByDescending(e => e.Amount) | |
| 83 | + .Take(10) | |
| 84 | + .ToList(); | |
| 85 | + foreach (var e in biggestExpensesEntities) | |
| 86 | + { | |
| 87 | + e.Trip ??= trips.FirstOrDefault(t => t.Id == e.TripId); | |
| 88 | + e.PaidByUser ??= allUsers.FirstOrDefault(u => u.Id == e.PaidByUserId); | |
| 89 | + } | |
| 90 | + var biggestExpenses = ExpenseBllDtoFactory.CreateList(biggestExpensesEntities); | |
| 91 | + | |
| 92 | + // === Active users in last N days === | |
| 93 | + var now = DateTime.UtcNow; | |
| 94 | + var cutoff7 = now.AddDays(-7); | |
| 95 | + var cutoff30 = now.AddDays(-30); | |
| 96 | + | |
| 97 | + var activeUserIds7 = expenses.Where(e => e.CreatedAt >= cutoff7) | |
| 98 | + .Select(e => e.PaidByUserId).Distinct().Count(); | |
| 99 | + var activeUserIds30 = expenses.Where(e => e.CreatedAt >= cutoff30) | |
| 100 | + .Select(e => e.PaidByUserId).Distinct().Count(); | |
| 101 | + | |
| 102 | + // === Top 5 most active users by expense count === | |
| 103 | + var topActiveUsers = expenses | |
| 104 | + .GroupBy(e => e.PaidByUserId) | |
| 105 | + .Select(g => | |
| 106 | + { | |
| 107 | + var user = allUsers.FirstOrDefault(u => u.Id == g.Key); | |
| 108 | + return new AdminTopActiveUserItem | |
| 109 | + { | |
| 110 | + UserId = g.Key, | |
| 111 | + Email = user?.Email ?? "—", | |
| 112 | + FullName = user != null ? $"{user.FirstName} {user.LastName}".Trim() : "—", | |
| 113 | + ExpenseCount = g.Count(), | |
| 114 | + TotalAmount = g.Sum(e => e.Amount) | |
| 115 | + }; | |
| 116 | + }) | |
| 117 | + .OrderByDescending(x => x.ExpenseCount) | |
| 118 | + .Take(5) | |
| 119 | + .ToList(); | |
| 120 | + | |
| 121 | + // === Activity feed (chronological) === | |
| 122 | + var feed = new List<AdminActivityFeedItem>(); | |
| 123 | + foreach (var t in trips) | |
| 124 | + { | |
| 125 | + feed.Add(new AdminActivityFeedItem | |
| 126 | + { | |
| 127 | + Type = "trip", | |
| 128 | + Date = t.CreatedAt, | |
| 129 | + MessageKey = "Trip \"{0}\" created", | |
| 130 | + MessageArgs = new object[] { t.Name }, | |
| 131 | + IconCssClass = "bi-suitcase-lg", | |
| 132 | + BadgeCssClass = "bg-primary" | |
| 133 | + }); | |
| 134 | + } | |
| 135 | + foreach (var e in expenses.OrderByDescending(x => x.CreatedAt).Take(30)) | |
| 136 | + { | |
| 137 | + var tripName = trips.FirstOrDefault(t => t.Id == e.TripId)?.Name ?? "?"; | |
| 138 | + feed.Add(new AdminActivityFeedItem | |
| 139 | + { | |
| 140 | + Type = "expense", | |
| 141 | + Date = e.CreatedAt, | |
| 142 | + MessageKey = "Expense {0:0.00} added to \"{1}\"", | |
| 143 | + MessageArgs = new object[] { e.Amount, tripName }, | |
| 144 | + IconCssClass = "bi-cash-coin", | |
| 145 | + BadgeCssClass = "bg-success" | |
| 146 | + }); | |
| 147 | + } | |
| 148 | + foreach (var s in settlementPlans) | |
| 149 | + { | |
| 150 | + var tripName = trips.FirstOrDefault(t => t.Id == s.TripId)?.Name ?? "?"; | |
| 151 | + feed.Add(new AdminActivityFeedItem | |
| 152 | + { | |
| 153 | + Type = "settlement", | |
| 154 | + Date = s.CreatedAt, | |
| 155 | + MessageKey = "Settlement plan for \"{0}\" ({1})", | |
| 156 | + MessageArgs = new object[] { tripName, s.Status }, | |
| 157 | + IconCssClass = "bi-diagram-3", | |
| 158 | + BadgeCssClass = "bg-info" | |
| 159 | + }); | |
| 160 | + } | |
| 161 | + var activityFeed = feed.OrderByDescending(f => f.Date).Take(15).ToList(); | |
| 162 | + | |
| 163 | + return new AdminDashboardData | |
| 164 | + { | |
| 165 | + TripCount = tripCount, | |
| 166 | + UserCount = userCount, | |
| 167 | + ExpenseCount = expenseCount, | |
| 168 | + CategoryCount = budgetCategories.Count, | |
| 169 | + SettlementCount = settlementPlans.Count, | |
| 170 | + WishlistCount = wishlistItems.Count, | |
| 171 | + PollCount = polls.Count, | |
| 172 | + InvitationCount = invitations.Count, | |
| 173 | + CurrencyCount = currencies.Count, | |
| 174 | + ParticipantCount = participants.Count, | |
| 175 | + | |
| 176 | + ActiveTrips = activeTrips, | |
| 177 | + SettledTrips = settledTrips, | |
| 178 | + ArchivedTrips = archivedTrips, | |
| 179 | + TotalExpenseAmount = totalExpenseAmount, | |
| 180 | + PendingSettlements = pendingSettlements, | |
| 181 | + InProgressSettlements = inProgressSettlements, | |
| 182 | + CompletedSettlements = completedSettlements, | |
| 183 | + PendingInvitations = pendingInvitations, | |
| 184 | + PendingPayments = pendingPayments, | |
| 185 | + MarkedPaidPayments = markedPaidPayments, | |
| 186 | + | |
| 187 | + RecentTrips = recentTrips, | |
| 188 | + RecentExpenses = recentExpenses, | |
| 189 | + RecentUsers = recentUsers, | |
| 190 | + | |
| 191 | + TopActiveTrips = topActiveTrips, | |
| 192 | + BiggestExpenses = biggestExpenses, | |
| 193 | + NewUsersLast7Days = activeUserIds7, | |
| 194 | + NewUsersLast30Days = activeUserIds30, | |
| 195 | + TopActiveUsers = topActiveUsers, | |
| 196 | + ActivityFeed = activityFeed | |
| 197 | + }; | |
| 198 | + } | |
| 199 | +} |
added SplitApp/App.BLL/Services/Admin/BudgetCategoryAdminService.cs +76 −0
| @@ -0,0 +1,76 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | +using Base.Domain; | |
| 6 | + | |
| 7 | +namespace App.BLL.Services.Admin; | |
| 8 | + | |
| 9 | +public class BudgetCategoryAdminService : IBudgetCategoryAdminService | |
| 10 | +{ | |
| 11 | + private readonly IAppUnitOfWork _uow; | |
| 12 | + | |
| 13 | + public BudgetCategoryAdminService(IAppUnitOfWork uow) | |
| 14 | + { | |
| 15 | + _uow = uow; | |
| 16 | + } | |
| 17 | + | |
| 18 | + public async Task<List<BudgetCategoryBllDto>> GetAllAsync(Guid? tripId, string? search) | |
| 19 | + { | |
| 20 | + var items = (await _uow.BudgetCategories.GetAllAsync()).ToList(); | |
| 21 | + if (tripId.HasValue) | |
| 22 | + items = items.Where(b => b.TripId == tripId.Value).ToList(); | |
| 23 | + if (!string.IsNullOrEmpty(search)) | |
| 24 | + items = items.Where(b => b.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); | |
| 25 | + return BudgetCategoryBllDtoFactory.CreateList(items.OrderBy(b => b.DisplayOrder)); | |
| 26 | + } | |
| 27 | + | |
| 28 | + public async Task<List<TripBllDto>> GetAllTripsAsync() | |
| 29 | + { | |
| 30 | + var trips = await _uow.Trips.GetAllAsync(); | |
| 31 | + return TripBllDtoFactory.CreateList(trips.OrderBy(t => t.Name)); | |
| 32 | + } | |
| 33 | + | |
| 34 | + public async Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id) | |
| 35 | + { | |
| 36 | + var category = await _uow.BudgetCategories.GetByIdAsync(id); | |
| 37 | + return category == null ? null : BudgetCategoryBllDtoFactory.Create(category); | |
| 38 | + } | |
| 39 | + | |
| 40 | + public async Task CreateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt) | |
| 41 | + { | |
| 42 | + var domainEntity = BudgetCategoryBllDtoFactory.ToEntity(entity); | |
| 43 | + ApplyLangStr(domainEntity, nameEn, nameEt); | |
| 44 | + domainEntity.Id = Guid.NewGuid(); | |
| 45 | + _uow.BudgetCategories.Add(domainEntity); | |
| 46 | + await _uow.SaveChangesAsync(); | |
| 47 | + } | |
| 48 | + | |
| 49 | + public async Task UpdateAsync(BudgetCategoryBllDto entity, string? nameEn, string? nameEt) | |
| 50 | + { | |
| 51 | + var existing = await _uow.BudgetCategories.GetByIdAsync(entity.Id); | |
| 52 | + if (existing == null) return; | |
| 53 | + existing.TripId = entity.TripId; | |
| 54 | + existing.IconName = entity.IconName; | |
| 55 | + existing.PlannedAmount = entity.PlannedAmount; | |
| 56 | + existing.DisplayOrder = entity.DisplayOrder; | |
| 57 | + ApplyLangStr(existing, nameEn, nameEt); | |
| 58 | + _uow.BudgetCategories.Update(existing); | |
| 59 | + await _uow.SaveChangesAsync(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + public async Task DeleteAsync(Guid id) | |
| 63 | + { | |
| 64 | + await _uow.BudgetCategories.RemoveAsync(id); | |
| 65 | + await _uow.SaveChangesAsync(); | |
| 66 | + } | |
| 67 | + | |
| 68 | + public Task<bool> ExistsAsync(Guid id) => _uow.BudgetCategories.ExistsAsync(id); | |
| 69 | + | |
| 70 | + private static void ApplyLangStr(BudgetCategory entity, string? nameEn, string? nameEt) | |
| 71 | + { | |
| 72 | + var name = new LangStr(nameEn ?? "", "en"); | |
| 73 | + name.SetTranslation(nameEt ?? nameEn ?? "", "et"); | |
| 74 | + entity.Name = name; | |
| 75 | + } | |
| 76 | +} |
added SplitApp/App.BLL/Services/Admin/CurrencyAdminService.cs +68 −0
| @@ -0,0 +1,68 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | +using Base.Domain; | |
| 6 | + | |
| 7 | +namespace App.BLL.Services.Admin; | |
| 8 | + | |
| 9 | +public class CurrencyAdminService : ICurrencyAdminService | |
| 10 | +{ | |
| 11 | + private readonly IAppUnitOfWork _uow; | |
| 12 | + | |
| 13 | + public CurrencyAdminService(IAppUnitOfWork uow) | |
| 14 | + { | |
| 15 | + _uow = uow; | |
| 16 | + } | |
| 17 | + | |
| 18 | + public async Task<List<CurrencyBllDto>> GetAllAsync(string? search) | |
| 19 | + { | |
| 20 | + var items = (await _uow.GetRepository<Currency>().GetAllAsync()).OrderBy(c => c.Code).ToList(); | |
| 21 | + if (!string.IsNullOrEmpty(search)) | |
| 22 | + items = items.Where(c => | |
| 23 | + c.Code.Contains(search, StringComparison.OrdinalIgnoreCase) || | |
| 24 | + c.Name.ToString().Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); | |
| 25 | + return CurrencyBllDtoFactory.CreateList(items); | |
| 26 | + } | |
| 27 | + | |
| 28 | + public async Task<CurrencyBllDto?> GetByIdAsync(Guid id) | |
| 29 | + { | |
| 30 | + var entity = await _uow.GetRepository<Currency>().GetByIdAsync(id); | |
| 31 | + return entity == null ? null : CurrencyBllDtoFactory.Create(entity); | |
| 32 | + } | |
| 33 | + | |
| 34 | + public async Task CreateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt) | |
| 35 | + { | |
| 36 | + var domainEntity = CurrencyBllDtoFactory.ToEntity(entity); | |
| 37 | + ApplyLangStr(domainEntity, nameEn, nameEt); | |
| 38 | + domainEntity.Id = Guid.NewGuid(); | |
| 39 | + _uow.GetRepository<Currency>().Add(domainEntity); | |
| 40 | + await _uow.SaveChangesAsync(); | |
| 41 | + } | |
| 42 | + | |
| 43 | + public async Task UpdateAsync(CurrencyBllDto entity, string? nameEn, string? nameEt) | |
| 44 | + { | |
| 45 | + var existing = await _uow.GetRepository<Currency>().GetByIdAsync(entity.Id); | |
| 46 | + if (existing == null) return; | |
| 47 | + existing.Code = entity.Code; | |
| 48 | + existing.Symbol = entity.Symbol; | |
| 49 | + ApplyLangStr(existing, nameEn, nameEt); | |
| 50 | + _uow.GetRepository<Currency>().Update(existing); | |
| 51 | + await _uow.SaveChangesAsync(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + public async Task DeleteAsync(Guid id) | |
| 55 | + { | |
| 56 | + await _uow.GetRepository<Currency>().RemoveAsync(id); | |
| 57 | + await _uow.SaveChangesAsync(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + public Task<bool> ExistsAsync(Guid id) => _uow.GetRepository<Currency>().ExistsAsync(id); | |
| 61 | + | |
| 62 | + private static void ApplyLangStr(Currency entity, string? nameEn, string? nameEt) | |
| 63 | + { | |
| 64 | + var name = new LangStr(nameEn ?? "", "en"); | |
| 65 | + name.SetTranslation(nameEt ?? nameEn ?? "", "et"); | |
| 66 | + entity.Name = name; | |
| 67 | + } | |
| 68 | +} |
added SplitApp/App.BLL/Services/Admin/ExpenseAdminService.cs +95 −0
| @@ -0,0 +1,95 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | +using App.Domain.Identity; | |
| 6 | + | |
| 7 | +namespace App.BLL.Services.Admin; | |
| 8 | + | |
| 9 | +public class ExpenseAdminService : IExpenseAdminService | |
| 10 | +{ | |
| 11 | + private readonly IAppUnitOfWork _uow; | |
| 12 | + | |
| 13 | + public ExpenseAdminService(IAppUnitOfWork uow) | |
| 14 | + { | |
| 15 | + _uow = uow; | |
| 16 | + } | |
| 17 | + | |
| 18 | + public async Task<List<ExpenseBllDto>> GetAllAsync(Guid? tripId, string? search) | |
| 19 | + { | |
| 20 | + var items = (await _uow.Expenses.GetAllAsync()).ToList(); | |
| 21 | + if (tripId.HasValue) | |
| 22 | + items = items.Where(e => e.TripId == tripId.Value).ToList(); | |
| 23 | + if (!string.IsNullOrEmpty(search)) | |
| 24 | + items = items.Where(e => e.Description != null && e.Description.Contains(search)).ToList(); | |
| 25 | + return ExpenseBllDtoFactory.CreateList(items.OrderByDescending(e => e.ExpenseDate)); | |
| 26 | + } | |
| 27 | + | |
| 28 | + public async Task<ExpenseBllDto?> GetByIdAsync(Guid id) | |
| 29 | + { | |
| 30 | + var entity = await _uow.Expenses.GetByIdAsync(id); | |
| 31 | + return entity == null ? null : ExpenseBllDtoFactory.Create(entity); | |
| 32 | + } | |
| 33 | + | |
| 34 | + public async Task CreateAsync(ExpenseBllDto entity) | |
| 35 | + { | |
| 36 | + var domainEntity = ExpenseBllDtoFactory.ToEntity(entity); | |
| 37 | + domainEntity.Id = Guid.NewGuid(); | |
| 38 | + _uow.Expenses.Add(domainEntity); | |
| 39 | + await _uow.SaveChangesAsync(); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public async Task UpdateAsync(ExpenseBllDto entity) | |
| 43 | + { | |
| 44 | + var existing = await _uow.Expenses.GetByIdAsync(entity.Id); | |
| 45 | + if (existing == null) return; | |
| 46 | + existing.TripId = entity.TripId; | |
| 47 | + existing.PaidByUserId = entity.PaidByUserId; | |
| 48 | + existing.BudgetCategoryId = entity.BudgetCategoryId; | |
| 49 | + existing.CurrencyId = entity.CurrencyId; | |
| 50 | + existing.Amount = entity.Amount; | |
| 51 | + existing.Description = entity.Description; | |
| 52 | + existing.ExpenseDate = entity.ExpenseDate; | |
| 53 | + existing.SplitMethod = entity.SplitMethod; | |
| 54 | + _uow.Expenses.Update(existing); | |
| 55 | + await _uow.SaveChangesAsync(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + public async Task DeleteAsync(Guid id) | |
| 59 | + { | |
| 60 | + await _uow.Expenses.RemoveAsync(id); | |
| 61 | + await _uow.SaveChangesAsync(); | |
| 62 | + } | |
| 63 | + | |
| 64 | + public Task<bool> ExistsAsync(Guid id) => _uow.Expenses.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 | + | |
| 84 | + public async Task<List<CurrencyBllDto>> GetCurrenciesAsync() | |
| 85 | + { | |
| 86 | + var currencies = await _uow.GetRepository<Currency>().GetAllAsync(); | |
| 87 | + return CurrencyBllDtoFactory.CreateList(currencies); | |
| 88 | + } | |
| 89 | + | |
| 90 | + public async Task<List<BudgetCategoryBllDto>> GetBudgetCategoriesAsync() | |
| 91 | + { | |
| 92 | + var categories = await _uow.BudgetCategories.GetAllAsync(); | |
| 93 | + return BudgetCategoryBllDtoFactory.CreateList(categories); | |
| 94 | + } | |
| 95 | +} |
added SplitApp/App.BLL/Services/Admin/IAdminStatsService.cs +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +namespace App.BLL.Services.Admin; | |
| 2 | + | |
| 3 | +public interface IAdminStatsService | |
| 4 | +{ | |
| 5 | + Task<AdminDashboardData> GetDashboardStatsAsync(); | |
| 6 | +} |
added SplitApp/App.BLL/Services/Admin/IBudgetCategoryAdminService.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/ICurrencyAdminService.cs +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/IExpenseAdminService.cs +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/IInvitationAdminService.cs +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/IPollAdminService.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/ISettlementPaymentAdminService.cs +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/ISettlementPlanAdminService.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/ISplitPresetAdminService.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/ITripAdminService.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/ITripParticipantAdminService.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/IWishlistAdminService.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Admin/InvitationAdminService.cs +77 −0
| @@ -0,0 +1,77 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | + | |
| 5 | +namespace App.BLL.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/App.BLL/Services/Admin/PollAdminService.cs +77 −0
| @@ -0,0 +1,77 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | + | |
| 5 | +namespace App.BLL.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/App.BLL/Services/Admin/SettlementPaymentAdminService.cs +78 −0
| @@ -0,0 +1,78 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | + | |
| 5 | +namespace App.BLL.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/App.BLL/Services/Admin/SettlementPlanAdminService.cs +76 −0
| @@ -0,0 +1,76 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | + | |
| 5 | +namespace App.BLL.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/App.BLL/Services/Admin/SplitPresetAdminService.cs +61 −0
| @@ -0,0 +1,61 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | + | |
| 5 | +namespace App.BLL.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/App.BLL/Services/Admin/TripAdminService.cs +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | + | |
| 6 | +namespace App.BLL.Services.Admin; | |
| 7 | + | |
| 8 | +public class TripAdminService : ITripAdminService | |
| 9 | +{ | |
| 10 | + private readonly IAppUnitOfWork _uow; | |
| 11 | + | |
| 12 | + public TripAdminService(IAppUnitOfWork uow) | |
| 13 | + { | |
| 14 | + _uow = uow; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task<List<TripBllDto>> GetAllAsync(string? search) | |
| 18 | + { | |
| 19 | + var items = (await _uow.Trips.GetAllAsync()).ToList(); | |
| 20 | + if (!string.IsNullOrEmpty(search)) | |
| 21 | + items = items.Where(t => t.Name.Contains(search)).ToList(); | |
| 22 | + return TripBllDtoFactory.CreateList(items.OrderByDescending(t => t.CreatedAt)); | |
| 23 | + } | |
| 24 | + | |
| 25 | + public async Task<List<CurrencyBllDto>> GetAllCurrenciesAsync() | |
| 26 | + { | |
| 27 | + var currencies = await _uow.GetRepository<Currency>().GetAllAsync(); | |
| 28 | + return CurrencyBllDtoFactory.CreateList(currencies); | |
| 29 | + } | |
| 30 | + | |
| 31 | + public async Task<TripBllDto?> GetByIdAsync(Guid id) | |
| 32 | + { | |
| 33 | + var trip = await _uow.Trips.GetByIdAsync(id); | |
| 34 | + return trip == null ? null : TripBllDtoFactory.Create(trip); | |
| 35 | + } | |
| 36 | + | |
| 37 | + public async Task CreateAsync(TripBllDto entity) | |
| 38 | + { | |
| 39 | + var domainEntity = TripBllDtoFactory.ToEntity(entity); | |
| 40 | + domainEntity.Id = Guid.NewGuid(); | |
| 41 | + _uow.Trips.Add(domainEntity); | |
| 42 | + await _uow.SaveChangesAsync(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + public async Task UpdateAsync(TripBllDto entity) | |
| 46 | + { | |
| 47 | + var existing = await _uow.Trips.GetByIdAsync(entity.Id); | |
| 48 | + if (existing == null) return; | |
| 49 | + existing.Name = entity.Name; | |
| 50 | + existing.Description = entity.Description; | |
| 51 | + existing.Destination = entity.Destination; | |
| 52 | + existing.StartDate = entity.StartDate; | |
| 53 | + existing.EndDate = entity.EndDate; | |
| 54 | + existing.Status = entity.Status; | |
| 55 | + existing.DefaultCurrencyId = entity.DefaultCurrencyId; | |
| 56 | + _uow.Trips.Update(existing); | |
| 57 | + await _uow.SaveChangesAsync(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + public async Task DeleteAsync(Guid id) | |
| 61 | + { | |
| 62 | + await _uow.Trips.RemoveAsync(id); | |
| 63 | + await _uow.SaveChangesAsync(); | |
| 64 | + } | |
| 65 | + | |
| 66 | + public Task<bool> ExistsAsync(Guid id) => _uow.Trips.ExistsAsync(id); | |
| 67 | +} |
added SplitApp/App.BLL/Services/Admin/TripParticipantAdminService.cs +86 −0
| @@ -0,0 +1,86 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | + | |
| 5 | +namespace App.BLL.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/App.BLL/Services/Admin/WishlistAdminService.cs +83 −0
| @@ -0,0 +1,83 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | + | |
| 5 | +namespace App.BLL.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/App.BLL/Services/BudgetCategoryService.cs +80 −0
| @@ -0,0 +1,80 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | + | |
| 5 | +namespace App.BLL.Services; | |
| 6 | + | |
| 7 | +public class BudgetCategoryService : IBudgetCategoryService | |
| 8 | +{ | |
| 9 | + private readonly IAppUnitOfWork _uow; | |
| 10 | + | |
| 11 | + public BudgetCategoryService(IAppUnitOfWork uow) | |
| 12 | + { | |
| 13 | + _uow = uow; | |
| 14 | + } | |
| 15 | + | |
| 16 | + public async Task<List<BudgetCategoryBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 17 | + { | |
| 18 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 19 | + return new List<BudgetCategoryBllDto>(); | |
| 20 | + var categories = await _uow.BudgetCategories.GetByTripIdAsync(tripId); | |
| 21 | + return BudgetCategoryBllDtoFactory.CreateList(categories); | |
| 22 | + } | |
| 23 | + | |
| 24 | + public async Task<List<BudgetCategoryBllDto>> GetByTripIdRawAsync(Guid tripId) | |
| 25 | + { | |
| 26 | + var categories = await _uow.BudgetCategories.GetByTripIdAsync(tripId); | |
| 27 | + return BudgetCategoryBllDtoFactory.CreateList(categories); | |
| 28 | + } | |
| 29 | + | |
| 30 | + public async Task<BudgetCategoryBllDto?> GetByIdAsync(Guid id) | |
| 31 | + { | |
| 32 | + var category = await _uow.BudgetCategories.GetByIdAsync(id); | |
| 33 | + return category == null ? null : BudgetCategoryBllDtoFactory.Create(category); | |
| 34 | + } | |
| 35 | + | |
| 36 | + public async Task<(BudgetCategoryBllDto? category, string? errorCode)> CreateAsync(BudgetCategoryBllDto category, Guid userId) | |
| 37 | + { | |
| 38 | + if (!await _uow.TripParticipants.IsOrganizerAsync(category.TripId, userId)) | |
| 39 | + return (null, "forbidden"); | |
| 40 | + | |
| 41 | + var entity = BudgetCategoryBllDtoFactory.ToEntity(category); | |
| 42 | + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid(); | |
| 43 | + _uow.BudgetCategories.Add(entity); | |
| 44 | + await _uow.SaveChangesAsync(); | |
| 45 | + | |
| 46 | + var reloaded = await _uow.BudgetCategories.GetByIdAsync(entity.Id); | |
| 47 | + return (reloaded == null ? null : BudgetCategoryBllDtoFactory.Create(reloaded), null); | |
| 48 | + } | |
| 49 | + | |
| 50 | + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, BudgetCategoryBllDto incoming, Guid userId) | |
| 51 | + { | |
| 52 | + var existing = await _uow.BudgetCategories.GetByIdAsync(id); | |
| 53 | + if (existing == null) return (false, "notfound"); | |
| 54 | + | |
| 55 | + if (!await _uow.TripParticipants.IsOrganizerAsync(existing.TripId, userId)) | |
| 56 | + return (false, "forbidden"); | |
| 57 | + | |
| 58 | + existing.Name = incoming.Name; | |
| 59 | + existing.IconName = incoming.IconName; | |
| 60 | + existing.PlannedAmount = incoming.PlannedAmount; | |
| 61 | + existing.DisplayOrder = incoming.DisplayOrder; | |
| 62 | + | |
| 63 | + _uow.BudgetCategories.Update(existing); | |
| 64 | + await _uow.SaveChangesAsync(); | |
| 65 | + return (true, null); | |
| 66 | + } | |
| 67 | + | |
| 68 | + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId) | |
| 69 | + { | |
| 70 | + var existing = await _uow.BudgetCategories.GetByIdAsync(id); | |
| 71 | + if (existing == null) return (false, "notfound"); | |
| 72 | + | |
| 73 | + if (!await _uow.TripParticipants.IsOrganizerAsync(existing.TripId, userId)) | |
| 74 | + return (false, "forbidden"); | |
| 75 | + | |
| 76 | + await _uow.BudgetCategories.RemoveAsync(id); | |
| 77 | + await _uow.SaveChangesAsync(); | |
| 78 | + return (true, null); | |
| 79 | + } | |
| 80 | +} |
added SplitApp/App.BLL/Services/ExpenseService.cs +344 −0
| @@ -0,0 +1,344 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | + | |
| 6 | +namespace App.BLL.Services; | |
| 7 | + | |
| 8 | +public class ExpenseService : IExpenseService | |
| 9 | +{ | |
| 10 | + private readonly IAppUnitOfWork _uow; | |
| 11 | + | |
| 12 | + public ExpenseService(IAppUnitOfWork uow) | |
| 13 | + { | |
| 14 | + _uow = uow; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task<ExpenseBllDto> CreateExpenseWithSplitsAsync(ExpenseBllDto expense, Guid[] participants, decimal[] amounts, decimal[] percentages) | |
| 18 | + { | |
| 19 | + var entity = ExpenseBllDtoFactory.ToEntity(expense); | |
| 20 | + entity.Id = Guid.NewGuid(); | |
| 21 | + _uow.Expenses.Add(entity); | |
| 22 | + | |
| 23 | + var splitRepo = _uow.GetRepository<ExpenseSplit>(); | |
| 24 | + | |
| 25 | + switch (entity.SplitMethod) | |
| 26 | + { | |
| 27 | + case ESplitMethod.EqualAll: | |
| 28 | + { | |
| 29 | + var allParticipants = (await _uow.TripParticipants.GetByTripIdAsync(entity.TripId)).ToList(); | |
| 30 | + var count = allParticipants.Count; | |
| 31 | + if (count > 0) | |
| 32 | + { | |
| 33 | + var baseAmount = Math.Floor(entity.Amount / count * 100) / 100; | |
| 34 | + var remainder = entity.Amount - baseAmount * count; | |
| 35 | + | |
| 36 | + for (var i = 0; i < allParticipants.Count; i++) | |
| 37 | + { | |
| 38 | + var amount = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0); | |
| 39 | + splitRepo.Add(new ExpenseSplit | |
| 40 | + { | |
| 41 | + Id = Guid.NewGuid(), | |
| 42 | + ExpenseId = entity.Id, | |
| 43 | + UserId = allParticipants[i].UserId, | |
| 44 | + Amount = amount | |
| 45 | + }); | |
| 46 | + } | |
| 47 | + } | |
| 48 | + break; | |
| 49 | + } | |
| 50 | + case ESplitMethod.EqualSubset: | |
| 51 | + { | |
| 52 | + if (participants.Length > 0) | |
| 53 | + { | |
| 54 | + var count = participants.Length; | |
| 55 | + var baseAmount = Math.Floor(entity.Amount / count * 100) / 100; | |
| 56 | + var remainder = entity.Amount - baseAmount * count; | |
| 57 | + | |
| 58 | + for (var i = 0; i < participants.Length; i++) | |
| 59 | + { | |
| 60 | + var amount = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0); | |
| 61 | + splitRepo.Add(new ExpenseSplit | |
| 62 | + { | |
| 63 | + Id = Guid.NewGuid(), | |
| 64 | + ExpenseId = entity.Id, | |
| 65 | + UserId = participants[i], | |
| 66 | + Amount = amount | |
| 67 | + }); | |
| 68 | + } | |
| 69 | + } | |
| 70 | + break; | |
| 71 | + } | |
| 72 | + case ESplitMethod.ExactAmounts: | |
| 73 | + { | |
| 74 | + if (participants.Length > 0 && amounts.Length == participants.Length) | |
| 75 | + { | |
| 76 | + for (var i = 0; i < participants.Length; i++) | |
| 77 | + { | |
| 78 | + splitRepo.Add(new ExpenseSplit | |
| 79 | + { | |
| 80 | + Id = Guid.NewGuid(), | |
| 81 | + ExpenseId = entity.Id, | |
| 82 | + UserId = participants[i], | |
| 83 | + Amount = amounts[i] | |
| 84 | + }); | |
| 85 | + } | |
| 86 | + } | |
| 87 | + break; | |
| 88 | + } | |
| 89 | + case ESplitMethod.Percentages: | |
| 90 | + { | |
| 91 | + if (participants.Length > 0 && percentages.Length == participants.Length) | |
| 92 | + { | |
| 93 | + for (var i = 0; i < participants.Length; i++) | |
| 94 | + { | |
| 95 | + var amount = Math.Round(entity.Amount * percentages[i] / 100, 2); | |
| 96 | + splitRepo.Add(new ExpenseSplit | |
| 97 | + { | |
| 98 | + Id = Guid.NewGuid(), | |
| 99 | + ExpenseId = entity.Id, | |
| 100 | + UserId = participants[i], | |
| 101 | + Amount = amount, | |
| 102 | + Percentage = percentages[i] | |
| 103 | + }); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + break; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + await _uow.SaveChangesAsync(); | |
| 111 | + | |
| 112 | + return ExpenseBllDtoFactory.Create(entity); | |
| 113 | + } | |
| 114 | + | |
| 115 | + public async Task DeleteExpenseWithSplitsAsync(Guid expenseId) | |
| 116 | + { | |
| 117 | + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(expenseId); | |
| 118 | + if (expense == null) return; | |
| 119 | + | |
| 120 | + var splitRepo = _uow.GetRepository<ExpenseSplit>(); | |
| 121 | + | |
| 122 | + if (expense.Splits != null) | |
| 123 | + { | |
| 124 | + foreach (var split in expense.Splits.ToList()) | |
| 125 | + { | |
| 126 | + await splitRepo.RemoveAsync(split.Id); | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + await _uow.Expenses.RemoveAsync(expenseId); | |
| 131 | + await _uow.SaveChangesAsync(); | |
| 132 | + } | |
| 133 | + | |
| 134 | + public async Task<List<ExpenseBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 135 | + { | |
| 136 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 137 | + return new List<ExpenseBllDto>(); | |
| 138 | + var expenses = await _uow.Expenses.GetByTripIdAsync(tripId); | |
| 139 | + return ExpenseBllDtoFactory.CreateList(expenses, includeSplits: true); | |
| 140 | + } | |
| 141 | + | |
| 142 | + public async Task<ExpenseBllDto?> GetByIdAsync(Guid expenseId, Guid userId) | |
| 143 | + { | |
| 144 | + var expense = await _uow.Expenses.GetByIdAsync(expenseId); | |
| 145 | + if (expense == null) return null; | |
| 146 | + if (!await _uow.TripParticipants.IsParticipantAsync(expense.TripId, userId)) return null; | |
| 147 | + return ExpenseBllDtoFactory.Create(expense); | |
| 148 | + } | |
| 149 | + | |
| 150 | + public async Task<ExpenseBllDto?> GetByIdWithDetailsAsync(Guid expenseId, Guid userId) | |
| 151 | + { | |
| 152 | + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(expenseId); | |
| 153 | + if (expense == null) return null; | |
| 154 | + if (!await _uow.TripParticipants.IsParticipantAsync(expense.TripId, userId)) return null; | |
| 155 | + return ExpenseBllDtoFactory.Create(expense, includeSplits: true); | |
| 156 | + } | |
| 157 | + | |
| 158 | + public async Task<ExpenseBllDto?> GetRawByIdAsync(Guid expenseId) | |
| 159 | + { | |
| 160 | + var expense = await _uow.Expenses.GetByIdAsync(expenseId); | |
| 161 | + return expense == null ? null : ExpenseBllDtoFactory.Create(expense); | |
| 162 | + } | |
| 163 | + | |
| 164 | + public async Task<bool> CanEditExpenseAsync(ExpenseBllDto expense, Guid userId) | |
| 165 | + { | |
| 166 | + if (expense.PaidByUserId == userId) return true; | |
| 167 | + return await _uow.TripParticipants.IsOrganizerAsync(expense.TripId, userId); | |
| 168 | + } | |
| 169 | + | |
| 170 | + public async Task<(bool success, string? errorCode)> UpdateExpenseWithSplitsFromDtoAsync( | |
| 171 | + Guid id, | |
| 172 | + decimal amount, | |
| 173 | + string? description, | |
| 174 | + DateTime expenseDate, | |
| 175 | + ESplitMethod splitMethod, | |
| 176 | + Guid? budgetCategoryId, | |
| 177 | + Guid? currencyId, | |
| 178 | + List<(Guid UserId, decimal Amount, decimal? Percentage)> splits, | |
| 179 | + Guid userId) | |
| 180 | + { | |
| 181 | + var expense = await _uow.Expenses.GetByIdWithDetailsAsync(id); | |
| 182 | + if (expense == null) return (false, "notfound"); | |
| 183 | + | |
| 184 | + if (!await CanEditExpenseAsync(ExpenseBllDtoFactory.Create(expense), userId)) | |
| 185 | + return (false, "forbidden"); | |
| 186 | + | |
| 187 | + var trip = await _uow.Trips.GetByIdAsync(expense.TripId); | |
| 188 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 189 | + return (false, "badstatus"); | |
| 190 | + | |
| 191 | + expense.BudgetCategoryId = budgetCategoryId; | |
| 192 | + expense.CurrencyId = currencyId; | |
| 193 | + expense.Amount = amount; | |
| 194 | + expense.Description = description; | |
| 195 | + expense.ExpenseDate = expenseDate; | |
| 196 | + expense.SplitMethod = splitMethod; | |
| 197 | + | |
| 198 | + _uow.Expenses.Update(expense); | |
| 199 | + | |
| 200 | + var splitRepo = _uow.GetRepository<ExpenseSplit>(); | |
| 201 | + if (expense.Splits != null) | |
| 202 | + { | |
| 203 | + foreach (var split in expense.Splits.ToList()) | |
| 204 | + { | |
| 205 | + await splitRepo.RemoveAsync(split.Id); | |
| 206 | + } | |
| 207 | + } | |
| 208 | + | |
| 209 | + foreach (var s in splits) | |
| 210 | + { | |
| 211 | + splitRepo.Add(new ExpenseSplit | |
| 212 | + { | |
| 213 | + ExpenseId = expense.Id, | |
| 214 | + UserId = s.UserId, | |
| 215 | + Amount = s.Amount, | |
| 216 | + Percentage = s.Percentage | |
| 217 | + }); | |
| 218 | + } | |
| 219 | + | |
| 220 | + await _uow.SaveChangesAsync(); | |
| 221 | + return (true, null); | |
| 222 | + } | |
| 223 | + | |
| 224 | + public async Task<(bool success, string? errorCode)> UpdateExpenseAsync(Guid id, ExpenseBllDto incoming, Guid userId) | |
| 225 | + { | |
| 226 | + var existing = await _uow.Expenses.GetByIdAsync(id); | |
| 227 | + if (existing == null) return (false, "notfound"); | |
| 228 | + | |
| 229 | + if (!await CanEditExpenseAsync(ExpenseBllDtoFactory.Create(existing), userId)) | |
| 230 | + return (false, "forbidden"); | |
| 231 | + | |
| 232 | + var trip = await _uow.Trips.GetByIdAsync(existing.TripId); | |
| 233 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 234 | + return (false, "badstatus"); | |
| 235 | + | |
| 236 | + existing.Amount = incoming.Amount; | |
| 237 | + existing.Description = incoming.Description; | |
| 238 | + existing.ExpenseDate = incoming.ExpenseDate; | |
| 239 | + existing.SplitMethod = incoming.SplitMethod; | |
| 240 | + existing.BudgetCategoryId = incoming.BudgetCategoryId; | |
| 241 | + existing.CurrencyId = incoming.CurrencyId; | |
| 242 | + existing.PaidByUserId = incoming.PaidByUserId; | |
| 243 | + | |
| 244 | + _uow.Expenses.Update(existing); | |
| 245 | + | |
| 246 | + if (incoming.SplitMethod == ESplitMethod.EqualAll) | |
| 247 | + { | |
| 248 | + var splitRepo = _uow.GetRepository<ExpenseSplit>(); | |
| 249 | + var expenseWithSplits = await _uow.Expenses.GetByIdWithDetailsAsync(id); | |
| 250 | + if (expenseWithSplits?.Splits != null) | |
| 251 | + { | |
| 252 | + foreach (var oldSplit in expenseWithSplits.Splits.ToList()) | |
| 253 | + { | |
| 254 | + await splitRepo.RemoveAsync(oldSplit.Id); | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + var participants = (await _uow.TripParticipants.GetByTripIdAsync(existing.TripId)).ToList(); | |
| 259 | + | |
| 260 | + var count = participants.Count; | |
| 261 | + if (count > 0) | |
| 262 | + { | |
| 263 | + var baseAmount = Math.Floor(incoming.Amount / count * 100) / 100; | |
| 264 | + var remainder = incoming.Amount - baseAmount * count; | |
| 265 | + | |
| 266 | + for (var i = 0; i < participants.Count; i++) | |
| 267 | + { | |
| 268 | + var amountPortion = baseAmount + (i < (int)(remainder * 100) ? 0.01m : 0); | |
| 269 | + splitRepo.Add(new ExpenseSplit | |
| 270 | + { | |
| 271 | + Id = Guid.NewGuid(), | |
| 272 | + ExpenseId = id, | |
| 273 | + UserId = participants[i].UserId, | |
| 274 | + Amount = amountPortion | |
| 275 | + }); | |
| 276 | + } | |
| 277 | + } | |
| 278 | + } | |
| 279 | + | |
| 280 | + await _uow.SaveChangesAsync(); | |
| 281 | + return (true, null); | |
| 282 | + } | |
| 283 | + | |
| 284 | + public async Task<List<SplitPresetBllDto>> GetSplitPresetsByTripAsync(Guid tripId, Guid userId) | |
| 285 | + { | |
| 286 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 287 | + return new List<SplitPresetBllDto>(); | |
| 288 | + var presets = await _uow.SplitPresets.GetByTripIdAsync(tripId); | |
| 289 | + return SplitPresetBllDtoFactory.CreateList(presets, includeMembers: true); | |
| 290 | + } | |
| 291 | + | |
| 292 | + public async Task<bool> SavePresetAsync(Guid tripId, string presetName, ESplitMethod splitMethod, | |
| 293 | + Guid[] selectedParticipants, decimal[] splitPercentages, Guid userId) | |
| 294 | + { | |
| 295 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return false; | |
| 296 | + | |
| 297 | + if (string.IsNullOrWhiteSpace(presetName) || selectedParticipants.Length == 0) | |
| 298 | + return false; | |
| 299 | + | |
| 300 | + var preset = new SplitPreset | |
| 301 | + { | |
| 302 | + TripId = tripId, | |
| 303 | + Name = presetName, | |
| 304 | + SplitMethod = splitMethod, | |
| 305 | + CreatedById = userId | |
| 306 | + }; | |
| 307 | + _uow.SplitPresets.Add(preset); | |
| 308 | + | |
| 309 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 310 | + for (var i = 0; i < selectedParticipants.Length; i++) | |
| 311 | + { | |
| 312 | + memberRepo.Add(new SplitPresetMember | |
| 313 | + { | |
| 314 | + SplitPresetId = preset.Id, | |
| 315 | + UserId = selectedParticipants[i], | |
| 316 | + Percentage = splitPercentages.Length > i ? splitPercentages[i] : null | |
| 317 | + }); | |
| 318 | + } | |
| 319 | + | |
| 320 | + await _uow.SaveChangesAsync(); | |
| 321 | + return true; | |
| 322 | + } | |
| 323 | + | |
| 324 | + public async Task<bool> DeletePresetAsync(Guid presetId, Guid tripId, Guid userId) | |
| 325 | + { | |
| 326 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return false; | |
| 327 | + | |
| 328 | + var presets = (await _uow.SplitPresets.GetByTripIdAsync(tripId)).ToList(); | |
| 329 | + var preset = presets.FirstOrDefault(p => p.Id == presetId); | |
| 330 | + if (preset == null) return false; | |
| 331 | + | |
| 332 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 333 | + if (preset.Members != null) | |
| 334 | + { | |
| 335 | + foreach (var member in preset.Members.ToList()) | |
| 336 | + { | |
| 337 | + await memberRepo.RemoveAsync(member.Id); | |
| 338 | + } | |
| 339 | + } | |
| 340 | + await _uow.SplitPresets.RemoveAsync(presetId); | |
| 341 | + await _uow.SaveChangesAsync(); | |
| 342 | + return true; | |
| 343 | + } | |
| 344 | +} |
added SplitApp/App.BLL/Services/IBudgetCategoryService.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/IExpenseService.cs +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Services; | |
| 5 | + | |
| 6 | +public interface IExpenseService | |
| 7 | +{ | |
| 8 | + Task<ExpenseBllDto> CreateExpenseWithSplitsAsync(ExpenseBllDto expense, Guid[] participants, decimal[] amounts, decimal[] percentages); | |
| 9 | + Task DeleteExpenseWithSplitsAsync(Guid expenseId); | |
| 10 | + | |
| 11 | + // Queries (IDOR-aware) | |
| 12 | + Task<List<ExpenseBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only | |
| 13 | + Task<ExpenseBllDto?> GetByIdAsync(Guid expenseId, Guid userId); // participant-only | |
| 14 | + Task<ExpenseBllDto?> GetByIdWithDetailsAsync(Guid expenseId, Guid userId); // participant-only | |
| 15 | + Task<ExpenseBllDto?> GetRawByIdAsync(Guid expenseId); // no IDOR, raw entity | |
| 16 | + | |
| 17 | + // Edit authorization | |
| 18 | + Task<bool> CanEditExpenseAsync(ExpenseBllDto expense, Guid userId); | |
| 19 | + | |
| 20 | + // API-level full update (re-populates splits from explicit DTO data) | |
| 21 | + Task<(bool success, string? errorCode)> UpdateExpenseWithSplitsFromDtoAsync( | |
| 22 | + Guid id, | |
| 23 | + decimal amount, | |
| 24 | + string? description, | |
| 25 | + DateTime expenseDate, | |
| 26 | + ESplitMethod splitMethod, | |
| 27 | + Guid? budgetCategoryId, | |
| 28 | + Guid? currencyId, | |
| 29 | + List<(Guid UserId, decimal Amount, decimal? Percentage)> splits, | |
| 30 | + Guid userId); | |
| 31 | + | |
| 32 | + // MVC-level partial update (re-splits for EqualAll only) | |
| 33 | + Task<(bool success, string? errorCode)> UpdateExpenseAsync(Guid id, ExpenseBllDto incoming, Guid userId); | |
| 34 | + | |
| 35 | + // Split presets (used by MVC Expenses controller dropdowns) | |
| 36 | + Task<List<SplitPresetBllDto>> GetSplitPresetsByTripAsync(Guid tripId, Guid userId); | |
| 37 | + Task<bool> SavePresetAsync(Guid tripId, string presetName, ESplitMethod splitMethod, | |
| 38 | + Guid[] selectedParticipants, decimal[] splitPercentages, Guid userId); | |
| 39 | + Task<bool> DeletePresetAsync(Guid presetId, Guid tripId, Guid userId); | |
| 40 | +} |
added SplitApp/App.BLL/Services/IInvitationService.cs +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Services; | |
| 5 | + | |
| 6 | +public interface IInvitationService | |
| 7 | +{ | |
| 8 | + Task<TripInvitationBllDto> CreateInvitationAsync(Guid tripId, Guid userId); | |
| 9 | + Task<bool> AcceptInvitationAsync(string token, Guid userId); | |
| 10 | + | |
| 11 | + // Guarded variant: also checks organizer | |
| 12 | + Task<(TripInvitationBllDto? invitation, string? errorCode)> CreateInvitationGuardedAsync(Guid tripId, Guid userId); | |
| 13 | + | |
| 14 | + // Lookups | |
| 15 | + Task<TripInvitationBllDto?> GetByIdAsync(Guid id); | |
| 16 | + Task<TripInvitationBllDto?> GetByTokenAsync(string token); | |
| 17 | + Task<List<TripInvitationBllDto>> GetPendingByTripIdAsync(Guid tripId, Guid userId); | |
| 18 | + | |
| 19 | + // Accept: full guarded flow (API) | |
| 20 | + Task<(bool success, string? errorCode)> AcceptInvitationGuardedAsync(string token, Guid userId); | |
| 21 | + | |
| 22 | + // Revoke & Decline | |
| 23 | + Task<(bool success, string? errorCode)> RevokeInvitationAsync(Guid invitationId, Guid tripId, Guid userId); | |
| 24 | + Task<(bool success, string? errorCode)> RevokeInvitationByTokenAsync(string token, Guid userId); | |
| 25 | + Task<(bool success, string? errorCode)> DeclineInvitationAsync(string token); | |
| 26 | +} |
added SplitApp/App.BLL/Services/IPollService.cs +27 −0
| @@ -0,0 +1,27 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/ISettlementService.cs +42 −0
| @@ -0,0 +1,42 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Services; | |
| 5 | + | |
| 6 | +public interface ISettlementService | |
| 7 | +{ | |
| 8 | + Task<List<BalanceEntry>> CalculateBalancesAsync(Guid tripId); | |
| 9 | + Task<SettlementPlanBllDto?> CalculateSettlementAsync(Guid tripId, Guid userId); | |
| 10 | + List<PreviewPayment> PreviewSettlement(List<BalanceEntry> balances); | |
| 11 | + Task MarkPaidAsync(Guid paymentId, Guid userId); | |
| 12 | + Task ConfirmPaymentAsync(Guid paymentId, Guid userId); | |
| 13 | + | |
| 14 | + // IDOR-protected queries | |
| 15 | + Task<List<BalanceEntry>> GetBalancesAsync(Guid tripId, Guid userId); // participant-only, empty if forbidden | |
| 16 | + Task<SettlementPlanBllDto?> GetLatestPlanAsync(Guid tripId, Guid userId); // participant-only | |
| 17 | + Task<SettlementPlanBllDto?> GetLatestPlanRawAsync(Guid tripId); // no IDOR (used internally after IDOR checked) | |
| 18 | + | |
| 19 | + // Payment lookups | |
| 20 | + Task<SettlementPaymentBllDto?> GetPaymentByIdAsync(Guid paymentId); | |
| 21 | + Task<SettlementPlanBllDto?> GetPlanByIdAsync(Guid planId); | |
| 22 | + | |
| 23 | + // Guarded mark/confirm that also validate trip participation | |
| 24 | + Task<(bool success, string? errorCode)> MarkPaidGuardedAsync(Guid paymentId, Guid userId); | |
| 25 | + Task<(bool success, string? errorCode)> ConfirmPaymentGuardedAsync(Guid paymentId, Guid userId); | |
| 26 | +} | |
| 27 | + | |
| 28 | +public class BalanceEntry | |
| 29 | +{ | |
| 30 | + public Guid UserId { get; set; } | |
| 31 | + public string UserName { get; set; } = default!; | |
| 32 | + public decimal TotalPaid { get; set; } | |
| 33 | + public decimal TotalOwed { get; set; } | |
| 34 | + public decimal NetBalance => TotalPaid - TotalOwed; | |
| 35 | +} | |
| 36 | + | |
| 37 | +public class PreviewPayment | |
| 38 | +{ | |
| 39 | + public string FromUserName { get; set; } = default!; | |
| 40 | + public string ToUserName { get; set; } = default!; | |
| 41 | + public decimal Amount { get; set; } | |
| 42 | +} |
added SplitApp/App.BLL/Services/ISplitPresetService.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.Domain; | |
| 3 | + | |
| 4 | +namespace App.BLL.Services; | |
| 5 | + | |
| 6 | +public interface ISplitPresetService | |
| 7 | +{ | |
| 8 | + Task<List<SplitPresetBllDto>> GetByTripIdAsync(Guid tripId, Guid userId); // participant-only | |
| 9 | + Task<SplitPresetBllDto?> GetByIdAsync(Guid id, Guid userId); // participant-only | |
| 10 | + | |
| 11 | + Task<(SplitPresetBllDto? preset, string? errorCode)> CreateAsync(SplitPresetBllDto preset, | |
| 12 | + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId); // participant | |
| 13 | + Task<(bool success, string? errorCode)> UpdateAsync(Guid id, string name, ESplitMethod splitMethod, | |
| 14 | + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId); // participant | |
| 15 | + Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId); // participant | |
| 16 | +} |
added SplitApp/App.BLL/Services/ITripService.cs +38 −0
| @@ -0,0 +1,38 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/IWishlistService.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | + | |
| 3 | +namespace App.BLL.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/App.BLL/Services/Identity/IIdentityService.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +namespace App.BLL.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/App.BLL/Services/Identity/IdentityService.cs +266 −0
| @@ -0,0 +1,266 @@ | ||
| 1 | +using System.IdentityModel.Tokens.Jwt; | |
| 2 | +using System.Security.Claims; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | +using App.Domain.Identity; | |
| 5 | +using Base.Helpers; | |
| 6 | +using Microsoft.AspNetCore.Identity; | |
| 7 | +using Microsoft.Extensions.Configuration; | |
| 8 | + | |
| 9 | +namespace App.BLL.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/App.BLL/Services/Identity/IdentityServiceResult.cs +63 −0
| @@ -0,0 +1,63 @@ | ||
| 1 | +namespace App.BLL.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/App.BLL/Services/InvitationService.cs +189 −0
| @@ -0,0 +1,189 @@ | ||
| 1 | +using System.Security.Cryptography; | |
| 2 | +using App.BLL.DTO; | |
| 3 | +using App.BLL.Mappers; | |
| 4 | +using App.Domain; | |
| 5 | +using App.Domain.Contracts; | |
| 6 | + | |
| 7 | +namespace App.BLL.Services; | |
| 8 | + | |
| 9 | +public class InvitationService : IInvitationService | |
| 10 | +{ | |
| 11 | + private readonly IAppUnitOfWork _uow; | |
| 12 | + | |
| 13 | + public InvitationService(IAppUnitOfWork uow) | |
| 14 | + { | |
| 15 | + _uow = uow; | |
| 16 | + } | |
| 17 | + | |
| 18 | + public async Task<TripInvitationBllDto> CreateInvitationAsync(Guid tripId, Guid userId) | |
| 19 | + { | |
| 20 | + var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) | |
| 21 | + .Replace("+", "-").Replace("/", "_").TrimEnd('='); | |
| 22 | + | |
| 23 | + var invitation = new TripInvitation | |
| 24 | + { | |
| 25 | + Id = Guid.NewGuid(), | |
| 26 | + TripId = tripId, | |
| 27 | + InvitedByUserId = userId, | |
| 28 | + Token = token, | |
| 29 | + Status = EInvitationStatus.Pending, | |
| 30 | + ExpiresAt = DateTime.UtcNow.AddDays(7) | |
| 31 | + }; | |
| 32 | + | |
| 33 | + _uow.TripInvitations.Add(invitation); | |
| 34 | + await _uow.SaveChangesAsync(); | |
| 35 | + | |
| 36 | + return InvitationBllDtoFactory.Create(invitation); | |
| 37 | + } | |
| 38 | + | |
| 39 | + public async Task<bool> AcceptInvitationAsync(string token, Guid userId) | |
| 40 | + { | |
| 41 | + var invitation = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 42 | + if (invitation == null) return false; | |
| 43 | + | |
| 44 | + if (invitation.Status != EInvitationStatus.Pending || invitation.ExpiresAt < DateTime.UtcNow) | |
| 45 | + return false; | |
| 46 | + | |
| 47 | + // Check if already a participant | |
| 48 | + var participants = (await _uow.TripParticipants.GetByTripIdAsync(invitation.TripId)).ToList(); | |
| 49 | + var existingParticipant = participants.FirstOrDefault(tp => tp.UserId == userId); | |
| 50 | + | |
| 51 | + var allParticipants = await _uow.TripParticipants.GetAllAsync(); | |
| 52 | + var inactiveParticipant = allParticipants | |
| 53 | + .FirstOrDefault(tp => tp.TripId == invitation.TripId && tp.UserId == userId && !tp.IsActive); | |
| 54 | + | |
| 55 | + if (existingParticipant != null) | |
| 56 | + { | |
| 57 | + // Already an active participant | |
| 58 | + } | |
| 59 | + else if (inactiveParticipant != null) | |
| 60 | + { | |
| 61 | + inactiveParticipant.IsActive = true; | |
| 62 | + inactiveParticipant.LeftAt = null; | |
| 63 | + _uow.TripParticipants.Update(inactiveParticipant); | |
| 64 | + } | |
| 65 | + else | |
| 66 | + { | |
| 67 | + var participant = new TripParticipant | |
| 68 | + { | |
| 69 | + Id = Guid.NewGuid(), | |
| 70 | + TripId = invitation.TripId, | |
| 71 | + UserId = userId, | |
| 72 | + Role = EParticipantRole.Participant, | |
| 73 | + JoinedAt = DateTime.UtcNow, | |
| 74 | + IsActive = true | |
| 75 | + }; | |
| 76 | + _uow.TripParticipants.Add(participant); | |
| 77 | + } | |
| 78 | + | |
| 79 | + invitation.Status = EInvitationStatus.Accepted; | |
| 80 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 81 | + _uow.TripInvitations.Update(invitation); | |
| 82 | + | |
| 83 | + await _uow.SaveChangesAsync(); | |
| 84 | + | |
| 85 | + return true; | |
| 86 | + } | |
| 87 | + | |
| 88 | + public async Task<(TripInvitationBllDto? invitation, string? errorCode)> CreateInvitationGuardedAsync(Guid tripId, Guid userId) | |
| 89 | + { | |
| 90 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) | |
| 91 | + return (null, "forbidden"); | |
| 92 | + var invitation = await CreateInvitationAsync(tripId, userId); | |
| 93 | + return (invitation, null); | |
| 94 | + } | |
| 95 | + | |
| 96 | + public async Task<TripInvitationBllDto?> GetByIdAsync(Guid id) | |
| 97 | + { | |
| 98 | + var entity = await _uow.TripInvitations.GetByIdAsync(id); | |
| 99 | + return entity == null ? null : InvitationBllDtoFactory.Create(entity); | |
| 100 | + } | |
| 101 | + | |
| 102 | + public async Task<TripInvitationBllDto?> GetByTokenAsync(string token) | |
| 103 | + { | |
| 104 | + var entity = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 105 | + return entity == null ? null : InvitationBllDtoFactory.Create(entity); | |
| 106 | + } | |
| 107 | + | |
| 108 | + public async Task<List<TripInvitationBllDto>> GetPendingByTripIdAsync(Guid tripId, Guid userId) | |
| 109 | + { | |
| 110 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 111 | + return new List<TripInvitationBllDto>(); | |
| 112 | + var invitations = await _uow.TripInvitations.GetPendingByTripIdAsync(tripId); | |
| 113 | + return InvitationBllDtoFactory.CreateList(invitations); | |
| 114 | + } | |
| 115 | + | |
| 116 | + public async Task<(bool success, string? errorCode)> AcceptInvitationGuardedAsync(string token, Guid userId) | |
| 117 | + { | |
| 118 | + var invitation = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 119 | + if (invitation == null) return (false, "notfound"); | |
| 120 | + | |
| 121 | + if (invitation.Status != EInvitationStatus.Pending) | |
| 122 | + return (false, "not-pending"); | |
| 123 | + | |
| 124 | + if (invitation.ExpiresAt < DateTime.UtcNow) | |
| 125 | + { | |
| 126 | + invitation.Status = EInvitationStatus.Expired; | |
| 127 | + _uow.TripInvitations.Update(invitation); | |
| 128 | + await _uow.SaveChangesAsync(); | |
| 129 | + return (false, "expired"); | |
| 130 | + } | |
| 131 | + | |
| 132 | + if (await _uow.TripParticipants.IsParticipantAsync(invitation.TripId, userId)) | |
| 133 | + return (false, "already-participant"); | |
| 134 | + | |
| 135 | + var accepted = await AcceptInvitationAsync(token, userId); | |
| 136 | + if (!accepted) return (false, "failed"); | |
| 137 | + | |
| 138 | + return (true, null); | |
| 139 | + } | |
| 140 | + | |
| 141 | + public async Task<(bool success, string? errorCode)> RevokeInvitationAsync(Guid invitationId, Guid tripId, Guid userId) | |
| 142 | + { | |
| 143 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) | |
| 144 | + return (false, "forbidden"); | |
| 145 | + | |
| 146 | + var invitation = await _uow.TripInvitations.GetByIdAsync(invitationId); | |
| 147 | + if (invitation == null || invitation.TripId != tripId) return (false, "notfound"); | |
| 148 | + | |
| 149 | + if (invitation.Status == EInvitationStatus.Pending) | |
| 150 | + { | |
| 151 | + invitation.Status = EInvitationStatus.Revoked; | |
| 152 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 153 | + _uow.TripInvitations.Update(invitation); | |
| 154 | + await _uow.SaveChangesAsync(); | |
| 155 | + } | |
| 156 | + | |
| 157 | + return (true, null); | |
| 158 | + } | |
| 159 | + | |
| 160 | + public async Task<(bool success, string? errorCode)> RevokeInvitationByTokenAsync(string token, Guid userId) | |
| 161 | + { | |
| 162 | + var invitation = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 163 | + if (invitation == null) return (false, "notfound"); | |
| 164 | + | |
| 165 | + if (!await _uow.TripParticipants.IsOrganizerAsync(invitation.TripId, userId)) | |
| 166 | + return (false, "forbidden"); | |
| 167 | + | |
| 168 | + invitation.Status = EInvitationStatus.Revoked; | |
| 169 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 170 | + _uow.TripInvitations.Update(invitation); | |
| 171 | + await _uow.SaveChangesAsync(); | |
| 172 | + return (true, null); | |
| 173 | + } | |
| 174 | + | |
| 175 | + public async Task<(bool success, string? errorCode)> DeclineInvitationAsync(string token) | |
| 176 | + { | |
| 177 | + var invitation = await _uow.TripInvitations.GetByTokenAsync(token); | |
| 178 | + if (invitation == null) return (false, "notfound"); | |
| 179 | + | |
| 180 | + if (invitation.Status != EInvitationStatus.Pending) | |
| 181 | + return (false, "not-pending"); | |
| 182 | + | |
| 183 | + invitation.Status = EInvitationStatus.Declined; | |
| 184 | + invitation.RespondedAt = DateTime.UtcNow; | |
| 185 | + _uow.TripInvitations.Update(invitation); | |
| 186 | + await _uow.SaveChangesAsync(); | |
| 187 | + return (true, null); | |
| 188 | + } | |
| 189 | +} |
added SplitApp/App.BLL/Services/PollService.cs +203 −0
| @@ -0,0 +1,203 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | + | |
| 6 | +namespace App.BLL.Services; | |
| 7 | + | |
| 8 | +public class PollService : IPollService | |
| 9 | +{ | |
| 10 | + private readonly IAppUnitOfWork _uow; | |
| 11 | + | |
| 12 | + public PollService(IAppUnitOfWork uow) | |
| 13 | + { | |
| 14 | + _uow = uow; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task<TripPollBllDto> CreatePollWithOptionsAsync(TripPollBllDto poll, List<string> optionTexts) | |
| 18 | + { | |
| 19 | + var entity = PollBllDtoFactory.ToEntity(poll); | |
| 20 | + entity.Id = Guid.NewGuid(); | |
| 21 | + _uow.TripPolls.Add(entity); | |
| 22 | + | |
| 23 | + var optionRepo = _uow.GetRepository<TripPollOption>(); | |
| 24 | + var order = 0; | |
| 25 | + | |
| 26 | + foreach (var text in optionTexts.Where(t => !string.IsNullOrWhiteSpace(t))) | |
| 27 | + { | |
| 28 | + optionRepo.Add(new TripPollOption | |
| 29 | + { | |
| 30 | + Id = Guid.NewGuid(), | |
| 31 | + PollId = entity.Id, | |
| 32 | + Text = text, | |
| 33 | + DisplayOrder = order++ | |
| 34 | + }); | |
| 35 | + } | |
| 36 | + | |
| 37 | + await _uow.SaveChangesAsync(); | |
| 38 | + | |
| 39 | + return PollBllDtoFactory.Create(entity); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public async Task ToggleVoteAsync(Guid pollId, Guid optionId, Guid userId) | |
| 43 | + { | |
| 44 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 45 | + if (poll == null) return; | |
| 46 | + | |
| 47 | + if (poll.ClosedAt != null) return; | |
| 48 | + | |
| 49 | + var option = poll.Options?.FirstOrDefault(o => o.Id == optionId); | |
| 50 | + if (option == null) return; | |
| 51 | + | |
| 52 | + var voteRepo = _uow.GetRepository<TripPollVote>(); | |
| 53 | + | |
| 54 | + if (!poll.AllowMultipleVotes) | |
| 55 | + { | |
| 56 | + if (poll.Options != null) | |
| 57 | + { | |
| 58 | + foreach (var opt in poll.Options) | |
| 59 | + { | |
| 60 | + if (opt.Votes != null) | |
| 61 | + { | |
| 62 | + foreach (var vote in opt.Votes.Where(v => v.UserId == userId).ToList()) | |
| 63 | + { | |
| 64 | + await voteRepo.RemoveAsync(vote.Id); | |
| 65 | + } | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + var existingVote = option.Votes?.FirstOrDefault(v => v.UserId == userId); | |
| 72 | + | |
| 73 | + if (existingVote != null) | |
| 74 | + { | |
| 75 | + await voteRepo.RemoveAsync(existingVote.Id); | |
| 76 | + } | |
| 77 | + else | |
| 78 | + { | |
| 79 | + voteRepo.Add(new TripPollVote | |
| 80 | + { | |
| 81 | + Id = Guid.NewGuid(), | |
| 82 | + PollOptionId = optionId, | |
| 83 | + UserId = userId | |
| 84 | + }); | |
| 85 | + } | |
| 86 | + | |
| 87 | + await _uow.SaveChangesAsync(); | |
| 88 | + } | |
| 89 | + | |
| 90 | + public async Task<List<TripPollBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 91 | + { | |
| 92 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 93 | + return new List<TripPollBllDto>(); | |
| 94 | + var polls = await _uow.TripPolls.GetByTripIdAsync(tripId); | |
| 95 | + return PollBllDtoFactory.CreateList(polls, includeOptions: true); | |
| 96 | + } | |
| 97 | + | |
| 98 | + public async Task<TripPollBllDto?> GetByIdAsync(Guid pollId, Guid userId) | |
| 99 | + { | |
| 100 | + var poll = await _uow.TripPolls.GetByIdAsync(pollId); | |
| 101 | + if (poll == null) return null; | |
| 102 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) return null; | |
| 103 | + return PollBllDtoFactory.Create(poll); | |
| 104 | + } | |
| 105 | + | |
| 106 | + public async Task<TripPollBllDto?> GetByIdWithDetailsAsync(Guid pollId, Guid userId) | |
| 107 | + { | |
| 108 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 109 | + if (poll == null) return null; | |
| 110 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) return null; | |
| 111 | + return PollBllDtoFactory.Create(poll, includeOptions: true); | |
| 112 | + } | |
| 113 | + | |
| 114 | + public async Task<(TripPollBllDto? poll, string? errorCode)> CreatePollGuardedAsync(TripPollBllDto poll, List<string> optionTexts, Guid userId) | |
| 115 | + { | |
| 116 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) | |
| 117 | + return (null, "forbidden"); | |
| 118 | + var created = await CreatePollWithOptionsAsync(poll, optionTexts); | |
| 119 | + return (created, null); | |
| 120 | + } | |
| 121 | + | |
| 122 | + public async Task<(bool success, string? errorCode)> CastVoteAsync(Guid pollId, Guid optionId, Guid userId) | |
| 123 | + { | |
| 124 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 125 | + if (poll == null) return (false, "notfound"); | |
| 126 | + | |
| 127 | + if (poll.ClosedAt != null) return (false, "closed"); | |
| 128 | + | |
| 129 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) | |
| 130 | + return (false, "forbidden"); | |
| 131 | + | |
| 132 | + var option = poll.Options?.FirstOrDefault(o => o.Id == optionId); | |
| 133 | + if (option == null) return (false, "invalid-option"); | |
| 134 | + | |
| 135 | + await ToggleVoteAsync(pollId, optionId, userId); | |
| 136 | + return (true, null); | |
| 137 | + } | |
| 138 | + | |
| 139 | + public async Task<(bool success, string? errorCode)> ClosePollAsync(Guid pollId, Guid userId, bool organizerAllowed) | |
| 140 | + { | |
| 141 | + var poll = await _uow.TripPolls.GetByIdAsync(pollId); | |
| 142 | + if (poll == null) return (false, "notfound"); | |
| 143 | + | |
| 144 | + if (!await _uow.TripParticipants.IsParticipantAsync(poll.TripId, userId)) | |
| 145 | + return (false, "forbidden"); | |
| 146 | + | |
| 147 | + var isCreator = poll.CreatedByUserId == userId; | |
| 148 | + var isOrganizer = organizerAllowed && await _uow.TripParticipants.IsOrganizerAsync(poll.TripId, userId); | |
| 149 | + | |
| 150 | + if (!isCreator && !isOrganizer) return (false, "forbidden"); | |
| 151 | + | |
| 152 | + poll.ClosedAt = DateTime.UtcNow; | |
| 153 | + _uow.TripPolls.Update(poll); | |
| 154 | + await _uow.SaveChangesAsync(); | |
| 155 | + return (true, null); | |
| 156 | + } | |
| 157 | + | |
| 158 | + public async Task<(bool success, string? errorCode)> DeletePollGuardedAsync(Guid pollId, Guid userId) | |
| 159 | + { | |
| 160 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 161 | + if (poll == null) return (false, "notfound"); | |
| 162 | + | |
| 163 | + var isCreator = poll.CreatedByUserId == userId; | |
| 164 | + var isOrganizer = await _uow.TripParticipants.IsOrganizerAsync(poll.TripId, userId); | |
| 165 | + | |
| 166 | + if (!isCreator && !isOrganizer) return (false, "forbidden"); | |
| 167 | + | |
| 168 | + await DeletePollCascadeAsync(pollId); | |
| 169 | + return (true, null); | |
| 170 | + } | |
| 171 | + | |
| 172 | + public async Task DeletePollCascadeAsync(Guid pollId) | |
| 173 | + { | |
| 174 | + var poll = await _uow.TripPolls.GetByIdWithDetailsAsync(pollId); | |
| 175 | + if (poll == null) return; | |
| 176 | + | |
| 177 | + var voteRepo = _uow.GetRepository<TripPollVote>(); | |
| 178 | + var optionRepo = _uow.GetRepository<TripPollOption>(); | |
| 179 | + | |
| 180 | + if (poll.Options != null) | |
| 181 | + { | |
| 182 | + foreach (var option in poll.Options) | |
| 183 | + { | |
| 184 | + if (option.Votes != null) | |
| 185 | + { | |
| 186 | + foreach (var vote in option.Votes.ToList()) | |
| 187 | + { | |
| 188 | + await voteRepo.RemoveAsync(vote.Id); | |
| 189 | + } | |
| 190 | + } | |
| 191 | + } | |
| 192 | + | |
| 193 | + foreach (var option in poll.Options.ToList()) | |
| 194 | + { | |
| 195 | + await optionRepo.RemoveAsync(option.Id); | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + await _uow.TripPolls.RemoveAsync(pollId); | |
| 200 | + | |
| 201 | + await _uow.SaveChangesAsync(); | |
| 202 | + } | |
| 203 | +} |
added SplitApp/App.BLL/Services/SettlementService.cs +331 −0
| @@ -0,0 +1,331 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Helpers; | |
| 3 | +using App.BLL.Mappers; | |
| 4 | +using App.Domain; | |
| 5 | +using App.Domain.Contracts; | |
| 6 | + | |
| 7 | +namespace App.BLL.Services; | |
| 8 | + | |
| 9 | +public class SettlementService : ISettlementService | |
| 10 | +{ | |
| 11 | + private readonly IAppUnitOfWork _uow; | |
| 12 | + | |
| 13 | + public SettlementService(IAppUnitOfWork uow) | |
| 14 | + { | |
| 15 | + _uow = uow; | |
| 16 | + } | |
| 17 | + | |
| 18 | + public async Task<List<BalanceEntry>> CalculateBalancesAsync(Guid tripId) | |
| 19 | + { | |
| 20 | + var trip = await _uow.Trips.GetByIdWithDetailsAsync(tripId); | |
| 21 | + if (trip == null) return new List<BalanceEntry>(); | |
| 22 | + | |
| 23 | + var defaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR"; | |
| 24 | + | |
| 25 | + var participants = (await _uow.TripParticipants.GetByTripIdAsync(tripId)).ToList(); | |
| 26 | + | |
| 27 | + var balances = new Dictionary<Guid, BalanceEntry>(); | |
| 28 | + foreach (var p in participants) | |
| 29 | + { | |
| 30 | + balances[p.UserId] = new BalanceEntry | |
| 31 | + { | |
| 32 | + UserId = p.UserId, | |
| 33 | + UserName = p.User != null ? $"{p.User.FirstName} {p.User.LastName}" : "Unknown", | |
| 34 | + TotalPaid = 0, | |
| 35 | + TotalOwed = 0 | |
| 36 | + }; | |
| 37 | + } | |
| 38 | + | |
| 39 | + var expenses = (await _uow.Expenses.GetByTripIdAsync(tripId)).ToList(); | |
| 40 | + | |
| 41 | + // Need expenses with splits - fetch each with details | |
| 42 | + foreach (var expense in expenses) | |
| 43 | + { | |
| 44 | + var expenseWithSplits = await _uow.Expenses.GetByIdWithDetailsAsync(expense.Id); | |
| 45 | + if (expenseWithSplits == null) continue; | |
| 46 | + | |
| 47 | + var expCurrency = expenseWithSplits.Currency?.Code ?? defaultCurrencyCode; | |
| 48 | + var convertedAmount = CurrencyConverter.Convert(expenseWithSplits.Amount, expCurrency, defaultCurrencyCode); | |
| 49 | + | |
| 50 | + if (balances.ContainsKey(expenseWithSplits.PaidByUserId)) | |
| 51 | + { | |
| 52 | + balances[expenseWithSplits.PaidByUserId].TotalPaid += convertedAmount; | |
| 53 | + } | |
| 54 | + | |
| 55 | + if (expenseWithSplits.Splits != null) | |
| 56 | + { | |
| 57 | + foreach (var split in expenseWithSplits.Splits) | |
| 58 | + { | |
| 59 | + var convertedSplit = CurrencyConverter.Convert(split.Amount, expCurrency, defaultCurrencyCode); | |
| 60 | + if (balances.ContainsKey(split.UserId)) | |
| 61 | + { | |
| 62 | + balances[split.UserId].TotalOwed += convertedSplit; | |
| 63 | + } | |
| 64 | + } | |
| 65 | + } | |
| 66 | + } | |
| 67 | + | |
| 68 | + return balances.Values.OrderByDescending(b => b.NetBalance).ToList(); | |
| 69 | + } | |
| 70 | + | |
| 71 | + public async Task<SettlementPlanBllDto?> CalculateSettlementAsync(Guid tripId, Guid userId) | |
| 72 | + { | |
| 73 | + var balanceList = await CalculateBalancesAsync(tripId); | |
| 74 | + | |
| 75 | + var creditors = balanceList.Where(b => b.NetBalance > 0.01m) | |
| 76 | + .Select(b => new { b.UserId, Amount = b.NetBalance }) | |
| 77 | + .OrderByDescending(c => c.Amount) | |
| 78 | + .ToList(); | |
| 79 | + | |
| 80 | + var debtors = balanceList.Where(b => b.NetBalance < -0.01m) | |
| 81 | + .Select(b => new { b.UserId, Amount = -b.NetBalance }) | |
| 82 | + .OrderByDescending(d => d.Amount) | |
| 83 | + .ToList(); | |
| 84 | + | |
| 85 | + if (!creditors.Any() || !debtors.Any()) return null; | |
| 86 | + | |
| 87 | + var plan = new SettlementPlan | |
| 88 | + { | |
| 89 | + Id = Guid.NewGuid(), | |
| 90 | + TripId = tripId, | |
| 91 | + CreatedByUserId = userId, | |
| 92 | + TotalAmount = creditors.Sum(c => c.Amount), | |
| 93 | + Status = ESettlementStatus.Pending | |
| 94 | + }; | |
| 95 | + | |
| 96 | + _uow.SettlementPlans.Add(plan); | |
| 97 | + | |
| 98 | + var paymentRepo = _uow.GetRepository<SettlementPayment>(); | |
| 99 | + | |
| 100 | + var creditBalances = creditors.ToDictionary(c => c.UserId, c => c.Amount); | |
| 101 | + var debtBalances = debtors.ToDictionary(d => d.UserId, d => d.Amount); | |
| 102 | + var sortedCreditors = creditBalances.Keys.ToList(); | |
| 103 | + var sortedDebtors = debtBalances.Keys.ToList(); | |
| 104 | + var ci = 0; | |
| 105 | + var di = 0; | |
| 106 | + | |
| 107 | + while (ci < sortedCreditors.Count && di < sortedDebtors.Count) | |
| 108 | + { | |
| 109 | + var creditorId = sortedCreditors[ci]; | |
| 110 | + var debtorId = sortedDebtors[di]; | |
| 111 | + var amount = Math.Min(creditBalances[creditorId], debtBalances[debtorId]); | |
| 112 | + | |
| 113 | + if (amount > 0.01m) | |
| 114 | + { | |
| 115 | + paymentRepo.Add(new SettlementPayment | |
| 116 | + { | |
| 117 | + Id = Guid.NewGuid(), | |
| 118 | + SettlementPlanId = plan.Id, | |
| 119 | + FromUserId = debtorId, | |
| 120 | + ToUserId = creditorId, | |
| 121 | + Amount = Math.Round(amount, 2), | |
| 122 | + Status = EPaymentStatus.Pending | |
| 123 | + }); | |
| 124 | + } | |
| 125 | + | |
| 126 | + creditBalances[creditorId] -= amount; | |
| 127 | + debtBalances[debtorId] -= amount; | |
| 128 | + if (creditBalances[creditorId] < 0.01m) ci++; | |
| 129 | + if (debtBalances[debtorId] < 0.01m) di++; | |
| 130 | + } | |
| 131 | + | |
| 132 | + await _uow.SaveChangesAsync(); | |
| 133 | + | |
| 134 | + // Return the plan with navigation properties loaded | |
| 135 | + var reloaded = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId); | |
| 136 | + return reloaded == null ? null : SettlementBllDtoFactory.Create(reloaded, includePayments: true); | |
| 137 | + } | |
| 138 | + | |
| 139 | + public List<PreviewPayment> PreviewSettlement(List<BalanceEntry> balanceList) | |
| 140 | + { | |
| 141 | + var result = new List<PreviewPayment>(); | |
| 142 | + | |
| 143 | + var creditors = balanceList.Where(b => b.NetBalance > 0.01m) | |
| 144 | + .Select(b => new { b.UserName, Amount = b.NetBalance }) | |
| 145 | + .OrderByDescending(c => c.Amount).ToList(); | |
| 146 | + | |
| 147 | + var debtors = balanceList.Where(b => b.NetBalance < -0.01m) | |
| 148 | + .Select(b => new { b.UserName, Amount = -b.NetBalance }) | |
| 149 | + .OrderByDescending(d => d.Amount).ToList(); | |
| 150 | + | |
| 151 | + if (!creditors.Any() || !debtors.Any()) return result; | |
| 152 | + | |
| 153 | + var creditBalances = creditors.ToDictionary(c => c.UserName, c => c.Amount); | |
| 154 | + var debtBalances = debtors.ToDictionary(d => d.UserName, d => d.Amount); | |
| 155 | + var sortedCreditors = creditBalances.Keys.ToList(); | |
| 156 | + var sortedDebtors = debtBalances.Keys.ToList(); | |
| 157 | + var ci = 0; | |
| 158 | + var di = 0; | |
| 159 | + | |
| 160 | + while (ci < sortedCreditors.Count && di < sortedDebtors.Count) | |
| 161 | + { | |
| 162 | + var creditor = sortedCreditors[ci]; | |
| 163 | + var debtor = sortedDebtors[di]; | |
| 164 | + var amount = Math.Min(creditBalances[creditor], debtBalances[debtor]); | |
| 165 | + | |
| 166 | + if (amount > 0.01m) | |
| 167 | + { | |
| 168 | + result.Add(new PreviewPayment | |
| 169 | + { | |
| 170 | + FromUserName = debtor, | |
| 171 | + ToUserName = creditor, | |
| 172 | + Amount = Math.Round(amount, 2) | |
| 173 | + }); | |
| 174 | + } | |
| 175 | + | |
| 176 | + creditBalances[creditor] -= amount; | |
| 177 | + debtBalances[debtor] -= amount; | |
| 178 | + if (creditBalances[creditor] < 0.01m) ci++; | |
| 179 | + if (debtBalances[debtor] < 0.01m) di++; | |
| 180 | + } | |
| 181 | + | |
| 182 | + return result; | |
| 183 | + } | |
| 184 | + | |
| 185 | + public async Task MarkPaidAsync(Guid paymentId, Guid userId) | |
| 186 | + { | |
| 187 | + var paymentRepo = _uow.GetRepository<SettlementPayment>(); | |
| 188 | + var payment = await paymentRepo.GetByIdAsync(paymentId); | |
| 189 | + if (payment == null) return; | |
| 190 | + | |
| 191 | + if (payment.FromUserId != userId) return; | |
| 192 | + | |
| 193 | + payment.Status = EPaymentStatus.MarkedPaid; | |
| 194 | + payment.MarkedPaidAt = DateTime.UtcNow; | |
| 195 | + | |
| 196 | + paymentRepo.Update(payment); | |
| 197 | + await _uow.SaveChangesAsync(); | |
| 198 | + } | |
| 199 | + | |
| 200 | + // --- New IDOR-protected helpers --- | |
| 201 | + | |
| 202 | + public async Task<List<BalanceEntry>> GetBalancesAsync(Guid tripId, Guid userId) | |
| 203 | + { | |
| 204 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 205 | + return new List<BalanceEntry>(); | |
| 206 | + return await CalculateBalancesAsync(tripId); | |
| 207 | + } | |
| 208 | + | |
| 209 | + public async Task<SettlementPlanBllDto?> GetLatestPlanAsync(Guid tripId, Guid userId) | |
| 210 | + { | |
| 211 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 212 | + return null; | |
| 213 | + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId); | |
| 214 | + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true); | |
| 215 | + } | |
| 216 | + | |
| 217 | + public async Task<SettlementPlanBllDto?> GetLatestPlanRawAsync(Guid tripId) | |
| 218 | + { | |
| 219 | + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId); | |
| 220 | + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true); | |
| 221 | + } | |
| 222 | + | |
| 223 | + public async Task<SettlementPaymentBllDto?> GetPaymentByIdAsync(Guid paymentId) | |
| 224 | + { | |
| 225 | + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId); | |
| 226 | + return payment == null ? null : SettlementPaymentBllDtoFactory.Create(payment); | |
| 227 | + } | |
| 228 | + | |
| 229 | + public async Task<SettlementPlanBllDto?> GetPlanByIdAsync(Guid planId) | |
| 230 | + { | |
| 231 | + var plan = await _uow.SettlementPlans.GetByIdAsync(planId); | |
| 232 | + return plan == null ? null : SettlementBllDtoFactory.Create(plan, includePayments: true); | |
| 233 | + } | |
| 234 | + | |
| 235 | + public async Task<(bool success, string? errorCode)> MarkPaidGuardedAsync(Guid paymentId, Guid userId) | |
| 236 | + { | |
| 237 | + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId); | |
| 238 | + if (payment == null) return (false, "notfound"); | |
| 239 | + | |
| 240 | + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId); | |
| 241 | + if (plan == null) return (false, "notfound"); | |
| 242 | + | |
| 243 | + if (!await _uow.TripParticipants.IsParticipantAsync(plan.TripId, userId)) | |
| 244 | + return (false, "forbidden"); | |
| 245 | + | |
| 246 | + if (payment.FromUserId != userId) return (false, "forbidden"); | |
| 247 | + | |
| 248 | + await MarkPaidAsync(paymentId, userId); | |
| 249 | + return (true, null); | |
| 250 | + } | |
| 251 | + | |
| 252 | + public async Task<(bool success, string? errorCode)> ConfirmPaymentGuardedAsync(Guid paymentId, Guid userId) | |
| 253 | + { | |
| 254 | + var payment = await _uow.GetRepository<SettlementPayment>().GetByIdAsync(paymentId); | |
| 255 | + if (payment == null) return (false, "notfound"); | |
| 256 | + | |
| 257 | + var plan = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId); | |
| 258 | + if (plan == null) return (false, "notfound"); | |
| 259 | + | |
| 260 | + if (!await _uow.TripParticipants.IsParticipantAsync(plan.TripId, userId)) | |
| 261 | + return (false, "forbidden"); | |
| 262 | + | |
| 263 | + if (payment.ToUserId != userId) return (false, "forbidden"); | |
| 264 | + | |
| 265 | + await ConfirmPaymentAsync(paymentId, userId); | |
| 266 | + return (true, null); | |
| 267 | + } | |
| 268 | + | |
| 269 | + public async Task ConfirmPaymentAsync(Guid paymentId, Guid userId) | |
| 270 | + { | |
| 271 | + // DAL uses NoTrackingWithIdentityResolution, so every load returns a | |
| 272 | + // detached entity. Mutations only persist via an explicit Update() call. | |
| 273 | + var paymentRepo = _uow.GetRepository<SettlementPayment>(); | |
| 274 | + var payment = await paymentRepo.GetByIdAsync(paymentId); | |
| 275 | + if (payment == null) return; | |
| 276 | + if (payment.ToUserId != userId) return; | |
| 277 | + | |
| 278 | + payment.Status = EPaymentStatus.Confirmed; | |
| 279 | + payment.ConfirmedAt = DateTime.UtcNow; | |
| 280 | + paymentRepo.Update(payment); | |
| 281 | + | |
| 282 | + // Read the plan with its Payments to check whether the plan is now | |
| 283 | + // fully confirmed. This load is read-only — used only for the All() | |
| 284 | + // check below — so we don't Update() it (its FromUser/ToUser includes | |
| 285 | + // would make DbSet.Update cascade into the AppUser graph and corrupt | |
| 286 | + // Identity rows on SaveChanges). | |
| 287 | + var planForCheck = await _uow.SettlementPlans.GetByIdAsync(payment.SettlementPlanId); | |
| 288 | + if (planForCheck?.Payments == null) | |
| 289 | + { | |
| 290 | + await _uow.SaveChangesAsync(); | |
| 291 | + return; | |
| 292 | + } | |
| 293 | + | |
| 294 | + // The just-mutated payment is a different instance from the one inside | |
| 295 | + // planForCheck.Payments (no tracking → no identity map across queries). | |
| 296 | + // Treat the current paymentId as already Confirmed when checking. | |
| 297 | + var allConfirmed = planForCheck.Payments.All(p => | |
| 298 | + p.Id == paymentId || p.Status == EPaymentStatus.Confirmed); | |
| 299 | + | |
| 300 | + // Update plan + trip via the base repo (no Includes) so Update() only | |
| 301 | + // touches the plan/trip rows themselves. | |
| 302 | + var planRepo = _uow.GetRepository<SettlementPlan>(); | |
| 303 | + var plan = await planRepo.GetByIdAsync(payment.SettlementPlanId); | |
| 304 | + if (plan == null) | |
| 305 | + { | |
| 306 | + await _uow.SaveChangesAsync(); | |
| 307 | + return; | |
| 308 | + } | |
| 309 | + | |
| 310 | + if (allConfirmed) | |
| 311 | + { | |
| 312 | + plan.Status = ESettlementStatus.Completed; | |
| 313 | + plan.CompletedAt = DateTime.UtcNow; | |
| 314 | + | |
| 315 | + var tripRepo = _uow.GetRepository<Trip>(); | |
| 316 | + var trip = await tripRepo.GetByIdAsync(plan.TripId); | |
| 317 | + if (trip != null && trip.Status == ETripStatus.Finalizing) | |
| 318 | + { | |
| 319 | + trip.Status = ETripStatus.Settled; | |
| 320 | + tripRepo.Update(trip); | |
| 321 | + } | |
| 322 | + } | |
| 323 | + else | |
| 324 | + { | |
| 325 | + plan.Status = ESettlementStatus.InProgress; | |
| 326 | + } | |
| 327 | + planRepo.Update(plan); | |
| 328 | + | |
| 329 | + await _uow.SaveChangesAsync(); | |
| 330 | + } | |
| 331 | +} |
added SplitApp/App.BLL/Services/SplitPresetService.cs +120 −0
| @@ -0,0 +1,120 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | + | |
| 6 | +namespace App.BLL.Services; | |
| 7 | + | |
| 8 | +public class SplitPresetService : ISplitPresetService | |
| 9 | +{ | |
| 10 | + private readonly IAppUnitOfWork _uow; | |
| 11 | + | |
| 12 | + public SplitPresetService(IAppUnitOfWork uow) | |
| 13 | + { | |
| 14 | + _uow = uow; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task<List<SplitPresetBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 18 | + { | |
| 19 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 20 | + return new List<SplitPresetBllDto>(); | |
| 21 | + var presets = await _uow.SplitPresets.GetByTripIdAsync(tripId); | |
| 22 | + return SplitPresetBllDtoFactory.CreateList(presets, includeMembers: true); | |
| 23 | + } | |
| 24 | + | |
| 25 | + public async Task<SplitPresetBllDto?> GetByIdAsync(Guid id, Guid userId) | |
| 26 | + { | |
| 27 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 28 | + if (preset == null) return null; | |
| 29 | + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) return null; | |
| 30 | + return SplitPresetBllDtoFactory.Create(preset, includeMembers: true); | |
| 31 | + } | |
| 32 | + | |
| 33 | + public async Task<(SplitPresetBllDto? preset, string? errorCode)> CreateAsync(SplitPresetBllDto preset, | |
| 34 | + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId) | |
| 35 | + { | |
| 36 | + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) | |
| 37 | + return (null, "forbidden"); | |
| 38 | + | |
| 39 | + var entity = SplitPresetBllDtoFactory.ToEntity(preset); | |
| 40 | + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid(); | |
| 41 | + entity.CreatedById = userId; | |
| 42 | + _uow.SplitPresets.Add(entity); | |
| 43 | + | |
| 44 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 45 | + foreach (var m in members) | |
| 46 | + { | |
| 47 | + memberRepo.Add(new SplitPresetMember | |
| 48 | + { | |
| 49 | + SplitPresetId = entity.Id, | |
| 50 | + UserId = m.UserId, | |
| 51 | + ShareWeight = m.ShareWeight, | |
| 52 | + Percentage = m.Percentage | |
| 53 | + }); | |
| 54 | + } | |
| 55 | + | |
| 56 | + await _uow.SaveChangesAsync(); | |
| 57 | + | |
| 58 | + var reloaded = await _uow.SplitPresets.GetByIdAsync(entity.Id); | |
| 59 | + return (reloaded == null ? null : SplitPresetBllDtoFactory.Create(reloaded, includeMembers: true), null); | |
| 60 | + } | |
| 61 | + | |
| 62 | + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, string name, ESplitMethod splitMethod, | |
| 63 | + List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)> members, Guid userId) | |
| 64 | + { | |
| 65 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 66 | + if (preset == null) return (false, "notfound"); | |
| 67 | + | |
| 68 | + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) | |
| 69 | + return (false, "forbidden"); | |
| 70 | + | |
| 71 | + preset.Name = name; | |
| 72 | + preset.SplitMethod = splitMethod; | |
| 73 | + _uow.SplitPresets.Update(preset); | |
| 74 | + | |
| 75 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 76 | + if (preset.Members != null) | |
| 77 | + { | |
| 78 | + foreach (var member in preset.Members.ToList()) | |
| 79 | + { | |
| 80 | + await memberRepo.RemoveAsync(member.Id); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + foreach (var m in members) | |
| 85 | + { | |
| 86 | + memberRepo.Add(new SplitPresetMember | |
| 87 | + { | |
| 88 | + SplitPresetId = preset.Id, | |
| 89 | + UserId = m.UserId, | |
| 90 | + ShareWeight = m.ShareWeight, | |
| 91 | + Percentage = m.Percentage | |
| 92 | + }); | |
| 93 | + } | |
| 94 | + | |
| 95 | + await _uow.SaveChangesAsync(); | |
| 96 | + return (true, null); | |
| 97 | + } | |
| 98 | + | |
| 99 | + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId) | |
| 100 | + { | |
| 101 | + var preset = await _uow.SplitPresets.GetByIdAsync(id); | |
| 102 | + if (preset == null) return (false, "notfound"); | |
| 103 | + | |
| 104 | + if (!await _uow.TripParticipants.IsParticipantAsync(preset.TripId, userId)) | |
| 105 | + return (false, "forbidden"); | |
| 106 | + | |
| 107 | + var memberRepo = _uow.GetRepository<SplitPresetMember>(); | |
| 108 | + if (preset.Members != null) | |
| 109 | + { | |
| 110 | + foreach (var member in preset.Members.ToList()) | |
| 111 | + { | |
| 112 | + await memberRepo.RemoveAsync(member.Id); | |
| 113 | + } | |
| 114 | + } | |
| 115 | + | |
| 116 | + await _uow.SplitPresets.RemoveAsync(id); | |
| 117 | + await _uow.SaveChangesAsync(); | |
| 118 | + return (true, null); | |
| 119 | + } | |
| 120 | +} |
added SplitApp/App.BLL/Services/TripService.cs +236 −0
| @@ -0,0 +1,236 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | + | |
| 6 | +namespace App.BLL.Services; | |
| 7 | + | |
| 8 | +public class TripService : ITripService | |
| 9 | +{ | |
| 10 | + private readonly IAppUnitOfWork _uow; | |
| 11 | + private readonly ISettlementService _settlementService; | |
| 12 | + | |
| 13 | + public TripService(IAppUnitOfWork uow, ISettlementService settlementService) | |
| 14 | + { | |
| 15 | + _uow = uow; | |
| 16 | + _settlementService = settlementService; | |
| 17 | + } | |
| 18 | + | |
| 19 | + public async Task<TripBllDto> CreateTripAsync(TripBllDto trip, Guid userId) | |
| 20 | + { | |
| 21 | + var entity = TripBllDtoFactory.ToEntity(trip); | |
| 22 | + entity.Id = Guid.NewGuid(); | |
| 23 | + entity.CreatedById = userId; | |
| 24 | + entity.Status = ETripStatus.Active; | |
| 25 | + | |
| 26 | + _uow.Trips.Add(entity); | |
| 27 | + | |
| 28 | + var participant = new TripParticipant | |
| 29 | + { | |
| 30 | + Id = Guid.NewGuid(), | |
| 31 | + TripId = entity.Id, | |
| 32 | + UserId = userId, | |
| 33 | + Role = EParticipantRole.Organizer, | |
| 34 | + JoinedAt = DateTime.UtcNow, | |
| 35 | + IsActive = true | |
| 36 | + }; | |
| 37 | + | |
| 38 | + _uow.TripParticipants.Add(participant); | |
| 39 | + | |
| 40 | + await _uow.SaveChangesAsync(); | |
| 41 | + | |
| 42 | + return TripBllDtoFactory.Create(entity); | |
| 43 | + } | |
| 44 | + | |
| 45 | + public async Task<List<TripBllDto>> GetUserTripsAsync(Guid userId) | |
| 46 | + { | |
| 47 | + var trips = await _uow.Trips.GetUserTripsAsync(userId); | |
| 48 | + return TripBllDtoFactory.CreateList(trips, includeParticipants: true); | |
| 49 | + } | |
| 50 | + | |
| 51 | + public async Task<TripBllDto?> GetByIdAsync(Guid tripId, Guid userId) | |
| 52 | + { | |
| 53 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return null; | |
| 54 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 55 | + return trip == null ? null : TripBllDtoFactory.Create(trip); | |
| 56 | + } | |
| 57 | + | |
| 58 | + public async Task<TripBllDto?> GetByIdWithDetailsAsync(Guid tripId, Guid userId) | |
| 59 | + { | |
| 60 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) return null; | |
| 61 | + var trip = await _uow.Trips.GetByIdWithDetailsAsync(tripId); | |
| 62 | + return trip == null ? null : TripBllDtoFactory.Create(trip, includeParticipants: true, includeExpenses: true); | |
| 63 | + } | |
| 64 | + | |
| 65 | + public async Task<TripBllDto?> GetByIdForOrganizerAsync(Guid tripId, Guid userId) | |
| 66 | + { | |
| 67 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) return null; | |
| 68 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 69 | + return trip == null ? null : TripBllDtoFactory.Create(trip); | |
| 70 | + } | |
| 71 | + | |
| 72 | + public async Task<TripBllDto?> GetRawByIdAsync(Guid tripId) | |
| 73 | + { | |
| 74 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 75 | + return trip == null ? null : TripBllDtoFactory.Create(trip); | |
| 76 | + } | |
| 77 | + | |
| 78 | + public Task<bool> IsParticipantAsync(Guid tripId, Guid userId) | |
| 79 | + => _uow.TripParticipants.IsParticipantAsync(tripId, userId); | |
| 80 | + | |
| 81 | + public Task<bool> IsOrganizerAsync(Guid tripId, Guid userId) | |
| 82 | + => _uow.TripParticipants.IsOrganizerAsync(tripId, userId); | |
| 83 | + | |
| 84 | + public async Task<TripBllDto?> UpdateAsync(TripBllDto trip, Guid userId) | |
| 85 | + { | |
| 86 | + if (!await _uow.TripParticipants.IsOrganizerAsync(trip.Id, userId)) return null; | |
| 87 | + | |
| 88 | + var existing = await _uow.Trips.GetByIdAsync(trip.Id); | |
| 89 | + if (existing == null) return null; | |
| 90 | + | |
| 91 | + existing.Name = trip.Name; | |
| 92 | + existing.Description = trip.Description; | |
| 93 | + existing.Destination = trip.Destination; | |
| 94 | + existing.StartDate = trip.StartDate; | |
| 95 | + existing.EndDate = trip.EndDate; | |
| 96 | + existing.DefaultCurrencyId = trip.DefaultCurrencyId; | |
| 97 | + existing.Status = trip.Status; | |
| 98 | + | |
| 99 | + _uow.Trips.Update(existing); | |
| 100 | + await _uow.SaveChangesAsync(); | |
| 101 | + return TripBllDtoFactory.Create(existing); | |
| 102 | + } | |
| 103 | + | |
| 104 | + public async Task<bool> DeleteAsync(Guid tripId, Guid userId) | |
| 105 | + { | |
| 106 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) return false; | |
| 107 | + | |
| 108 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 109 | + if (trip == null) return false; | |
| 110 | + | |
| 111 | + await _uow.Trips.RemoveAsync(tripId); | |
| 112 | + await _uow.SaveChangesAsync(); | |
| 113 | + return true; | |
| 114 | + } | |
| 115 | + | |
| 116 | + public async Task<List<TripParticipantBllDto>> GetParticipantsAsync(Guid tripId, Guid userId) | |
| 117 | + { | |
| 118 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 119 | + return new List<TripParticipantBllDto>(); | |
| 120 | + var participants = await _uow.TripParticipants.GetByTripIdAsync(tripId); | |
| 121 | + return TripParticipantBllDtoFactory.CreateList(participants); | |
| 122 | + } | |
| 123 | + | |
| 124 | + public Task<List<TripParticipantBllDto>> GetParticipantsForIndexAsync(Guid tripId, Guid userId) | |
| 125 | + => GetParticipantsAsync(tripId, userId); | |
| 126 | + | |
| 127 | + public async Task<TripParticipantBllDto?> GetParticipantByIdAsync(Guid participantId) | |
| 128 | + { | |
| 129 | + var participant = await _uow.TripParticipants.GetByIdAsync(participantId); | |
| 130 | + return participant == null ? null : TripParticipantBllDtoFactory.Create(participant); | |
| 131 | + } | |
| 132 | + | |
| 133 | + public async Task<(bool success, string? errorCode)> RemoveParticipantAsync(Guid tripId, Guid participantUserId, Guid currentUserId) | |
| 134 | + { | |
| 135 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, currentUserId)) | |
| 136 | + return (false, "forbidden"); | |
| 137 | + | |
| 138 | + var participants = (await _uow.TripParticipants.GetByTripIdAsync(tripId)).ToList(); | |
| 139 | + var participant = participants.FirstOrDefault(tp => tp.UserId == participantUserId); | |
| 140 | + if (participant == null) return (false, "notfound"); | |
| 141 | + | |
| 142 | + if (participant.Role == EParticipantRole.Organizer) | |
| 143 | + return (false, "organizer"); | |
| 144 | + if (participant.UserId == currentUserId) | |
| 145 | + return (false, "self"); | |
| 146 | + | |
| 147 | + participant.IsActive = false; | |
| 148 | + participant.LeftAt = DateTime.UtcNow; | |
| 149 | + _uow.TripParticipants.Update(participant); | |
| 150 | + await _uow.SaveChangesAsync(); | |
| 151 | + | |
| 152 | + return (true, null); | |
| 153 | + } | |
| 154 | + | |
| 155 | + public async Task<bool> RemoveParticipantByIdAsync(Guid tripId, Guid participantId, Guid currentUserId) | |
| 156 | + { | |
| 157 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, currentUserId)) | |
| 158 | + return false; | |
| 159 | + | |
| 160 | + var participant = await _uow.TripParticipants.GetByIdAsync(participantId); | |
| 161 | + if (participant == null || participant.TripId != tripId) return false; | |
| 162 | + | |
| 163 | + // Cannot remove yourself | |
| 164 | + if (participant.UserId == currentUserId) return false; | |
| 165 | + | |
| 166 | + participant.IsActive = false; | |
| 167 | + participant.LeftAt = DateTime.UtcNow; | |
| 168 | + _uow.TripParticipants.Update(participant); | |
| 169 | + await _uow.SaveChangesAsync(); | |
| 170 | + | |
| 171 | + return true; | |
| 172 | + } | |
| 173 | + | |
| 174 | + public async Task<(bool success, string? errorCode)> FinalizeTripAsync(Guid tripId, Guid userId) | |
| 175 | + { | |
| 176 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) | |
| 177 | + return (false, "forbidden"); | |
| 178 | + | |
| 179 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 180 | + if (trip == null) return (false, "notfound"); | |
| 181 | + if (trip.Status != ETripStatus.Active) return (false, "badstatus"); | |
| 182 | + | |
| 183 | + trip.Status = ETripStatus.Finalizing; | |
| 184 | + _uow.Trips.Update(trip); | |
| 185 | + await _uow.SaveChangesAsync(); | |
| 186 | + | |
| 187 | + // Auto-create settlement plan | |
| 188 | + var createdPlan = await _settlementService.CalculateSettlementAsync(tripId, userId); | |
| 189 | + | |
| 190 | + // If nobody owes anyone, skip straight to Settled. | |
| 191 | + if (createdPlan == null) | |
| 192 | + { | |
| 193 | + trip.Status = ETripStatus.Settled; | |
| 194 | + await _uow.SaveChangesAsync(); | |
| 195 | + } | |
| 196 | + | |
| 197 | + return (true, null); | |
| 198 | + } | |
| 199 | + | |
| 200 | + public async Task<(bool success, string? errorCode)> ReopenTripAsync(Guid tripId, Guid userId) | |
| 201 | + { | |
| 202 | + if (!await _uow.TripParticipants.IsOrganizerAsync(tripId, userId)) | |
| 203 | + return (false, "forbidden"); | |
| 204 | + | |
| 205 | + var trip = await _uow.Trips.GetByIdAsync(tripId); | |
| 206 | + if (trip == null) return (false, "notfound"); | |
| 207 | + if (trip.Status != ETripStatus.Finalizing && trip.Status != ETripStatus.Settled) | |
| 208 | + return (false, "badstatus"); | |
| 209 | + | |
| 210 | + var plan = await _uow.SettlementPlans.GetLatestByTripIdAsync(tripId); | |
| 211 | + if (plan?.Payments != null && plan.Payments.Any(p => p.Status == EPaymentStatus.Confirmed)) | |
| 212 | + return (false, "payments-confirmed"); | |
| 213 | + | |
| 214 | + if (plan != null) | |
| 215 | + { | |
| 216 | + await _uow.SettlementPlans.DeletePlanWithPaymentsAsync(plan.Id); | |
| 217 | + } | |
| 218 | + | |
| 219 | + var tripToUpdate = await _uow.Trips.GetByIdAsync(tripId); | |
| 220 | + if (tripToUpdate == null) return (false, "notfound"); | |
| 221 | + tripToUpdate.Status = ETripStatus.Active; | |
| 222 | + _uow.Trips.Update(tripToUpdate); | |
| 223 | + await _uow.SaveChangesAsync(); | |
| 224 | + | |
| 225 | + return (true, null); | |
| 226 | + } | |
| 227 | + | |
| 228 | + public async Task<List<CurrencyBllDto>> GetAllCurrenciesAsync() | |
| 229 | + { | |
| 230 | + var currencies = await _uow.GetRepository<Currency>().GetAllAsync(); | |
| 231 | + return currencies | |
| 232 | + .OrderBy(c => c.Code) | |
| 233 | + .Select(CurrencyBllDtoFactory.Create) | |
| 234 | + .ToList(); | |
| 235 | + } | |
| 236 | +} |
added SplitApp/App.BLL/Services/WishlistService.cs +145 −0
| @@ -0,0 +1,145 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | + | |
| 6 | +namespace App.BLL.Services; | |
| 7 | + | |
| 8 | +public class WishlistService : IWishlistService | |
| 9 | +{ | |
| 10 | + private readonly IAppUnitOfWork _uow; | |
| 11 | + | |
| 12 | + public WishlistService(IAppUnitOfWork uow) | |
| 13 | + { | |
| 14 | + _uow = uow; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public async Task<List<TripWishlistItemBllDto>> GetByTripIdAsync(Guid tripId, Guid userId) | |
| 18 | + { | |
| 19 | + if (!await _uow.TripParticipants.IsParticipantAsync(tripId, userId)) | |
| 20 | + return new List<TripWishlistItemBllDto>(); | |
| 21 | + var items = await _uow.TripWishlistItems.GetByTripIdAsync(tripId); | |
| 22 | + return WishlistBllDtoFactory.CreateList(items); | |
| 23 | + } | |
| 24 | + | |
| 25 | + public async Task<TripWishlistItemBllDto?> GetByIdAsync(Guid id, Guid userId) | |
| 26 | + { | |
| 27 | + var item = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 28 | + if (item == null) return null; | |
| 29 | + if (!await _uow.TripParticipants.IsParticipantAsync(item.TripId, userId)) return null; | |
| 30 | + return WishlistBllDtoFactory.Create(item); | |
| 31 | + } | |
| 32 | + | |
| 33 | + public async Task<TripWishlistItemBllDto?> GetByIdRawAsync(Guid id) | |
| 34 | + { | |
| 35 | + var item = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 36 | + return item == null ? null : WishlistBllDtoFactory.Create(item); | |
| 37 | + } | |
| 38 | + | |
| 39 | + public async Task<(TripWishlistItemBllDto? item, string? errorCode)> CreateAsync(TripWishlistItemBllDto item, Guid userId) | |
| 40 | + { | |
| 41 | + if (!await _uow.TripParticipants.IsParticipantAsync(item.TripId, userId)) | |
| 42 | + return (null, "forbidden"); | |
| 43 | + | |
| 44 | + var entity = WishlistBllDtoFactory.ToEntity(item); | |
| 45 | + if (entity.Id == Guid.Empty) entity.Id = Guid.NewGuid(); | |
| 46 | + entity.AddedByUserId = userId; | |
| 47 | + _uow.TripWishlistItems.Add(entity); | |
| 48 | + await _uow.SaveChangesAsync(); | |
| 49 | + | |
| 50 | + var reloaded = await _uow.TripWishlistItems.GetByIdAsync(entity.Id); | |
| 51 | + return (reloaded == null ? null : WishlistBllDtoFactory.Create(reloaded), null); | |
| 52 | + } | |
| 53 | + | |
| 54 | + public async Task<(bool success, string? errorCode)> UpdateAsync(Guid id, TripWishlistItemBllDto incoming, Guid userId) | |
| 55 | + { | |
| 56 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 57 | + if (existing == null) return (false, "notfound"); | |
| 58 | + | |
| 59 | + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId)) | |
| 60 | + return (false, "forbidden"); | |
| 61 | + | |
| 62 | + if (existing.AddedByUserId != userId) return (false, "forbidden"); | |
| 63 | + | |
| 64 | + existing.Title = incoming.Title; | |
| 65 | + existing.Description = incoming.Description; | |
| 66 | + existing.Category = incoming.Category; | |
| 67 | + existing.Priority = incoming.Priority; | |
| 68 | + existing.EstimatedCost = incoming.EstimatedCost; | |
| 69 | + existing.Url = incoming.Url; | |
| 70 | + existing.Location = incoming.Location; | |
| 71 | + | |
| 72 | + _uow.TripWishlistItems.Update(existing); | |
| 73 | + await _uow.SaveChangesAsync(); | |
| 74 | + return (true, null); | |
| 75 | + } | |
| 76 | + | |
| 77 | + public async Task<(bool success, string? errorCode)> DeleteAsync(Guid id, Guid userId) | |
| 78 | + { | |
| 79 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 80 | + if (existing == null) return (false, "notfound"); | |
| 81 | + | |
| 82 | + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId)) | |
| 83 | + return (false, "forbidden"); | |
| 84 | + | |
| 85 | + if (existing.AddedByUserId != userId) return (false, "forbidden"); | |
| 86 | + | |
| 87 | + var voteRepo = _uow.GetRepository<TripWishlistVote>(); | |
| 88 | + var allVotes = (await voteRepo.GetAllAsync()).Where(v => v.WishlistItemId == id).ToList(); | |
| 89 | + foreach (var vote in allVotes) | |
| 90 | + { | |
| 91 | + await voteRepo.RemoveAsync(vote.Id); | |
| 92 | + } | |
| 93 | + | |
| 94 | + await _uow.TripWishlistItems.RemoveAsync(id); | |
| 95 | + await _uow.SaveChangesAsync(); | |
| 96 | + return (true, null); | |
| 97 | + } | |
| 98 | + | |
| 99 | + public async Task<(bool success, string? errorCode)> ToggleVoteAsync(Guid id, Guid userId) | |
| 100 | + { | |
| 101 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 102 | + if (existing == null) return (false, "notfound"); | |
| 103 | + | |
| 104 | + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId)) | |
| 105 | + return (false, "forbidden"); | |
| 106 | + | |
| 107 | + var voteRepo = _uow.GetRepository<TripWishlistVote>(); | |
| 108 | + var allVotes = (await voteRepo.GetAllAsync()).ToList(); | |
| 109 | + var existingVote = allVotes.FirstOrDefault(v => v.WishlistItemId == id && v.UserId == userId); | |
| 110 | + | |
| 111 | + if (existingVote != null) | |
| 112 | + { | |
| 113 | + await voteRepo.RemoveAsync(existingVote.Id); | |
| 114 | + } | |
| 115 | + else | |
| 116 | + { | |
| 117 | + voteRepo.Add(new TripWishlistVote | |
| 118 | + { | |
| 119 | + Id = Guid.NewGuid(), | |
| 120 | + WishlistItemId = id, | |
| 121 | + UserId = userId, | |
| 122 | + IsInterested = true | |
| 123 | + }); | |
| 124 | + } | |
| 125 | + | |
| 126 | + await _uow.SaveChangesAsync(); | |
| 127 | + return (true, null); | |
| 128 | + } | |
| 129 | + | |
| 130 | + public async Task<(bool success, string? errorCode)> ToggleCompleteAsync(Guid id, Guid userId) | |
| 131 | + { | |
| 132 | + var existing = await _uow.TripWishlistItems.GetByIdAsync(id); | |
| 133 | + if (existing == null) return (false, "notfound"); | |
| 134 | + | |
| 135 | + if (!await _uow.TripParticipants.IsParticipantAsync(existing.TripId, userId)) | |
| 136 | + return (false, "forbidden"); | |
| 137 | + | |
| 138 | + existing.IsCompleted = !existing.IsCompleted; | |
| 139 | + existing.CompletedAt = existing.IsCompleted ? DateTime.UtcNow : null; | |
| 140 | + | |
| 141 | + _uow.TripWishlistItems.Update(existing); | |
| 142 | + await _uow.SaveChangesAsync(); | |
| 143 | + return (true, null); | |
| 144 | + } | |
| 145 | +} |
added SplitApp/App.DAL.EF/App.DAL.EF.csproj +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <ItemGroup> | |
| 4 | + <ProjectReference Include="..\App.Domain\App.Domain.csproj" /> | |
| 5 | + <ProjectReference Include="..\Base.Contracts\Base.Contracts.csproj" /> | |
| 6 | + </ItemGroup> | |
| 7 | + | |
| 8 | + <ItemGroup> | |
| 9 | + <PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.5" /> | |
| 10 | + <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" /> | |
| 11 | + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> | |
| 12 | + </ItemGroup> | |
| 13 | + | |
| 14 | + <PropertyGroup> | |
| 15 | + <TargetFramework>net10.0</TargetFramework> | |
| 16 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 17 | + <Nullable>enable</Nullable> | |
| 18 | + </PropertyGroup> | |
| 19 | + | |
| 20 | +</Project> |
added SplitApp/App.DAL.EF/AppDbContext.cs +127 −0
| @@ -0,0 +1,127 @@ | ||
| 1 | +using System.Text.Json; | |
| 2 | +using App.Domain; | |
| 3 | +using App.Domain.Identity; | |
| 4 | +using Base.Domain; | |
| 5 | +using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; | |
| 6 | +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; | |
| 7 | +using Microsoft.EntityFrameworkCore; | |
| 8 | +using Microsoft.EntityFrameworkCore.ChangeTracking; | |
| 9 | + | |
| 10 | +namespace App.DAL.EF; | |
| 11 | + | |
| 12 | +public class AppDbContext : IdentityDbContext<AppUser, AppRole, Guid>, IDataProtectionKeyContext | |
| 13 | +{ | |
| 14 | + public DbSet<Trip> Trips { get; set; } = default!; | |
| 15 | + public DbSet<TripParticipant> TripParticipants { get; set; } = default!; | |
| 16 | + public DbSet<TripInvitation> TripInvitations { get; set; } = default!; | |
| 17 | + public DbSet<Expense> Expenses { get; set; } = default!; | |
| 18 | + public DbSet<ExpenseSplit> ExpenseSplits { get; set; } = default!; | |
| 19 | + public DbSet<BudgetCategory> BudgetCategories { get; set; } = default!; | |
| 20 | + public DbSet<SplitPreset> SplitPresets { get; set; } = default!; | |
| 21 | + public DbSet<SplitPresetMember> SplitPresetMembers { get; set; } = default!; | |
| 22 | + public DbSet<SettlementPlan> SettlementPlans { get; set; } = default!; | |
| 23 | + public DbSet<SettlementPayment> SettlementPayments { get; set; } = default!; | |
| 24 | + public DbSet<Currency> Currencies { get; set; } = default!; | |
| 25 | + public DbSet<TripWishlistItem> TripWishlistItems { get; set; } = default!; | |
| 26 | + public DbSet<TripWishlistVote> TripWishlistVotes { get; set; } = default!; | |
| 27 | + public DbSet<TripPoll> TripPolls { get; set; } = default!; | |
| 28 | + public DbSet<TripPollOption> TripPollOptions { get; set; } = default!; | |
| 29 | + public DbSet<TripPollVote> TripPollVotes { get; set; } = default!; | |
| 30 | + public DbSet<AppRefreshToken> RefreshTokens { get; set; } = default!; | |
| 31 | + public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = default!; | |
| 32 | + | |
| 33 | + public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) | |
| 34 | + { | |
| 35 | + } | |
| 36 | + | |
| 37 | + public override int SaveChanges() | |
| 38 | + { | |
| 39 | + UpdateTimestamps(); | |
| 40 | + return base.SaveChanges(); | |
| 41 | + } | |
| 42 | + | |
| 43 | + public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) | |
| 44 | + { | |
| 45 | + UpdateTimestamps(); | |
| 46 | + return base.SaveChangesAsync(cancellationToken); | |
| 47 | + } | |
| 48 | + | |
| 49 | + private void UpdateTimestamps() | |
| 50 | + { | |
| 51 | + var entries = ChangeTracker.Entries<BaseEntity>(); | |
| 52 | + foreach (var entry in entries) | |
| 53 | + { | |
| 54 | + if (entry.State == EntityState.Modified) | |
| 55 | + { | |
| 56 | + entry.Entity.UpdatedAt = DateTime.UtcNow; | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) | |
| 62 | + { | |
| 63 | + base.ConfigureConventions(configurationBuilder); | |
| 64 | + | |
| 65 | + // force all DateTime properties to be stored as UTC | |
| 66 | + configurationBuilder.Properties<DateTime>() | |
| 67 | + .HaveConversion<UtcDateTimeConverter>(); | |
| 68 | + } | |
| 69 | + | |
| 70 | + protected override void OnModelCreating(ModelBuilder builder) | |
| 71 | + { | |
| 72 | + base.OnModelCreating(builder); | |
| 73 | + | |
| 74 | + // disable cascade delete for all relationships | |
| 75 | + foreach (var relationship in builder.Model | |
| 76 | + .GetEntityTypes() | |
| 77 | + .SelectMany(e => e.GetForeignKeys())) | |
| 78 | + { | |
| 79 | + relationship.DeleteBehavior = DeleteBehavior.Restrict; | |
| 80 | + } | |
| 81 | + | |
| 82 | + // unique index on invitation token | |
| 83 | + builder.Entity<TripInvitation>() | |
| 84 | + .HasIndex(i => i.Token) | |
| 85 | + .IsUnique(); | |
| 86 | + | |
| 87 | + // composite unique on TripParticipant (TripId, UserId) | |
| 88 | + builder.Entity<TripParticipant>() | |
| 89 | + .HasIndex(tp => new { tp.TripId, tp.UserId }) | |
| 90 | + .IsUnique(); | |
| 91 | + | |
| 92 | + // composite unique on TripWishlistVote (WishlistItemId, UserId) | |
| 93 | + builder.Entity<TripWishlistVote>() | |
| 94 | + .HasIndex(v => new { v.WishlistItemId, v.UserId }) | |
| 95 | + .IsUnique(); | |
| 96 | + | |
| 97 | + // composite unique on TripPollVote (PollOptionId, UserId) | |
| 98 | + builder.Entity<TripPollVote>() | |
| 99 | + .HasIndex(v => new { v.PollOptionId, v.UserId }) | |
| 100 | + .IsUnique(); | |
| 101 | + | |
| 102 | + // LangStr JSON value converter for Currency.Name | |
| 103 | + builder.Entity<Currency>() | |
| 104 | + .Property(c => c.Name) | |
| 105 | + .HasConversion( | |
| 106 | + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), | |
| 107 | + v => JsonSerializer.Deserialize<LangStr>(v, (JsonSerializerOptions?)null) ?? new LangStr()) | |
| 108 | + .HasMaxLength(1024) | |
| 109 | + .Metadata.SetValueComparer(new ValueComparer<LangStr>( | |
| 110 | + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), | |
| 111 | + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), | |
| 112 | + v => JsonSerializer.Deserialize<LangStr>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!)); | |
| 113 | + | |
| 114 | + // LangStr JSON value converter for BudgetCategory.Name | |
| 115 | + builder.Entity<BudgetCategory>() | |
| 116 | + .Property(c => c.Name) | |
| 117 | + .HasConversion( | |
| 118 | + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), | |
| 119 | + v => JsonSerializer.Deserialize<LangStr>(v, (JsonSerializerOptions?)null) ?? new LangStr()) | |
| 120 | + .HasMaxLength(1024) | |
| 121 | + .Metadata.SetValueComparer(new ValueComparer<LangStr>( | |
| 122 | + (a, b) => JsonSerializer.Serialize(a, (JsonSerializerOptions?)null) == JsonSerializer.Serialize(b, (JsonSerializerOptions?)null), | |
| 123 | + v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null).GetHashCode(), | |
| 124 | + v => JsonSerializer.Deserialize<LangStr>(JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), (JsonSerializerOptions?)null)!)); | |
| 125 | + | |
| 126 | + } | |
| 127 | +} |
added SplitApp/App.DAL.EF/AppUnitOfWork.cs +46 −0
| @@ -0,0 +1,46 @@ | ||
| 1 | +using App.DAL.EF.Repositories; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Base.Contracts; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF; | |
| 6 | + | |
| 7 | +public class AppUnitOfWork : IAppUnitOfWork | |
| 8 | +{ | |
| 9 | + private readonly AppDbContext _context; | |
| 10 | + | |
| 11 | + private ITripRepository? _trips; | |
| 12 | + private IExpenseRepository? _expenses; | |
| 13 | + private ITripParticipantRepository? _tripParticipants; | |
| 14 | + private ITripInvitationRepository? _tripInvitations; | |
| 15 | + private ISettlementPlanRepository? _settlementPlans; | |
| 16 | + private ISettlementPaymentRepository? _settlementPayments; | |
| 17 | + private ITripPollRepository? _tripPolls; | |
| 18 | + private ITripWishlistItemRepository? _tripWishlistItems; | |
| 19 | + private ISplitPresetRepository? _splitPresets; | |
| 20 | + private IBudgetCategoryRepository? _budgetCategories; | |
| 21 | + private IRefreshTokenRepository? _refreshTokens; | |
| 22 | + private IUserRepository? _users; | |
| 23 | + | |
| 24 | + public AppUnitOfWork(AppDbContext context) | |
| 25 | + { | |
| 26 | + _context = context; | |
| 27 | + } | |
| 28 | + | |
| 29 | + public ITripRepository Trips => _trips ??= new TripRepository(_context); | |
| 30 | + public IExpenseRepository Expenses => _expenses ??= new ExpenseRepository(_context); | |
| 31 | + public ITripParticipantRepository TripParticipants => _tripParticipants ??= new TripParticipantRepository(_context); | |
| 32 | + public ITripInvitationRepository TripInvitations => _tripInvitations ??= new TripInvitationRepository(_context); | |
| 33 | + public ISettlementPlanRepository SettlementPlans => _settlementPlans ??= new SettlementPlanRepository(_context); | |
| 34 | + public ISettlementPaymentRepository SettlementPayments => _settlementPayments ??= new SettlementPaymentRepository(_context); | |
| 35 | + public ITripPollRepository TripPolls => _tripPolls ??= new TripPollRepository(_context); | |
| 36 | + public ITripWishlistItemRepository TripWishlistItems => _tripWishlistItems ??= new TripWishlistItemRepository(_context); | |
| 37 | + public ISplitPresetRepository SplitPresets => _splitPresets ??= new SplitPresetRepository(_context); | |
| 38 | + public IBudgetCategoryRepository BudgetCategories => _budgetCategories ??= new BudgetCategoryRepository(_context); | |
| 39 | + public IRefreshTokenRepository RefreshTokens => _refreshTokens ??= new RefreshTokenRepository(_context); | |
| 40 | + public IUserRepository Users => _users ??= new UserRepository(_context); | |
| 41 | + | |
| 42 | + public IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity | |
| 43 | + => new BaseRepository<TEntity>(_context); | |
| 44 | + | |
| 45 | + public Task<int> SaveChangesAsync() => _context.SaveChangesAsync(); | |
| 46 | +} |
added SplitApp/App.DAL.EF/Migrations/20260328145416_Initial.Designer.cs +1267 −0
| @@ -0,0 +1,1267 @@ | ||
| 1 | +// <auto-generated /> | |
| 2 | +using System; | |
| 3 | +using App.DAL.EF; | |
| 4 | +using Microsoft.EntityFrameworkCore; | |
| 5 | +using Microsoft.EntityFrameworkCore.Infrastructure; | |
| 6 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 7 | +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | |
| 8 | +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | |
| 9 | + | |
| 10 | +#nullable disable | |
| 11 | + | |
| 12 | +namespace App.DAL.EF.Migrations | |
| 13 | +{ | |
| 14 | + [DbContext(typeof(AppDbContext))] | |
| 15 | + [Migration("20260328145416_Initial")] | |
| 16 | + partial class Initial | |
| 17 | + { | |
| 18 | + /// <inheritdoc /> | |
| 19 | + protected override void BuildTargetModel(ModelBuilder modelBuilder) | |
| 20 | + { | |
| 21 | +#pragma warning disable 612, 618 | |
| 22 | + modelBuilder | |
| 23 | + .HasAnnotation("ProductVersion", "10.0.5") | |
| 24 | + .HasAnnotation("Relational:MaxIdentifierLength", 63); | |
| 25 | + | |
| 26 | + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | |
| 27 | + | |
| 28 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 29 | + { | |
| 30 | + b.Property<Guid>("Id") | |
| 31 | + .ValueGeneratedOnAdd() | |
| 32 | + .HasColumnType("uuid"); | |
| 33 | + | |
| 34 | + b.Property<int>("DisplayOrder") | |
| 35 | + .HasColumnType("integer"); | |
| 36 | + | |
| 37 | + b.Property<string>("IconName") | |
| 38 | + .HasMaxLength(100) | |
| 39 | + .HasColumnType("character varying(100)"); | |
| 40 | + | |
| 41 | + b.Property<string>("Name") | |
| 42 | + .IsRequired() | |
| 43 | + .HasMaxLength(100) | |
| 44 | + .HasColumnType("character varying(100)"); | |
| 45 | + | |
| 46 | + b.Property<decimal?>("PlannedAmount") | |
| 47 | + .HasColumnType("numeric"); | |
| 48 | + | |
| 49 | + b.Property<Guid>("TripId") | |
| 50 | + .HasColumnType("uuid"); | |
| 51 | + | |
| 52 | + b.HasKey("Id"); | |
| 53 | + | |
| 54 | + b.HasIndex("TripId"); | |
| 55 | + | |
| 56 | + b.ToTable("BudgetCategories"); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + modelBuilder.Entity("App.Domain.BudgetCategoryTranslation", b => | |
| 60 | + { | |
| 61 | + b.Property<Guid>("Id") | |
| 62 | + .ValueGeneratedOnAdd() | |
| 63 | + .HasColumnType("uuid"); | |
| 64 | + | |
| 65 | + b.Property<Guid>("BudgetCategoryId") | |
| 66 | + .HasColumnType("uuid"); | |
| 67 | + | |
| 68 | + b.Property<string>("Culture") | |
| 69 | + .IsRequired() | |
| 70 | + .HasMaxLength(5) | |
| 71 | + .HasColumnType("character varying(5)"); | |
| 72 | + | |
| 73 | + b.Property<string>("Name") | |
| 74 | + .IsRequired() | |
| 75 | + .HasMaxLength(100) | |
| 76 | + .HasColumnType("character varying(100)"); | |
| 77 | + | |
| 78 | + b.HasKey("Id"); | |
| 79 | + | |
| 80 | + b.HasIndex("BudgetCategoryId"); | |
| 81 | + | |
| 82 | + b.ToTable("BudgetCategoryTranslations"); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + modelBuilder.Entity("App.Domain.Currency", b => | |
| 86 | + { | |
| 87 | + b.Property<Guid>("Id") | |
| 88 | + .ValueGeneratedOnAdd() | |
| 89 | + .HasColumnType("uuid"); | |
| 90 | + | |
| 91 | + b.Property<string>("Code") | |
| 92 | + .IsRequired() | |
| 93 | + .HasMaxLength(3) | |
| 94 | + .HasColumnType("character varying(3)"); | |
| 95 | + | |
| 96 | + b.Property<string>("Name") | |
| 97 | + .IsRequired() | |
| 98 | + .HasMaxLength(100) | |
| 99 | + .HasColumnType("character varying(100)"); | |
| 100 | + | |
| 101 | + b.Property<string>("Symbol") | |
| 102 | + .IsRequired() | |
| 103 | + .HasMaxLength(10) | |
| 104 | + .HasColumnType("character varying(10)"); | |
| 105 | + | |
| 106 | + b.HasKey("Id"); | |
| 107 | + | |
| 108 | + b.ToTable("Currencies"); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 112 | + { | |
| 113 | + b.Property<Guid>("Id") | |
| 114 | + .ValueGeneratedOnAdd() | |
| 115 | + .HasColumnType("uuid"); | |
| 116 | + | |
| 117 | + b.Property<decimal>("Amount") | |
| 118 | + .HasColumnType("numeric"); | |
| 119 | + | |
| 120 | + b.Property<Guid?>("BudgetCategoryId") | |
| 121 | + .HasColumnType("uuid"); | |
| 122 | + | |
| 123 | + b.Property<Guid?>("CurrencyId") | |
| 124 | + .HasColumnType("uuid"); | |
| 125 | + | |
| 126 | + b.Property<string>("Description") | |
| 127 | + .HasMaxLength(500) | |
| 128 | + .HasColumnType("character varying(500)"); | |
| 129 | + | |
| 130 | + b.Property<DateTime>("ExpenseDate") | |
| 131 | + .HasColumnType("timestamp with time zone"); | |
| 132 | + | |
| 133 | + b.Property<Guid>("PaidByUserId") | |
| 134 | + .HasColumnType("uuid"); | |
| 135 | + | |
| 136 | + b.Property<int>("SplitMethod") | |
| 137 | + .HasColumnType("integer"); | |
| 138 | + | |
| 139 | + b.Property<Guid>("TripId") | |
| 140 | + .HasColumnType("uuid"); | |
| 141 | + | |
| 142 | + b.HasKey("Id"); | |
| 143 | + | |
| 144 | + b.HasIndex("BudgetCategoryId"); | |
| 145 | + | |
| 146 | + b.HasIndex("CurrencyId"); | |
| 147 | + | |
| 148 | + b.HasIndex("PaidByUserId"); | |
| 149 | + | |
| 150 | + b.HasIndex("TripId"); | |
| 151 | + | |
| 152 | + b.ToTable("Expenses"); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 156 | + { | |
| 157 | + b.Property<Guid>("Id") | |
| 158 | + .ValueGeneratedOnAdd() | |
| 159 | + .HasColumnType("uuid"); | |
| 160 | + | |
| 161 | + b.Property<decimal>("Amount") | |
| 162 | + .HasColumnType("numeric"); | |
| 163 | + | |
| 164 | + b.Property<Guid>("ExpenseId") | |
| 165 | + .HasColumnType("uuid"); | |
| 166 | + | |
| 167 | + b.Property<decimal?>("Percentage") | |
| 168 | + .HasColumnType("numeric"); | |
| 169 | + | |
| 170 | + b.Property<Guid>("UserId") | |
| 171 | + .HasColumnType("uuid"); | |
| 172 | + | |
| 173 | + b.HasKey("Id"); | |
| 174 | + | |
| 175 | + b.HasIndex("ExpenseId"); | |
| 176 | + | |
| 177 | + b.HasIndex("UserId"); | |
| 178 | + | |
| 179 | + b.ToTable("ExpenseSplits"); | |
| 180 | + }); | |
| 181 | + | |
| 182 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 183 | + { | |
| 184 | + b.Property<Guid>("Id") | |
| 185 | + .ValueGeneratedOnAdd() | |
| 186 | + .HasColumnType("uuid"); | |
| 187 | + | |
| 188 | + b.Property<Guid>("AppUserId") | |
| 189 | + .HasColumnType("uuid"); | |
| 190 | + | |
| 191 | + b.Property<DateTime>("ExpirationDT") | |
| 192 | + .HasColumnType("timestamp with time zone"); | |
| 193 | + | |
| 194 | + b.Property<DateTime>("PreviousExpirationDT") | |
| 195 | + .HasColumnType("timestamp with time zone"); | |
| 196 | + | |
| 197 | + b.Property<string>("PreviousRefreshToken") | |
| 198 | + .HasMaxLength(64) | |
| 199 | + .HasColumnType("character varying(64)"); | |
| 200 | + | |
| 201 | + b.Property<string>("RefreshToken") | |
| 202 | + .IsRequired() | |
| 203 | + .HasMaxLength(64) | |
| 204 | + .HasColumnType("character varying(64)"); | |
| 205 | + | |
| 206 | + b.HasKey("Id"); | |
| 207 | + | |
| 208 | + b.HasIndex("AppUserId"); | |
| 209 | + | |
| 210 | + b.ToTable("RefreshTokens"); | |
| 211 | + }); | |
| 212 | + | |
| 213 | + modelBuilder.Entity("App.Domain.Identity.AppRole", b => | |
| 214 | + { | |
| 215 | + b.Property<Guid>("Id") | |
| 216 | + .ValueGeneratedOnAdd() | |
| 217 | + .HasColumnType("uuid"); | |
| 218 | + | |
| 219 | + b.Property<string>("ConcurrencyStamp") | |
| 220 | + .IsConcurrencyToken() | |
| 221 | + .HasColumnType("text"); | |
| 222 | + | |
| 223 | + b.Property<string>("Name") | |
| 224 | + .HasMaxLength(256) | |
| 225 | + .HasColumnType("character varying(256)"); | |
| 226 | + | |
| 227 | + b.Property<string>("NormalizedName") | |
| 228 | + .HasMaxLength(256) | |
| 229 | + .HasColumnType("character varying(256)"); | |
| 230 | + | |
| 231 | + b.HasKey("Id"); | |
| 232 | + | |
| 233 | + b.HasIndex("NormalizedName") | |
| 234 | + .IsUnique() | |
| 235 | + .HasDatabaseName("RoleNameIndex"); | |
| 236 | + | |
| 237 | + b.ToTable("AspNetRoles", (string)null); | |
| 238 | + }); | |
| 239 | + | |
| 240 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 241 | + { | |
| 242 | + b.Property<Guid>("Id") | |
| 243 | + .ValueGeneratedOnAdd() | |
| 244 | + .HasColumnType("uuid"); | |
| 245 | + | |
| 246 | + b.Property<int>("AccessFailedCount") | |
| 247 | + .HasColumnType("integer"); | |
| 248 | + | |
| 249 | + b.Property<string>("ConcurrencyStamp") | |
| 250 | + .IsConcurrencyToken() | |
| 251 | + .HasColumnType("text"); | |
| 252 | + | |
| 253 | + b.Property<string>("Email") | |
| 254 | + .HasMaxLength(256) | |
| 255 | + .HasColumnType("character varying(256)"); | |
| 256 | + | |
| 257 | + b.Property<bool>("EmailConfirmed") | |
| 258 | + .HasColumnType("boolean"); | |
| 259 | + | |
| 260 | + b.Property<string>("FirstName") | |
| 261 | + .IsRequired() | |
| 262 | + .HasMaxLength(128) | |
| 263 | + .HasColumnType("character varying(128)"); | |
| 264 | + | |
| 265 | + b.Property<string>("LastName") | |
| 266 | + .IsRequired() | |
| 267 | + .HasMaxLength(128) | |
| 268 | + .HasColumnType("character varying(128)"); | |
| 269 | + | |
| 270 | + b.Property<bool>("LockoutEnabled") | |
| 271 | + .HasColumnType("boolean"); | |
| 272 | + | |
| 273 | + b.Property<DateTimeOffset?>("LockoutEnd") | |
| 274 | + .HasColumnType("timestamp with time zone"); | |
| 275 | + | |
| 276 | + b.Property<string>("NormalizedEmail") | |
| 277 | + .HasMaxLength(256) | |
| 278 | + .HasColumnType("character varying(256)"); | |
| 279 | + | |
| 280 | + b.Property<string>("NormalizedUserName") | |
| 281 | + .HasMaxLength(256) | |
| 282 | + .HasColumnType("character varying(256)"); | |
| 283 | + | |
| 284 | + b.Property<string>("PasswordHash") | |
| 285 | + .HasColumnType("text"); | |
| 286 | + | |
| 287 | + b.Property<string>("PhoneNumber") | |
| 288 | + .HasColumnType("text"); | |
| 289 | + | |
| 290 | + b.Property<bool>("PhoneNumberConfirmed") | |
| 291 | + .HasColumnType("boolean"); | |
| 292 | + | |
| 293 | + b.Property<string>("SecurityStamp") | |
| 294 | + .HasColumnType("text"); | |
| 295 | + | |
| 296 | + b.Property<bool>("TwoFactorEnabled") | |
| 297 | + .HasColumnType("boolean"); | |
| 298 | + | |
| 299 | + b.Property<string>("UserName") | |
| 300 | + .HasMaxLength(256) | |
| 301 | + .HasColumnType("character varying(256)"); | |
| 302 | + | |
| 303 | + b.HasKey("Id"); | |
| 304 | + | |
| 305 | + b.HasIndex("NormalizedEmail") | |
| 306 | + .HasDatabaseName("EmailIndex"); | |
| 307 | + | |
| 308 | + b.HasIndex("NormalizedUserName") | |
| 309 | + .IsUnique() | |
| 310 | + .HasDatabaseName("UserNameIndex"); | |
| 311 | + | |
| 312 | + b.ToTable("AspNetUsers", (string)null); | |
| 313 | + }); | |
| 314 | + | |
| 315 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 316 | + { | |
| 317 | + b.Property<Guid>("Id") | |
| 318 | + .ValueGeneratedOnAdd() | |
| 319 | + .HasColumnType("uuid"); | |
| 320 | + | |
| 321 | + b.Property<decimal>("Amount") | |
| 322 | + .HasColumnType("numeric"); | |
| 323 | + | |
| 324 | + b.Property<DateTime?>("ConfirmedAt") | |
| 325 | + .HasColumnType("timestamp with time zone"); | |
| 326 | + | |
| 327 | + b.Property<Guid>("FromUserId") | |
| 328 | + .HasColumnType("uuid"); | |
| 329 | + | |
| 330 | + b.Property<DateTime?>("MarkedPaidAt") | |
| 331 | + .HasColumnType("timestamp with time zone"); | |
| 332 | + | |
| 333 | + b.Property<Guid>("SettlementPlanId") | |
| 334 | + .HasColumnType("uuid"); | |
| 335 | + | |
| 336 | + b.Property<int>("Status") | |
| 337 | + .HasColumnType("integer"); | |
| 338 | + | |
| 339 | + b.Property<Guid>("ToUserId") | |
| 340 | + .HasColumnType("uuid"); | |
| 341 | + | |
| 342 | + b.HasKey("Id"); | |
| 343 | + | |
| 344 | + b.HasIndex("FromUserId"); | |
| 345 | + | |
| 346 | + b.HasIndex("SettlementPlanId"); | |
| 347 | + | |
| 348 | + b.HasIndex("ToUserId"); | |
| 349 | + | |
| 350 | + b.ToTable("SettlementPayments"); | |
| 351 | + }); | |
| 352 | + | |
| 353 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 354 | + { | |
| 355 | + b.Property<Guid>("Id") | |
| 356 | + .ValueGeneratedOnAdd() | |
| 357 | + .HasColumnType("uuid"); | |
| 358 | + | |
| 359 | + b.Property<DateTime?>("CompletedAt") | |
| 360 | + .HasColumnType("timestamp with time zone"); | |
| 361 | + | |
| 362 | + b.Property<Guid>("CreatedByUserId") | |
| 363 | + .HasColumnType("uuid"); | |
| 364 | + | |
| 365 | + b.Property<int>("Status") | |
| 366 | + .HasColumnType("integer"); | |
| 367 | + | |
| 368 | + b.Property<decimal>("TotalAmount") | |
| 369 | + .HasColumnType("numeric"); | |
| 370 | + | |
| 371 | + b.Property<Guid>("TripId") | |
| 372 | + .HasColumnType("uuid"); | |
| 373 | + | |
| 374 | + b.HasKey("Id"); | |
| 375 | + | |
| 376 | + b.HasIndex("CreatedByUserId"); | |
| 377 | + | |
| 378 | + b.HasIndex("TripId"); | |
| 379 | + | |
| 380 | + b.ToTable("SettlementPlans"); | |
| 381 | + }); | |
| 382 | + | |
| 383 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 384 | + { | |
| 385 | + b.Property<Guid>("Id") | |
| 386 | + .ValueGeneratedOnAdd() | |
| 387 | + .HasColumnType("uuid"); | |
| 388 | + | |
| 389 | + b.Property<Guid>("CreatedById") | |
| 390 | + .HasColumnType("uuid"); | |
| 391 | + | |
| 392 | + b.Property<string>("Name") | |
| 393 | + .IsRequired() | |
| 394 | + .HasMaxLength(200) | |
| 395 | + .HasColumnType("character varying(200)"); | |
| 396 | + | |
| 397 | + b.Property<int>("SplitMethod") | |
| 398 | + .HasColumnType("integer"); | |
| 399 | + | |
| 400 | + b.Property<Guid>("TripId") | |
| 401 | + .HasColumnType("uuid"); | |
| 402 | + | |
| 403 | + b.HasKey("Id"); | |
| 404 | + | |
| 405 | + b.HasIndex("CreatedById"); | |
| 406 | + | |
| 407 | + b.HasIndex("TripId"); | |
| 408 | + | |
| 409 | + b.ToTable("SplitPresets"); | |
| 410 | + }); | |
| 411 | + | |
| 412 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 413 | + { | |
| 414 | + b.Property<Guid>("Id") | |
| 415 | + .ValueGeneratedOnAdd() | |
| 416 | + .HasColumnType("uuid"); | |
| 417 | + | |
| 418 | + b.Property<decimal?>("Percentage") | |
| 419 | + .HasColumnType("numeric"); | |
| 420 | + | |
| 421 | + b.Property<decimal?>("ShareWeight") | |
| 422 | + .HasColumnType("numeric"); | |
| 423 | + | |
| 424 | + b.Property<Guid>("SplitPresetId") | |
| 425 | + .HasColumnType("uuid"); | |
| 426 | + | |
| 427 | + b.Property<Guid>("UserId") | |
| 428 | + .HasColumnType("uuid"); | |
| 429 | + | |
| 430 | + b.HasKey("Id"); | |
| 431 | + | |
| 432 | + b.HasIndex("SplitPresetId"); | |
| 433 | + | |
| 434 | + b.HasIndex("UserId"); | |
| 435 | + | |
| 436 | + b.ToTable("SplitPresetMembers"); | |
| 437 | + }); | |
| 438 | + | |
| 439 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 440 | + { | |
| 441 | + b.Property<Guid>("Id") | |
| 442 | + .ValueGeneratedOnAdd() | |
| 443 | + .HasColumnType("uuid"); | |
| 444 | + | |
| 445 | + b.Property<Guid>("CreatedById") | |
| 446 | + .HasColumnType("uuid"); | |
| 447 | + | |
| 448 | + b.Property<Guid>("DefaultCurrencyId") | |
| 449 | + .HasColumnType("uuid"); | |
| 450 | + | |
| 451 | + b.Property<string>("Description") | |
| 452 | + .HasColumnType("text"); | |
| 453 | + | |
| 454 | + b.Property<string>("Destination") | |
| 455 | + .HasMaxLength(200) | |
| 456 | + .HasColumnType("character varying(200)"); | |
| 457 | + | |
| 458 | + b.Property<DateTime?>("EndDate") | |
| 459 | + .HasColumnType("timestamp with time zone"); | |
| 460 | + | |
| 461 | + b.Property<string>("Name") | |
| 462 | + .IsRequired() | |
| 463 | + .HasMaxLength(200) | |
| 464 | + .HasColumnType("character varying(200)"); | |
| 465 | + | |
| 466 | + b.Property<DateTime?>("StartDate") | |
| 467 | + .HasColumnType("timestamp with time zone"); | |
| 468 | + | |
| 469 | + b.Property<int>("Status") | |
| 470 | + .HasColumnType("integer"); | |
| 471 | + | |
| 472 | + b.HasKey("Id"); | |
| 473 | + | |
| 474 | + b.HasIndex("CreatedById"); | |
| 475 | + | |
| 476 | + b.HasIndex("DefaultCurrencyId"); | |
| 477 | + | |
| 478 | + b.ToTable("Trips"); | |
| 479 | + }); | |
| 480 | + | |
| 481 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 482 | + { | |
| 483 | + b.Property<Guid>("Id") | |
| 484 | + .ValueGeneratedOnAdd() | |
| 485 | + .HasColumnType("uuid"); | |
| 486 | + | |
| 487 | + b.Property<DateTime>("ExpiresAt") | |
| 488 | + .HasColumnType("timestamp with time zone"); | |
| 489 | + | |
| 490 | + b.Property<Guid>("InvitedByUserId") | |
| 491 | + .HasColumnType("uuid"); | |
| 492 | + | |
| 493 | + b.Property<DateTime?>("RespondedAt") | |
| 494 | + .HasColumnType("timestamp with time zone"); | |
| 495 | + | |
| 496 | + b.Property<int>("Status") | |
| 497 | + .HasColumnType("integer"); | |
| 498 | + | |
| 499 | + b.Property<string>("Token") | |
| 500 | + .IsRequired() | |
| 501 | + .HasMaxLength(256) | |
| 502 | + .HasColumnType("character varying(256)"); | |
| 503 | + | |
| 504 | + b.Property<Guid>("TripId") | |
| 505 | + .HasColumnType("uuid"); | |
| 506 | + | |
| 507 | + b.HasKey("Id"); | |
| 508 | + | |
| 509 | + b.HasIndex("InvitedByUserId"); | |
| 510 | + | |
| 511 | + b.HasIndex("Token") | |
| 512 | + .IsUnique(); | |
| 513 | + | |
| 514 | + b.HasIndex("TripId"); | |
| 515 | + | |
| 516 | + b.ToTable("TripInvitations"); | |
| 517 | + }); | |
| 518 | + | |
| 519 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 520 | + { | |
| 521 | + b.Property<Guid>("Id") | |
| 522 | + .ValueGeneratedOnAdd() | |
| 523 | + .HasColumnType("uuid"); | |
| 524 | + | |
| 525 | + b.Property<bool>("IsActive") | |
| 526 | + .HasColumnType("boolean"); | |
| 527 | + | |
| 528 | + b.Property<DateTime>("JoinedAt") | |
| 529 | + .HasColumnType("timestamp with time zone"); | |
| 530 | + | |
| 531 | + b.Property<DateTime?>("LeftAt") | |
| 532 | + .HasColumnType("timestamp with time zone"); | |
| 533 | + | |
| 534 | + b.Property<string>("Nickname") | |
| 535 | + .HasMaxLength(100) | |
| 536 | + .HasColumnType("character varying(100)"); | |
| 537 | + | |
| 538 | + b.Property<int>("Role") | |
| 539 | + .HasColumnType("integer"); | |
| 540 | + | |
| 541 | + b.Property<Guid>("TripId") | |
| 542 | + .HasColumnType("uuid"); | |
| 543 | + | |
| 544 | + b.Property<Guid>("UserId") | |
| 545 | + .HasColumnType("uuid"); | |
| 546 | + | |
| 547 | + b.HasKey("Id"); | |
| 548 | + | |
| 549 | + b.HasIndex("UserId"); | |
| 550 | + | |
| 551 | + b.HasIndex("TripId", "UserId") | |
| 552 | + .IsUnique(); | |
| 553 | + | |
| 554 | + b.ToTable("TripParticipants"); | |
| 555 | + }); | |
| 556 | + | |
| 557 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 558 | + { | |
| 559 | + b.Property<Guid>("Id") | |
| 560 | + .ValueGeneratedOnAdd() | |
| 561 | + .HasColumnType("uuid"); | |
| 562 | + | |
| 563 | + b.Property<bool>("AllowMultipleVotes") | |
| 564 | + .HasColumnType("boolean"); | |
| 565 | + | |
| 566 | + b.Property<DateTime?>("ClosedAt") | |
| 567 | + .HasColumnType("timestamp with time zone"); | |
| 568 | + | |
| 569 | + b.Property<Guid>("CreatedByUserId") | |
| 570 | + .HasColumnType("uuid"); | |
| 571 | + | |
| 572 | + b.Property<bool>("IsAnonymous") | |
| 573 | + .HasColumnType("boolean"); | |
| 574 | + | |
| 575 | + b.Property<string>("Question") | |
| 576 | + .IsRequired() | |
| 577 | + .HasMaxLength(500) | |
| 578 | + .HasColumnType("character varying(500)"); | |
| 579 | + | |
| 580 | + b.Property<Guid>("TripId") | |
| 581 | + .HasColumnType("uuid"); | |
| 582 | + | |
| 583 | + b.HasKey("Id"); | |
| 584 | + | |
| 585 | + b.HasIndex("CreatedByUserId"); | |
| 586 | + | |
| 587 | + b.HasIndex("TripId"); | |
| 588 | + | |
| 589 | + b.ToTable("TripPolls"); | |
| 590 | + }); | |
| 591 | + | |
| 592 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 593 | + { | |
| 594 | + b.Property<Guid>("Id") | |
| 595 | + .ValueGeneratedOnAdd() | |
| 596 | + .HasColumnType("uuid"); | |
| 597 | + | |
| 598 | + b.Property<int>("DisplayOrder") | |
| 599 | + .HasColumnType("integer"); | |
| 600 | + | |
| 601 | + b.Property<Guid>("PollId") | |
| 602 | + .HasColumnType("uuid"); | |
| 603 | + | |
| 604 | + b.Property<string>("Text") | |
| 605 | + .IsRequired() | |
| 606 | + .HasMaxLength(300) | |
| 607 | + .HasColumnType("character varying(300)"); | |
| 608 | + | |
| 609 | + b.HasKey("Id"); | |
| 610 | + | |
| 611 | + b.HasIndex("PollId"); | |
| 612 | + | |
| 613 | + b.ToTable("TripPollOptions"); | |
| 614 | + }); | |
| 615 | + | |
| 616 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 617 | + { | |
| 618 | + b.Property<Guid>("Id") | |
| 619 | + .ValueGeneratedOnAdd() | |
| 620 | + .HasColumnType("uuid"); | |
| 621 | + | |
| 622 | + b.Property<Guid>("PollOptionId") | |
| 623 | + .HasColumnType("uuid"); | |
| 624 | + | |
| 625 | + b.Property<Guid>("UserId") | |
| 626 | + .HasColumnType("uuid"); | |
| 627 | + | |
| 628 | + b.HasKey("Id"); | |
| 629 | + | |
| 630 | + b.HasIndex("UserId"); | |
| 631 | + | |
| 632 | + b.HasIndex("PollOptionId", "UserId") | |
| 633 | + .IsUnique(); | |
| 634 | + | |
| 635 | + b.ToTable("TripPollVotes"); | |
| 636 | + }); | |
| 637 | + | |
| 638 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 639 | + { | |
| 640 | + b.Property<Guid>("Id") | |
| 641 | + .ValueGeneratedOnAdd() | |
| 642 | + .HasColumnType("uuid"); | |
| 643 | + | |
| 644 | + b.Property<Guid>("AddedByUserId") | |
| 645 | + .HasColumnType("uuid"); | |
| 646 | + | |
| 647 | + b.Property<int>("Category") | |
| 648 | + .HasColumnType("integer"); | |
| 649 | + | |
| 650 | + b.Property<DateTime?>("CompletedAt") | |
| 651 | + .HasColumnType("timestamp with time zone"); | |
| 652 | + | |
| 653 | + b.Property<string>("Description") | |
| 654 | + .HasColumnType("text"); | |
| 655 | + | |
| 656 | + b.Property<int>("DisplayOrder") | |
| 657 | + .HasColumnType("integer"); | |
| 658 | + | |
| 659 | + b.Property<decimal?>("EstimatedCost") | |
| 660 | + .HasColumnType("numeric"); | |
| 661 | + | |
| 662 | + b.Property<bool>("IsCompleted") | |
| 663 | + .HasColumnType("boolean"); | |
| 664 | + | |
| 665 | + b.Property<string>("Location") | |
| 666 | + .HasMaxLength(300) | |
| 667 | + .HasColumnType("character varying(300)"); | |
| 668 | + | |
| 669 | + b.Property<int>("Priority") | |
| 670 | + .HasColumnType("integer"); | |
| 671 | + | |
| 672 | + b.Property<string>("Title") | |
| 673 | + .IsRequired() | |
| 674 | + .HasMaxLength(200) | |
| 675 | + .HasColumnType("character varying(200)"); | |
| 676 | + | |
| 677 | + b.Property<Guid>("TripId") | |
| 678 | + .HasColumnType("uuid"); | |
| 679 | + | |
| 680 | + b.Property<string>("Url") | |
| 681 | + .HasMaxLength(500) | |
| 682 | + .HasColumnType("character varying(500)"); | |
| 683 | + | |
| 684 | + b.HasKey("Id"); | |
| 685 | + | |
| 686 | + b.HasIndex("AddedByUserId"); | |
| 687 | + | |
| 688 | + b.HasIndex("TripId"); | |
| 689 | + | |
| 690 | + b.ToTable("TripWishlistItems"); | |
| 691 | + }); | |
| 692 | + | |
| 693 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 694 | + { | |
| 695 | + b.Property<Guid>("Id") | |
| 696 | + .ValueGeneratedOnAdd() | |
| 697 | + .HasColumnType("uuid"); | |
| 698 | + | |
| 699 | + b.Property<bool>("IsInterested") | |
| 700 | + .HasColumnType("boolean"); | |
| 701 | + | |
| 702 | + b.Property<Guid>("UserId") | |
| 703 | + .HasColumnType("uuid"); | |
| 704 | + | |
| 705 | + b.Property<Guid>("WishlistItemId") | |
| 706 | + .HasColumnType("uuid"); | |
| 707 | + | |
| 708 | + b.HasKey("Id"); | |
| 709 | + | |
| 710 | + b.HasIndex("UserId"); | |
| 711 | + | |
| 712 | + b.HasIndex("WishlistItemId", "UserId") | |
| 713 | + .IsUnique(); | |
| 714 | + | |
| 715 | + b.ToTable("TripWishlistVotes"); | |
| 716 | + }); | |
| 717 | + | |
| 718 | + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => | |
| 719 | + { | |
| 720 | + b.Property<int>("Id") | |
| 721 | + .ValueGeneratedOnAdd() | |
| 722 | + .HasColumnType("integer"); | |
| 723 | + | |
| 724 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 725 | + | |
| 726 | + b.Property<string>("FriendlyName") | |
| 727 | + .HasColumnType("text"); | |
| 728 | + | |
| 729 | + b.Property<string>("Xml") | |
| 730 | + .HasColumnType("text"); | |
| 731 | + | |
| 732 | + b.HasKey("Id"); | |
| 733 | + | |
| 734 | + b.ToTable("DataProtectionKeys"); | |
| 735 | + }); | |
| 736 | + | |
| 737 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 738 | + { | |
| 739 | + b.Property<int>("Id") | |
| 740 | + .ValueGeneratedOnAdd() | |
| 741 | + .HasColumnType("integer"); | |
| 742 | + | |
| 743 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 744 | + | |
| 745 | + b.Property<string>("ClaimType") | |
| 746 | + .HasColumnType("text"); | |
| 747 | + | |
| 748 | + b.Property<string>("ClaimValue") | |
| 749 | + .HasColumnType("text"); | |
| 750 | + | |
| 751 | + b.Property<Guid>("RoleId") | |
| 752 | + .HasColumnType("uuid"); | |
| 753 | + | |
| 754 | + b.HasKey("Id"); | |
| 755 | + | |
| 756 | + b.HasIndex("RoleId"); | |
| 757 | + | |
| 758 | + b.ToTable("AspNetRoleClaims", (string)null); | |
| 759 | + }); | |
| 760 | + | |
| 761 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 762 | + { | |
| 763 | + b.Property<int>("Id") | |
| 764 | + .ValueGeneratedOnAdd() | |
| 765 | + .HasColumnType("integer"); | |
| 766 | + | |
| 767 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 768 | + | |
| 769 | + b.Property<string>("ClaimType") | |
| 770 | + .HasColumnType("text"); | |
| 771 | + | |
| 772 | + b.Property<string>("ClaimValue") | |
| 773 | + .HasColumnType("text"); | |
| 774 | + | |
| 775 | + b.Property<Guid>("UserId") | |
| 776 | + .HasColumnType("uuid"); | |
| 777 | + | |
| 778 | + b.HasKey("Id"); | |
| 779 | + | |
| 780 | + b.HasIndex("UserId"); | |
| 781 | + | |
| 782 | + b.ToTable("AspNetUserClaims", (string)null); | |
| 783 | + }); | |
| 784 | + | |
| 785 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 786 | + { | |
| 787 | + b.Property<string>("LoginProvider") | |
| 788 | + .HasColumnType("text"); | |
| 789 | + | |
| 790 | + b.Property<string>("ProviderKey") | |
| 791 | + .HasColumnType("text"); | |
| 792 | + | |
| 793 | + b.Property<string>("ProviderDisplayName") | |
| 794 | + .HasColumnType("text"); | |
| 795 | + | |
| 796 | + b.Property<Guid>("UserId") | |
| 797 | + .HasColumnType("uuid"); | |
| 798 | + | |
| 799 | + b.HasKey("LoginProvider", "ProviderKey"); | |
| 800 | + | |
| 801 | + b.HasIndex("UserId"); | |
| 802 | + | |
| 803 | + b.ToTable("AspNetUserLogins", (string)null); | |
| 804 | + }); | |
| 805 | + | |
| 806 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 807 | + { | |
| 808 | + b.Property<Guid>("UserId") | |
| 809 | + .HasColumnType("uuid"); | |
| 810 | + | |
| 811 | + b.Property<Guid>("RoleId") | |
| 812 | + .HasColumnType("uuid"); | |
| 813 | + | |
| 814 | + b.HasKey("UserId", "RoleId"); | |
| 815 | + | |
| 816 | + b.HasIndex("RoleId"); | |
| 817 | + | |
| 818 | + b.ToTable("AspNetUserRoles", (string)null); | |
| 819 | + }); | |
| 820 | + | |
| 821 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 822 | + { | |
| 823 | + b.Property<Guid>("UserId") | |
| 824 | + .HasColumnType("uuid"); | |
| 825 | + | |
| 826 | + b.Property<string>("LoginProvider") | |
| 827 | + .HasColumnType("text"); | |
| 828 | + | |
| 829 | + b.Property<string>("Name") | |
| 830 | + .HasColumnType("text"); | |
| 831 | + | |
| 832 | + b.Property<string>("Value") | |
| 833 | + .HasColumnType("text"); | |
| 834 | + | |
| 835 | + b.HasKey("UserId", "LoginProvider", "Name"); | |
| 836 | + | |
| 837 | + b.ToTable("AspNetUserTokens", (string)null); | |
| 838 | + }); | |
| 839 | + | |
| 840 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 841 | + { | |
| 842 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 843 | + .WithMany("BudgetCategories") | |
| 844 | + .HasForeignKey("TripId") | |
| 845 | + .OnDelete(DeleteBehavior.Restrict) | |
| 846 | + .IsRequired(); | |
| 847 | + | |
| 848 | + b.Navigation("Trip"); | |
| 849 | + }); | |
| 850 | + | |
| 851 | + modelBuilder.Entity("App.Domain.BudgetCategoryTranslation", b => | |
| 852 | + { | |
| 853 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 854 | + .WithMany("Translations") | |
| 855 | + .HasForeignKey("BudgetCategoryId") | |
| 856 | + .OnDelete(DeleteBehavior.Restrict) | |
| 857 | + .IsRequired(); | |
| 858 | + | |
| 859 | + b.Navigation("BudgetCategory"); | |
| 860 | + }); | |
| 861 | + | |
| 862 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 863 | + { | |
| 864 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 865 | + .WithMany("Expenses") | |
| 866 | + .HasForeignKey("BudgetCategoryId") | |
| 867 | + .OnDelete(DeleteBehavior.Restrict); | |
| 868 | + | |
| 869 | + b.HasOne("App.Domain.Currency", "Currency") | |
| 870 | + .WithMany() | |
| 871 | + .HasForeignKey("CurrencyId") | |
| 872 | + .OnDelete(DeleteBehavior.Restrict); | |
| 873 | + | |
| 874 | + b.HasOne("App.Domain.Identity.AppUser", "PaidByUser") | |
| 875 | + .WithMany() | |
| 876 | + .HasForeignKey("PaidByUserId") | |
| 877 | + .OnDelete(DeleteBehavior.Restrict) | |
| 878 | + .IsRequired(); | |
| 879 | + | |
| 880 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 881 | + .WithMany("Expenses") | |
| 882 | + .HasForeignKey("TripId") | |
| 883 | + .OnDelete(DeleteBehavior.Restrict) | |
| 884 | + .IsRequired(); | |
| 885 | + | |
| 886 | + b.Navigation("BudgetCategory"); | |
| 887 | + | |
| 888 | + b.Navigation("Currency"); | |
| 889 | + | |
| 890 | + b.Navigation("PaidByUser"); | |
| 891 | + | |
| 892 | + b.Navigation("Trip"); | |
| 893 | + }); | |
| 894 | + | |
| 895 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 896 | + { | |
| 897 | + b.HasOne("App.Domain.Expense", "Expense") | |
| 898 | + .WithMany("Splits") | |
| 899 | + .HasForeignKey("ExpenseId") | |
| 900 | + .OnDelete(DeleteBehavior.Restrict) | |
| 901 | + .IsRequired(); | |
| 902 | + | |
| 903 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 904 | + .WithMany() | |
| 905 | + .HasForeignKey("UserId") | |
| 906 | + .OnDelete(DeleteBehavior.Restrict) | |
| 907 | + .IsRequired(); | |
| 908 | + | |
| 909 | + b.Navigation("Expense"); | |
| 910 | + | |
| 911 | + b.Navigation("User"); | |
| 912 | + }); | |
| 913 | + | |
| 914 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 915 | + { | |
| 916 | + b.HasOne("App.Domain.Identity.AppUser", "AppUser") | |
| 917 | + .WithMany("RefreshTokens") | |
| 918 | + .HasForeignKey("AppUserId") | |
| 919 | + .OnDelete(DeleteBehavior.Restrict) | |
| 920 | + .IsRequired(); | |
| 921 | + | |
| 922 | + b.Navigation("AppUser"); | |
| 923 | + }); | |
| 924 | + | |
| 925 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 926 | + { | |
| 927 | + b.HasOne("App.Domain.Identity.AppUser", "FromUser") | |
| 928 | + .WithMany() | |
| 929 | + .HasForeignKey("FromUserId") | |
| 930 | + .OnDelete(DeleteBehavior.Restrict) | |
| 931 | + .IsRequired(); | |
| 932 | + | |
| 933 | + b.HasOne("App.Domain.SettlementPlan", "SettlementPlan") | |
| 934 | + .WithMany("Payments") | |
| 935 | + .HasForeignKey("SettlementPlanId") | |
| 936 | + .OnDelete(DeleteBehavior.Restrict) | |
| 937 | + .IsRequired(); | |
| 938 | + | |
| 939 | + b.HasOne("App.Domain.Identity.AppUser", "ToUser") | |
| 940 | + .WithMany() | |
| 941 | + .HasForeignKey("ToUserId") | |
| 942 | + .OnDelete(DeleteBehavior.Restrict) | |
| 943 | + .IsRequired(); | |
| 944 | + | |
| 945 | + b.Navigation("FromUser"); | |
| 946 | + | |
| 947 | + b.Navigation("SettlementPlan"); | |
| 948 | + | |
| 949 | + b.Navigation("ToUser"); | |
| 950 | + }); | |
| 951 | + | |
| 952 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 953 | + { | |
| 954 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 955 | + .WithMany() | |
| 956 | + .HasForeignKey("CreatedByUserId") | |
| 957 | + .OnDelete(DeleteBehavior.Restrict) | |
| 958 | + .IsRequired(); | |
| 959 | + | |
| 960 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 961 | + .WithMany("SettlementPlans") | |
| 962 | + .HasForeignKey("TripId") | |
| 963 | + .OnDelete(DeleteBehavior.Restrict) | |
| 964 | + .IsRequired(); | |
| 965 | + | |
| 966 | + b.Navigation("CreatedByUser"); | |
| 967 | + | |
| 968 | + b.Navigation("Trip"); | |
| 969 | + }); | |
| 970 | + | |
| 971 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 972 | + { | |
| 973 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 974 | + .WithMany() | |
| 975 | + .HasForeignKey("CreatedById") | |
| 976 | + .OnDelete(DeleteBehavior.Restrict) | |
| 977 | + .IsRequired(); | |
| 978 | + | |
| 979 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 980 | + .WithMany() | |
| 981 | + .HasForeignKey("TripId") | |
| 982 | + .OnDelete(DeleteBehavior.Restrict) | |
| 983 | + .IsRequired(); | |
| 984 | + | |
| 985 | + b.Navigation("CreatedBy"); | |
| 986 | + | |
| 987 | + b.Navigation("Trip"); | |
| 988 | + }); | |
| 989 | + | |
| 990 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 991 | + { | |
| 992 | + b.HasOne("App.Domain.SplitPreset", "SplitPreset") | |
| 993 | + .WithMany("Members") | |
| 994 | + .HasForeignKey("SplitPresetId") | |
| 995 | + .OnDelete(DeleteBehavior.Restrict) | |
| 996 | + .IsRequired(); | |
| 997 | + | |
| 998 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 999 | + .WithMany() | |
| 1000 | + .HasForeignKey("UserId") | |
| 1001 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1002 | + .IsRequired(); | |
| 1003 | + | |
| 1004 | + b.Navigation("SplitPreset"); | |
| 1005 | + | |
| 1006 | + b.Navigation("User"); | |
| 1007 | + }); | |
| 1008 | + | |
| 1009 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1010 | + { | |
| 1011 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1012 | + .WithMany() | |
| 1013 | + .HasForeignKey("CreatedById") | |
| 1014 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1015 | + .IsRequired(); | |
| 1016 | + | |
| 1017 | + b.HasOne("App.Domain.Currency", "DefaultCurrency") | |
| 1018 | + .WithMany() | |
| 1019 | + .HasForeignKey("DefaultCurrencyId") | |
| 1020 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1021 | + .IsRequired(); | |
| 1022 | + | |
| 1023 | + b.Navigation("CreatedBy"); | |
| 1024 | + | |
| 1025 | + b.Navigation("DefaultCurrency"); | |
| 1026 | + }); | |
| 1027 | + | |
| 1028 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 1029 | + { | |
| 1030 | + b.HasOne("App.Domain.Identity.AppUser", "InvitedByUser") | |
| 1031 | + .WithMany() | |
| 1032 | + .HasForeignKey("InvitedByUserId") | |
| 1033 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1034 | + .IsRequired(); | |
| 1035 | + | |
| 1036 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1037 | + .WithMany("Invitations") | |
| 1038 | + .HasForeignKey("TripId") | |
| 1039 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1040 | + .IsRequired(); | |
| 1041 | + | |
| 1042 | + b.Navigation("InvitedByUser"); | |
| 1043 | + | |
| 1044 | + b.Navigation("Trip"); | |
| 1045 | + }); | |
| 1046 | + | |
| 1047 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 1048 | + { | |
| 1049 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1050 | + .WithMany("Participants") | |
| 1051 | + .HasForeignKey("TripId") | |
| 1052 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1053 | + .IsRequired(); | |
| 1054 | + | |
| 1055 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1056 | + .WithMany("TripParticipants") | |
| 1057 | + .HasForeignKey("UserId") | |
| 1058 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1059 | + .IsRequired(); | |
| 1060 | + | |
| 1061 | + b.Navigation("Trip"); | |
| 1062 | + | |
| 1063 | + b.Navigation("User"); | |
| 1064 | + }); | |
| 1065 | + | |
| 1066 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1067 | + { | |
| 1068 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1069 | + .WithMany() | |
| 1070 | + .HasForeignKey("CreatedByUserId") | |
| 1071 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1072 | + .IsRequired(); | |
| 1073 | + | |
| 1074 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1075 | + .WithMany("Polls") | |
| 1076 | + .HasForeignKey("TripId") | |
| 1077 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1078 | + .IsRequired(); | |
| 1079 | + | |
| 1080 | + b.Navigation("CreatedByUser"); | |
| 1081 | + | |
| 1082 | + b.Navigation("Trip"); | |
| 1083 | + }); | |
| 1084 | + | |
| 1085 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1086 | + { | |
| 1087 | + b.HasOne("App.Domain.TripPoll", "Poll") | |
| 1088 | + .WithMany("Options") | |
| 1089 | + .HasForeignKey("PollId") | |
| 1090 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1091 | + .IsRequired(); | |
| 1092 | + | |
| 1093 | + b.Navigation("Poll"); | |
| 1094 | + }); | |
| 1095 | + | |
| 1096 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 1097 | + { | |
| 1098 | + b.HasOne("App.Domain.TripPollOption", "PollOption") | |
| 1099 | + .WithMany("Votes") | |
| 1100 | + .HasForeignKey("PollOptionId") | |
| 1101 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1102 | + .IsRequired(); | |
| 1103 | + | |
| 1104 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1105 | + .WithMany() | |
| 1106 | + .HasForeignKey("UserId") | |
| 1107 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1108 | + .IsRequired(); | |
| 1109 | + | |
| 1110 | + b.Navigation("PollOption"); | |
| 1111 | + | |
| 1112 | + b.Navigation("User"); | |
| 1113 | + }); | |
| 1114 | + | |
| 1115 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1116 | + { | |
| 1117 | + b.HasOne("App.Domain.Identity.AppUser", "AddedByUser") | |
| 1118 | + .WithMany() | |
| 1119 | + .HasForeignKey("AddedByUserId") | |
| 1120 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1121 | + .IsRequired(); | |
| 1122 | + | |
| 1123 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1124 | + .WithMany("WishlistItems") | |
| 1125 | + .HasForeignKey("TripId") | |
| 1126 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1127 | + .IsRequired(); | |
| 1128 | + | |
| 1129 | + b.Navigation("AddedByUser"); | |
| 1130 | + | |
| 1131 | + b.Navigation("Trip"); | |
| 1132 | + }); | |
| 1133 | + | |
| 1134 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 1135 | + { | |
| 1136 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1137 | + .WithMany() | |
| 1138 | + .HasForeignKey("UserId") | |
| 1139 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1140 | + .IsRequired(); | |
| 1141 | + | |
| 1142 | + b.HasOne("App.Domain.TripWishlistItem", "WishlistItem") | |
| 1143 | + .WithMany("Votes") | |
| 1144 | + .HasForeignKey("WishlistItemId") | |
| 1145 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1146 | + .IsRequired(); | |
| 1147 | + | |
| 1148 | + b.Navigation("User"); | |
| 1149 | + | |
| 1150 | + b.Navigation("WishlistItem"); | |
| 1151 | + }); | |
| 1152 | + | |
| 1153 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 1154 | + { | |
| 1155 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1156 | + .WithMany() | |
| 1157 | + .HasForeignKey("RoleId") | |
| 1158 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1159 | + .IsRequired(); | |
| 1160 | + }); | |
| 1161 | + | |
| 1162 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 1163 | + { | |
| 1164 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1165 | + .WithMany() | |
| 1166 | + .HasForeignKey("UserId") | |
| 1167 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1168 | + .IsRequired(); | |
| 1169 | + }); | |
| 1170 | + | |
| 1171 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 1172 | + { | |
| 1173 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1174 | + .WithMany() | |
| 1175 | + .HasForeignKey("UserId") | |
| 1176 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1177 | + .IsRequired(); | |
| 1178 | + }); | |
| 1179 | + | |
| 1180 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 1181 | + { | |
| 1182 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1183 | + .WithMany() | |
| 1184 | + .HasForeignKey("RoleId") | |
| 1185 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1186 | + .IsRequired(); | |
| 1187 | + | |
| 1188 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1189 | + .WithMany() | |
| 1190 | + .HasForeignKey("UserId") | |
| 1191 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1192 | + .IsRequired(); | |
| 1193 | + }); | |
| 1194 | + | |
| 1195 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 1196 | + { | |
| 1197 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1198 | + .WithMany() | |
| 1199 | + .HasForeignKey("UserId") | |
| 1200 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1201 | + .IsRequired(); | |
| 1202 | + }); | |
| 1203 | + | |
| 1204 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 1205 | + { | |
| 1206 | + b.Navigation("Expenses"); | |
| 1207 | + | |
| 1208 | + b.Navigation("Translations"); | |
| 1209 | + }); | |
| 1210 | + | |
| 1211 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 1212 | + { | |
| 1213 | + b.Navigation("Splits"); | |
| 1214 | + }); | |
| 1215 | + | |
| 1216 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 1217 | + { | |
| 1218 | + b.Navigation("RefreshTokens"); | |
| 1219 | + | |
| 1220 | + b.Navigation("TripParticipants"); | |
| 1221 | + }); | |
| 1222 | + | |
| 1223 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1224 | + { | |
| 1225 | + b.Navigation("Payments"); | |
| 1226 | + }); | |
| 1227 | + | |
| 1228 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1229 | + { | |
| 1230 | + b.Navigation("Members"); | |
| 1231 | + }); | |
| 1232 | + | |
| 1233 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1234 | + { | |
| 1235 | + b.Navigation("BudgetCategories"); | |
| 1236 | + | |
| 1237 | + b.Navigation("Expenses"); | |
| 1238 | + | |
| 1239 | + b.Navigation("Invitations"); | |
| 1240 | + | |
| 1241 | + b.Navigation("Participants"); | |
| 1242 | + | |
| 1243 | + b.Navigation("Polls"); | |
| 1244 | + | |
| 1245 | + b.Navigation("SettlementPlans"); | |
| 1246 | + | |
| 1247 | + b.Navigation("WishlistItems"); | |
| 1248 | + }); | |
| 1249 | + | |
| 1250 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1251 | + { | |
| 1252 | + b.Navigation("Options"); | |
| 1253 | + }); | |
| 1254 | + | |
| 1255 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1256 | + { | |
| 1257 | + b.Navigation("Votes"); | |
| 1258 | + }); | |
| 1259 | + | |
| 1260 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1261 | + { | |
| 1262 | + b.Navigation("Votes"); | |
| 1263 | + }); | |
| 1264 | +#pragma warning restore 612, 618 | |
| 1265 | + } | |
| 1266 | + } | |
| 1267 | +} |
added SplitApp/App.DAL.EF/Migrations/20260328145416_Initial.cs +961 −0
| @@ -0,0 +1,961 @@ | ||
| 1 | +using System; | |
| 2 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 3 | +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | |
| 4 | + | |
| 5 | +#nullable disable | |
| 6 | + | |
| 7 | +namespace App.DAL.EF.Migrations | |
| 8 | +{ | |
| 9 | + /// <inheritdoc /> | |
| 10 | + public partial class Initial : Migration | |
| 11 | + { | |
| 12 | + /// <inheritdoc /> | |
| 13 | + protected override void Up(MigrationBuilder migrationBuilder) | |
| 14 | + { | |
| 15 | + migrationBuilder.CreateTable( | |
| 16 | + name: "AspNetRoles", | |
| 17 | + columns: table => new | |
| 18 | + { | |
| 19 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 20 | + Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), | |
| 21 | + NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), | |
| 22 | + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true) | |
| 23 | + }, | |
| 24 | + constraints: table => | |
| 25 | + { | |
| 26 | + table.PrimaryKey("PK_AspNetRoles", x => x.Id); | |
| 27 | + }); | |
| 28 | + | |
| 29 | + migrationBuilder.CreateTable( | |
| 30 | + name: "AspNetUsers", | |
| 31 | + columns: table => new | |
| 32 | + { | |
| 33 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 34 | + FirstName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), | |
| 35 | + LastName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), | |
| 36 | + UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), | |
| 37 | + NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), | |
| 38 | + Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), | |
| 39 | + NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), | |
| 40 | + EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false), | |
| 41 | + PasswordHash = table.Column<string>(type: "text", nullable: true), | |
| 42 | + SecurityStamp = table.Column<string>(type: "text", nullable: true), | |
| 43 | + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true), | |
| 44 | + PhoneNumber = table.Column<string>(type: "text", nullable: true), | |
| 45 | + PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false), | |
| 46 | + TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false), | |
| 47 | + LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true), | |
| 48 | + LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false), | |
| 49 | + AccessFailedCount = table.Column<int>(type: "integer", nullable: false) | |
| 50 | + }, | |
| 51 | + constraints: table => | |
| 52 | + { | |
| 53 | + table.PrimaryKey("PK_AspNetUsers", x => x.Id); | |
| 54 | + }); | |
| 55 | + | |
| 56 | + migrationBuilder.CreateTable( | |
| 57 | + name: "Currencies", | |
| 58 | + columns: table => new | |
| 59 | + { | |
| 60 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 61 | + Code = table.Column<string>(type: "character varying(3)", maxLength: 3, nullable: false), | |
| 62 | + Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), | |
| 63 | + Symbol = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false) | |
| 64 | + }, | |
| 65 | + constraints: table => | |
| 66 | + { | |
| 67 | + table.PrimaryKey("PK_Currencies", x => x.Id); | |
| 68 | + }); | |
| 69 | + | |
| 70 | + migrationBuilder.CreateTable( | |
| 71 | + name: "DataProtectionKeys", | |
| 72 | + columns: table => new | |
| 73 | + { | |
| 74 | + Id = table.Column<int>(type: "integer", nullable: false) | |
| 75 | + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), | |
| 76 | + FriendlyName = table.Column<string>(type: "text", nullable: true), | |
| 77 | + Xml = table.Column<string>(type: "text", nullable: true) | |
| 78 | + }, | |
| 79 | + constraints: table => | |
| 80 | + { | |
| 81 | + table.PrimaryKey("PK_DataProtectionKeys", x => x.Id); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + migrationBuilder.CreateTable( | |
| 85 | + name: "AspNetRoleClaims", | |
| 86 | + columns: table => new | |
| 87 | + { | |
| 88 | + Id = table.Column<int>(type: "integer", nullable: false) | |
| 89 | + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), | |
| 90 | + RoleId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 91 | + ClaimType = table.Column<string>(type: "text", nullable: true), | |
| 92 | + ClaimValue = table.Column<string>(type: "text", nullable: true) | |
| 93 | + }, | |
| 94 | + constraints: table => | |
| 95 | + { | |
| 96 | + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); | |
| 97 | + table.ForeignKey( | |
| 98 | + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", | |
| 99 | + column: x => x.RoleId, | |
| 100 | + principalTable: "AspNetRoles", | |
| 101 | + principalColumn: "Id", | |
| 102 | + onDelete: ReferentialAction.Restrict); | |
| 103 | + }); | |
| 104 | + | |
| 105 | + migrationBuilder.CreateTable( | |
| 106 | + name: "AspNetUserClaims", | |
| 107 | + columns: table => new | |
| 108 | + { | |
| 109 | + Id = table.Column<int>(type: "integer", nullable: false) | |
| 110 | + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), | |
| 111 | + UserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 112 | + ClaimType = table.Column<string>(type: "text", nullable: true), | |
| 113 | + ClaimValue = table.Column<string>(type: "text", nullable: true) | |
| 114 | + }, | |
| 115 | + constraints: table => | |
| 116 | + { | |
| 117 | + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); | |
| 118 | + table.ForeignKey( | |
| 119 | + name: "FK_AspNetUserClaims_AspNetUsers_UserId", | |
| 120 | + column: x => x.UserId, | |
| 121 | + principalTable: "AspNetUsers", | |
| 122 | + principalColumn: "Id", | |
| 123 | + onDelete: ReferentialAction.Restrict); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + migrationBuilder.CreateTable( | |
| 127 | + name: "AspNetUserLogins", | |
| 128 | + columns: table => new | |
| 129 | + { | |
| 130 | + LoginProvider = table.Column<string>(type: "text", nullable: false), | |
| 131 | + ProviderKey = table.Column<string>(type: "text", nullable: false), | |
| 132 | + ProviderDisplayName = table.Column<string>(type: "text", nullable: true), | |
| 133 | + UserId = table.Column<Guid>(type: "uuid", nullable: false) | |
| 134 | + }, | |
| 135 | + constraints: table => | |
| 136 | + { | |
| 137 | + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); | |
| 138 | + table.ForeignKey( | |
| 139 | + name: "FK_AspNetUserLogins_AspNetUsers_UserId", | |
| 140 | + column: x => x.UserId, | |
| 141 | + principalTable: "AspNetUsers", | |
| 142 | + principalColumn: "Id", | |
| 143 | + onDelete: ReferentialAction.Restrict); | |
| 144 | + }); | |
| 145 | + | |
| 146 | + migrationBuilder.CreateTable( | |
| 147 | + name: "AspNetUserRoles", | |
| 148 | + columns: table => new | |
| 149 | + { | |
| 150 | + UserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 151 | + RoleId = table.Column<Guid>(type: "uuid", nullable: false) | |
| 152 | + }, | |
| 153 | + constraints: table => | |
| 154 | + { | |
| 155 | + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); | |
| 156 | + table.ForeignKey( | |
| 157 | + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", | |
| 158 | + column: x => x.RoleId, | |
| 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 | + principalTable: "AspNetUsers", | |
| 166 | + principalColumn: "Id", | |
| 167 | + onDelete: ReferentialAction.Restrict); | |
| 168 | + }); | |
| 169 | + | |
| 170 | + migrationBuilder.CreateTable( | |
| 171 | + name: "AspNetUserTokens", | |
| 172 | + columns: table => new | |
| 173 | + { | |
| 174 | + UserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 175 | + LoginProvider = table.Column<string>(type: "text", nullable: false), | |
| 176 | + Name = table.Column<string>(type: "text", nullable: false), | |
| 177 | + Value = table.Column<string>(type: "text", nullable: true) | |
| 178 | + }, | |
| 179 | + constraints: table => | |
| 180 | + { | |
| 181 | + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); | |
| 182 | + table.ForeignKey( | |
| 183 | + name: "FK_AspNetUserTokens_AspNetUsers_UserId", | |
| 184 | + column: x => x.UserId, | |
| 185 | + principalTable: "AspNetUsers", | |
| 186 | + principalColumn: "Id", | |
| 187 | + onDelete: ReferentialAction.Restrict); | |
| 188 | + }); | |
| 189 | + | |
| 190 | + migrationBuilder.CreateTable( | |
| 191 | + name: "RefreshTokens", | |
| 192 | + columns: table => new | |
| 193 | + { | |
| 194 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 195 | + RefreshToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false), | |
| 196 | + ExpirationDT = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | |
| 197 | + PreviousRefreshToken = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true), | |
| 198 | + PreviousExpirationDT = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | |
| 199 | + AppUserId = table.Column<Guid>(type: "uuid", nullable: false) | |
| 200 | + }, | |
| 201 | + constraints: table => | |
| 202 | + { | |
| 203 | + table.PrimaryKey("PK_RefreshTokens", x => x.Id); | |
| 204 | + table.ForeignKey( | |
| 205 | + name: "FK_RefreshTokens_AspNetUsers_AppUserId", | |
| 206 | + column: x => x.AppUserId, | |
| 207 | + principalTable: "AspNetUsers", | |
| 208 | + principalColumn: "Id", | |
| 209 | + onDelete: ReferentialAction.Restrict); | |
| 210 | + }); | |
| 211 | + | |
| 212 | + migrationBuilder.CreateTable( | |
| 213 | + name: "Trips", | |
| 214 | + columns: table => new | |
| 215 | + { | |
| 216 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 217 | + Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false), | |
| 218 | + Description = table.Column<string>(type: "text", nullable: true), | |
| 219 | + Destination = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true), | |
| 220 | + StartDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), | |
| 221 | + EndDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), | |
| 222 | + Status = table.Column<int>(type: "integer", nullable: false), | |
| 223 | + DefaultCurrencyId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 224 | + CreatedById = table.Column<Guid>(type: "uuid", nullable: false) | |
| 225 | + }, | |
| 226 | + constraints: table => | |
| 227 | + { | |
| 228 | + table.PrimaryKey("PK_Trips", x => x.Id); | |
| 229 | + table.ForeignKey( | |
| 230 | + name: "FK_Trips_AspNetUsers_CreatedById", | |
| 231 | + column: x => x.CreatedById, | |
| 232 | + principalTable: "AspNetUsers", | |
| 233 | + principalColumn: "Id", | |
| 234 | + onDelete: ReferentialAction.Restrict); | |
| 235 | + table.ForeignKey( | |
| 236 | + name: "FK_Trips_Currencies_DefaultCurrencyId", | |
| 237 | + column: x => x.DefaultCurrencyId, | |
| 238 | + principalTable: "Currencies", | |
| 239 | + principalColumn: "Id", | |
| 240 | + onDelete: ReferentialAction.Restrict); | |
| 241 | + }); | |
| 242 | + | |
| 243 | + migrationBuilder.CreateTable( | |
| 244 | + name: "BudgetCategories", | |
| 245 | + columns: table => new | |
| 246 | + { | |
| 247 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 248 | + TripId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 249 | + Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), | |
| 250 | + IconName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true), | |
| 251 | + PlannedAmount = table.Column<decimal>(type: "numeric", nullable: true), | |
| 252 | + DisplayOrder = table.Column<int>(type: "integer", nullable: false) | |
| 253 | + }, | |
| 254 | + constraints: table => | |
| 255 | + { | |
| 256 | + table.PrimaryKey("PK_BudgetCategories", x => x.Id); | |
| 257 | + table.ForeignKey( | |
| 258 | + name: "FK_BudgetCategories_Trips_TripId", | |
| 259 | + column: x => x.TripId, | |
| 260 | + principalTable: "Trips", | |
| 261 | + principalColumn: "Id", | |
| 262 | + onDelete: ReferentialAction.Restrict); | |
| 263 | + }); | |
| 264 | + | |
| 265 | + migrationBuilder.CreateTable( | |
| 266 | + name: "SettlementPlans", | |
| 267 | + columns: table => new | |
| 268 | + { | |
| 269 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 270 | + TripId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 271 | + CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 272 | + TotalAmount = table.Column<decimal>(type: "numeric", nullable: false), | |
| 273 | + Status = table.Column<int>(type: "integer", nullable: false), | |
| 274 | + CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true) | |
| 275 | + }, | |
| 276 | + constraints: table => | |
| 277 | + { | |
| 278 | + table.PrimaryKey("PK_SettlementPlans", x => x.Id); | |
| 279 | + table.ForeignKey( | |
| 280 | + name: "FK_SettlementPlans_AspNetUsers_CreatedByUserId", | |
| 281 | + column: x => x.CreatedByUserId, | |
| 282 | + principalTable: "AspNetUsers", | |
| 283 | + principalColumn: "Id", | |
| 284 | + onDelete: ReferentialAction.Restrict); | |
| 285 | + table.ForeignKey( | |
| 286 | + name: "FK_SettlementPlans_Trips_TripId", | |
| 287 | + column: x => x.TripId, | |
| 288 | + principalTable: "Trips", | |
| 289 | + principalColumn: "Id", | |
| 290 | + onDelete: ReferentialAction.Restrict); | |
| 291 | + }); | |
| 292 | + | |
| 293 | + migrationBuilder.CreateTable( | |
| 294 | + name: "SplitPresets", | |
| 295 | + columns: table => new | |
| 296 | + { | |
| 297 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 298 | + TripId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 299 | + Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false), | |
| 300 | + SplitMethod = table.Column<int>(type: "integer", nullable: false), | |
| 301 | + CreatedById = table.Column<Guid>(type: "uuid", nullable: false) | |
| 302 | + }, | |
| 303 | + constraints: table => | |
| 304 | + { | |
| 305 | + table.PrimaryKey("PK_SplitPresets", x => x.Id); | |
| 306 | + table.ForeignKey( | |
| 307 | + name: "FK_SplitPresets_AspNetUsers_CreatedById", | |
| 308 | + column: x => x.CreatedById, | |
| 309 | + principalTable: "AspNetUsers", | |
| 310 | + principalColumn: "Id", | |
| 311 | + onDelete: ReferentialAction.Restrict); | |
| 312 | + table.ForeignKey( | |
| 313 | + name: "FK_SplitPresets_Trips_TripId", | |
| 314 | + column: x => x.TripId, | |
| 315 | + principalTable: "Trips", | |
| 316 | + principalColumn: "Id", | |
| 317 | + onDelete: ReferentialAction.Restrict); | |
| 318 | + }); | |
| 319 | + | |
| 320 | + migrationBuilder.CreateTable( | |
| 321 | + name: "TripInvitations", | |
| 322 | + columns: table => new | |
| 323 | + { | |
| 324 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 325 | + TripId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 326 | + InvitedByUserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 327 | + Token = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), | |
| 328 | + Status = table.Column<int>(type: "integer", nullable: false), | |
| 329 | + ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | |
| 330 | + RespondedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true) | |
| 331 | + }, | |
| 332 | + constraints: table => | |
| 333 | + { | |
| 334 | + table.PrimaryKey("PK_TripInvitations", x => x.Id); | |
| 335 | + table.ForeignKey( | |
| 336 | + name: "FK_TripInvitations_AspNetUsers_InvitedByUserId", | |
| 337 | + column: x => x.InvitedByUserId, | |
| 338 | + principalTable: "AspNetUsers", | |
| 339 | + principalColumn: "Id", | |
| 340 | + onDelete: ReferentialAction.Restrict); | |
| 341 | + table.ForeignKey( | |
| 342 | + name: "FK_TripInvitations_Trips_TripId", | |
| 343 | + column: x => x.TripId, | |
| 344 | + principalTable: "Trips", | |
| 345 | + principalColumn: "Id", | |
| 346 | + onDelete: ReferentialAction.Restrict); | |
| 347 | + }); | |
| 348 | + | |
| 349 | + migrationBuilder.CreateTable( | |
| 350 | + name: "TripParticipants", | |
| 351 | + columns: table => new | |
| 352 | + { | |
| 353 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 354 | + TripId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 355 | + UserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 356 | + Role = table.Column<int>(type: "integer", nullable: false), | |
| 357 | + Nickname = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true), | |
| 358 | + JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | |
| 359 | + LeftAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), | |
| 360 | + IsActive = table.Column<bool>(type: "boolean", nullable: false) | |
| 361 | + }, | |
| 362 | + constraints: table => | |
| 363 | + { | |
| 364 | + table.PrimaryKey("PK_TripParticipants", x => x.Id); | |
| 365 | + table.ForeignKey( | |
| 366 | + name: "FK_TripParticipants_AspNetUsers_UserId", | |
| 367 | + column: x => x.UserId, | |
| 368 | + principalTable: "AspNetUsers", | |
| 369 | + principalColumn: "Id", | |
| 370 | + onDelete: ReferentialAction.Restrict); | |
| 371 | + table.ForeignKey( | |
| 372 | + name: "FK_TripParticipants_Trips_TripId", | |
| 373 | + column: x => x.TripId, | |
| 374 | + principalTable: "Trips", | |
| 375 | + principalColumn: "Id", | |
| 376 | + onDelete: ReferentialAction.Restrict); | |
| 377 | + }); | |
| 378 | + | |
| 379 | + migrationBuilder.CreateTable( | |
| 380 | + name: "TripPolls", | |
| 381 | + columns: table => new | |
| 382 | + { | |
| 383 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 384 | + TripId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 385 | + CreatedByUserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 386 | + Question = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false), | |
| 387 | + AllowMultipleVotes = table.Column<bool>(type: "boolean", nullable: false), | |
| 388 | + IsAnonymous = table.Column<bool>(type: "boolean", nullable: false), | |
| 389 | + ClosedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true) | |
| 390 | + }, | |
| 391 | + constraints: table => | |
| 392 | + { | |
| 393 | + table.PrimaryKey("PK_TripPolls", x => x.Id); | |
| 394 | + table.ForeignKey( | |
| 395 | + name: "FK_TripPolls_AspNetUsers_CreatedByUserId", | |
| 396 | + column: x => x.CreatedByUserId, | |
| 397 | + principalTable: "AspNetUsers", | |
| 398 | + principalColumn: "Id", | |
| 399 | + onDelete: ReferentialAction.Restrict); | |
| 400 | + table.ForeignKey( | |
| 401 | + name: "FK_TripPolls_Trips_TripId", | |
| 402 | + column: x => x.TripId, | |
| 403 | + principalTable: "Trips", | |
| 404 | + principalColumn: "Id", | |
| 405 | + onDelete: ReferentialAction.Restrict); | |
| 406 | + }); | |
| 407 | + | |
| 408 | + migrationBuilder.CreateTable( | |
| 409 | + name: "TripWishlistItems", | |
| 410 | + columns: table => new | |
| 411 | + { | |
| 412 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 413 | + TripId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 414 | + AddedByUserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 415 | + Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false), | |
| 416 | + Description = table.Column<string>(type: "text", nullable: true), | |
| 417 | + Category = table.Column<int>(type: "integer", nullable: false), | |
| 418 | + Priority = table.Column<int>(type: "integer", nullable: false), | |
| 419 | + EstimatedCost = table.Column<decimal>(type: "numeric", nullable: true), | |
| 420 | + Url = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true), | |
| 421 | + Location = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true), | |
| 422 | + IsCompleted = table.Column<bool>(type: "boolean", nullable: false), | |
| 423 | + CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), | |
| 424 | + DisplayOrder = table.Column<int>(type: "integer", nullable: false) | |
| 425 | + }, | |
| 426 | + constraints: table => | |
| 427 | + { | |
| 428 | + table.PrimaryKey("PK_TripWishlistItems", x => x.Id); | |
| 429 | + table.ForeignKey( | |
| 430 | + name: "FK_TripWishlistItems_AspNetUsers_AddedByUserId", | |
| 431 | + column: x => x.AddedByUserId, | |
| 432 | + principalTable: "AspNetUsers", | |
| 433 | + principalColumn: "Id", | |
| 434 | + onDelete: ReferentialAction.Restrict); | |
| 435 | + table.ForeignKey( | |
| 436 | + name: "FK_TripWishlistItems_Trips_TripId", | |
| 437 | + column: x => x.TripId, | |
| 438 | + principalTable: "Trips", | |
| 439 | + principalColumn: "Id", | |
| 440 | + onDelete: ReferentialAction.Restrict); | |
| 441 | + }); | |
| 442 | + | |
| 443 | + migrationBuilder.CreateTable( | |
| 444 | + name: "BudgetCategoryTranslations", | |
| 445 | + columns: table => new | |
| 446 | + { | |
| 447 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 448 | + BudgetCategoryId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 449 | + Culture = table.Column<string>(type: "character varying(5)", maxLength: 5, nullable: false), | |
| 450 | + Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false) | |
| 451 | + }, | |
| 452 | + constraints: table => | |
| 453 | + { | |
| 454 | + table.PrimaryKey("PK_BudgetCategoryTranslations", x => x.Id); | |
| 455 | + table.ForeignKey( | |
| 456 | + name: "FK_BudgetCategoryTranslations_BudgetCategories_BudgetCategoryId", | |
| 457 | + column: x => x.BudgetCategoryId, | |
| 458 | + principalTable: "BudgetCategories", | |
| 459 | + principalColumn: "Id", | |
| 460 | + onDelete: ReferentialAction.Restrict); | |
| 461 | + }); | |
| 462 | + | |
| 463 | + migrationBuilder.CreateTable( | |
| 464 | + name: "Expenses", | |
| 465 | + columns: table => new | |
| 466 | + { | |
| 467 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 468 | + TripId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 469 | + PaidByUserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 470 | + BudgetCategoryId = table.Column<Guid>(type: "uuid", nullable: true), | |
| 471 | + CurrencyId = table.Column<Guid>(type: "uuid", nullable: true), | |
| 472 | + Amount = table.Column<decimal>(type: "numeric", nullable: false), | |
| 473 | + Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true), | |
| 474 | + ExpenseDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | |
| 475 | + SplitMethod = table.Column<int>(type: "integer", nullable: false) | |
| 476 | + }, | |
| 477 | + constraints: table => | |
| 478 | + { | |
| 479 | + table.PrimaryKey("PK_Expenses", x => x.Id); | |
| 480 | + table.ForeignKey( | |
| 481 | + name: "FK_Expenses_AspNetUsers_PaidByUserId", | |
| 482 | + column: x => x.PaidByUserId, | |
| 483 | + principalTable: "AspNetUsers", | |
| 484 | + principalColumn: "Id", | |
| 485 | + onDelete: ReferentialAction.Restrict); | |
| 486 | + table.ForeignKey( | |
| 487 | + name: "FK_Expenses_BudgetCategories_BudgetCategoryId", | |
| 488 | + column: x => x.BudgetCategoryId, | |
| 489 | + principalTable: "BudgetCategories", | |
| 490 | + principalColumn: "Id", | |
| 491 | + onDelete: ReferentialAction.Restrict); | |
| 492 | + table.ForeignKey( | |
| 493 | + name: "FK_Expenses_Currencies_CurrencyId", | |
| 494 | + column: x => x.CurrencyId, | |
| 495 | + principalTable: "Currencies", | |
| 496 | + principalColumn: "Id", | |
| 497 | + onDelete: ReferentialAction.Restrict); | |
| 498 | + table.ForeignKey( | |
| 499 | + name: "FK_Expenses_Trips_TripId", | |
| 500 | + column: x => x.TripId, | |
| 501 | + principalTable: "Trips", | |
| 502 | + principalColumn: "Id", | |
| 503 | + onDelete: ReferentialAction.Restrict); | |
| 504 | + }); | |
| 505 | + | |
| 506 | + migrationBuilder.CreateTable( | |
| 507 | + name: "SettlementPayments", | |
| 508 | + columns: table => new | |
| 509 | + { | |
| 510 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 511 | + SettlementPlanId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 512 | + FromUserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 513 | + ToUserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 514 | + Amount = table.Column<decimal>(type: "numeric", nullable: false), | |
| 515 | + Status = table.Column<int>(type: "integer", nullable: false), | |
| 516 | + MarkedPaidAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), | |
| 517 | + ConfirmedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true) | |
| 518 | + }, | |
| 519 | + constraints: table => | |
| 520 | + { | |
| 521 | + table.PrimaryKey("PK_SettlementPayments", x => x.Id); | |
| 522 | + table.ForeignKey( | |
| 523 | + name: "FK_SettlementPayments_AspNetUsers_FromUserId", | |
| 524 | + column: x => x.FromUserId, | |
| 525 | + principalTable: "AspNetUsers", | |
| 526 | + principalColumn: "Id", | |
| 527 | + onDelete: ReferentialAction.Restrict); | |
| 528 | + table.ForeignKey( | |
| 529 | + name: "FK_SettlementPayments_AspNetUsers_ToUserId", | |
| 530 | + column: x => x.ToUserId, | |
| 531 | + principalTable: "AspNetUsers", | |
| 532 | + principalColumn: "Id", | |
| 533 | + onDelete: ReferentialAction.Restrict); | |
| 534 | + table.ForeignKey( | |
| 535 | + name: "FK_SettlementPayments_SettlementPlans_SettlementPlanId", | |
| 536 | + column: x => x.SettlementPlanId, | |
| 537 | + principalTable: "SettlementPlans", | |
| 538 | + principalColumn: "Id", | |
| 539 | + onDelete: ReferentialAction.Restrict); | |
| 540 | + }); | |
| 541 | + | |
| 542 | + migrationBuilder.CreateTable( | |
| 543 | + name: "SplitPresetMembers", | |
| 544 | + columns: table => new | |
| 545 | + { | |
| 546 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 547 | + SplitPresetId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 548 | + UserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 549 | + ShareWeight = table.Column<decimal>(type: "numeric", nullable: true), | |
| 550 | + Percentage = table.Column<decimal>(type: "numeric", nullable: true) | |
| 551 | + }, | |
| 552 | + constraints: table => | |
| 553 | + { | |
| 554 | + table.PrimaryKey("PK_SplitPresetMembers", x => x.Id); | |
| 555 | + table.ForeignKey( | |
| 556 | + name: "FK_SplitPresetMembers_AspNetUsers_UserId", | |
| 557 | + column: x => x.UserId, | |
| 558 | + principalTable: "AspNetUsers", | |
| 559 | + principalColumn: "Id", | |
| 560 | + onDelete: ReferentialAction.Restrict); | |
| 561 | + table.ForeignKey( | |
| 562 | + name: "FK_SplitPresetMembers_SplitPresets_SplitPresetId", | |
| 563 | + column: x => x.SplitPresetId, | |
| 564 | + principalTable: "SplitPresets", | |
| 565 | + principalColumn: "Id", | |
| 566 | + onDelete: ReferentialAction.Restrict); | |
| 567 | + }); | |
| 568 | + | |
| 569 | + migrationBuilder.CreateTable( | |
| 570 | + name: "TripPollOptions", | |
| 571 | + columns: table => new | |
| 572 | + { | |
| 573 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 574 | + PollId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 575 | + Text = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false), | |
| 576 | + DisplayOrder = table.Column<int>(type: "integer", nullable: false) | |
| 577 | + }, | |
| 578 | + constraints: table => | |
| 579 | + { | |
| 580 | + table.PrimaryKey("PK_TripPollOptions", x => x.Id); | |
| 581 | + table.ForeignKey( | |
| 582 | + name: "FK_TripPollOptions_TripPolls_PollId", | |
| 583 | + column: x => x.PollId, | |
| 584 | + principalTable: "TripPolls", | |
| 585 | + principalColumn: "Id", | |
| 586 | + onDelete: ReferentialAction.Restrict); | |
| 587 | + }); | |
| 588 | + | |
| 589 | + migrationBuilder.CreateTable( | |
| 590 | + name: "TripWishlistVotes", | |
| 591 | + columns: table => new | |
| 592 | + { | |
| 593 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 594 | + WishlistItemId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 595 | + UserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 596 | + IsInterested = table.Column<bool>(type: "boolean", nullable: false) | |
| 597 | + }, | |
| 598 | + constraints: table => | |
| 599 | + { | |
| 600 | + table.PrimaryKey("PK_TripWishlistVotes", x => x.Id); | |
| 601 | + table.ForeignKey( | |
| 602 | + name: "FK_TripWishlistVotes_AspNetUsers_UserId", | |
| 603 | + column: x => x.UserId, | |
| 604 | + principalTable: "AspNetUsers", | |
| 605 | + principalColumn: "Id", | |
| 606 | + onDelete: ReferentialAction.Restrict); | |
| 607 | + table.ForeignKey( | |
| 608 | + name: "FK_TripWishlistVotes_TripWishlistItems_WishlistItemId", | |
| 609 | + column: x => x.WishlistItemId, | |
| 610 | + principalTable: "TripWishlistItems", | |
| 611 | + principalColumn: "Id", | |
| 612 | + onDelete: ReferentialAction.Restrict); | |
| 613 | + }); | |
| 614 | + | |
| 615 | + migrationBuilder.CreateTable( | |
| 616 | + name: "ExpenseSplits", | |
| 617 | + columns: table => new | |
| 618 | + { | |
| 619 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 620 | + ExpenseId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 621 | + UserId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 622 | + Amount = table.Column<decimal>(type: "numeric", nullable: false), | |
| 623 | + Percentage = table.Column<decimal>(type: "numeric", nullable: true) | |
| 624 | + }, | |
| 625 | + constraints: table => | |
| 626 | + { | |
| 627 | + table.PrimaryKey("PK_ExpenseSplits", x => x.Id); | |
| 628 | + table.ForeignKey( | |
| 629 | + name: "FK_ExpenseSplits_AspNetUsers_UserId", | |
| 630 | + column: x => x.UserId, | |
| 631 | + principalTable: "AspNetUsers", | |
| 632 | + principalColumn: "Id", | |
| 633 | + onDelete: ReferentialAction.Restrict); | |
| 634 | + table.ForeignKey( | |
| 635 | + name: "FK_ExpenseSplits_Expenses_ExpenseId", | |
| 636 | + column: x => x.ExpenseId, | |
| 637 | + principalTable: "Expenses", | |
| 638 | + principalColumn: "Id", | |
| 639 | + onDelete: ReferentialAction.Restrict); | |
| 640 | + }); | |
| 641 | + | |
| 642 | + migrationBuilder.CreateTable( | |
| 643 | + name: "TripPollVotes", | |
| 644 | + columns: table => new | |
| 645 | + { | |
| 646 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 647 | + PollOptionId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 648 | + UserId = table.Column<Guid>(type: "uuid", nullable: false) | |
| 649 | + }, | |
| 650 | + constraints: table => | |
| 651 | + { | |
| 652 | + table.PrimaryKey("PK_TripPollVotes", x => x.Id); | |
| 653 | + table.ForeignKey( | |
| 654 | + name: "FK_TripPollVotes_AspNetUsers_UserId", | |
| 655 | + column: x => x.UserId, | |
| 656 | + principalTable: "AspNetUsers", | |
| 657 | + principalColumn: "Id", | |
| 658 | + onDelete: ReferentialAction.Restrict); | |
| 659 | + table.ForeignKey( | |
| 660 | + name: "FK_TripPollVotes_TripPollOptions_PollOptionId", | |
| 661 | + column: x => x.PollOptionId, | |
| 662 | + principalTable: "TripPollOptions", | |
| 663 | + principalColumn: "Id", | |
| 664 | + onDelete: ReferentialAction.Restrict); | |
| 665 | + }); | |
| 666 | + | |
| 667 | + migrationBuilder.CreateIndex( | |
| 668 | + name: "IX_AspNetRoleClaims_RoleId", | |
| 669 | + table: "AspNetRoleClaims", | |
| 670 | + column: "RoleId"); | |
| 671 | + | |
| 672 | + migrationBuilder.CreateIndex( | |
| 673 | + name: "RoleNameIndex", | |
| 674 | + table: "AspNetRoles", | |
| 675 | + column: "NormalizedName", | |
| 676 | + unique: true); | |
| 677 | + | |
| 678 | + migrationBuilder.CreateIndex( | |
| 679 | + name: "IX_AspNetUserClaims_UserId", | |
| 680 | + table: "AspNetUserClaims", | |
| 681 | + column: "UserId"); | |
| 682 | + | |
| 683 | + migrationBuilder.CreateIndex( | |
| 684 | + name: "IX_AspNetUserLogins_UserId", | |
| 685 | + table: "AspNetUserLogins", | |
| 686 | + column: "UserId"); | |
| 687 | + | |
| 688 | + migrationBuilder.CreateIndex( | |
| 689 | + name: "IX_AspNetUserRoles_RoleId", | |
| 690 | + table: "AspNetUserRoles", | |
| 691 | + column: "RoleId"); | |
| 692 | + | |
| 693 | + migrationBuilder.CreateIndex( | |
| 694 | + name: "EmailIndex", | |
| 695 | + table: "AspNetUsers", | |
| 696 | + column: "NormalizedEmail"); | |
| 697 | + | |
| 698 | + migrationBuilder.CreateIndex( | |
| 699 | + name: "UserNameIndex", | |
| 700 | + table: "AspNetUsers", | |
| 701 | + column: "NormalizedUserName", | |
| 702 | + unique: true); | |
| 703 | + | |
| 704 | + migrationBuilder.CreateIndex( | |
| 705 | + name: "IX_BudgetCategories_TripId", | |
| 706 | + table: "BudgetCategories", | |
| 707 | + column: "TripId"); | |
| 708 | + | |
| 709 | + migrationBuilder.CreateIndex( | |
| 710 | + name: "IX_BudgetCategoryTranslations_BudgetCategoryId", | |
| 711 | + table: "BudgetCategoryTranslations", | |
| 712 | + column: "BudgetCategoryId"); | |
| 713 | + | |
| 714 | + migrationBuilder.CreateIndex( | |
| 715 | + name: "IX_Expenses_BudgetCategoryId", | |
| 716 | + table: "Expenses", | |
| 717 | + column: "BudgetCategoryId"); | |
| 718 | + | |
| 719 | + migrationBuilder.CreateIndex( | |
| 720 | + name: "IX_Expenses_CurrencyId", | |
| 721 | + table: "Expenses", | |
| 722 | + column: "CurrencyId"); | |
| 723 | + | |
| 724 | + migrationBuilder.CreateIndex( | |
| 725 | + name: "IX_Expenses_PaidByUserId", | |
| 726 | + table: "Expenses", | |
| 727 | + column: "PaidByUserId"); | |
| 728 | + | |
| 729 | + migrationBuilder.CreateIndex( | |
| 730 | + name: "IX_Expenses_TripId", | |
| 731 | + table: "Expenses", | |
| 732 | + column: "TripId"); | |
| 733 | + | |
| 734 | + migrationBuilder.CreateIndex( | |
| 735 | + name: "IX_ExpenseSplits_ExpenseId", | |
| 736 | + table: "ExpenseSplits", | |
| 737 | + column: "ExpenseId"); | |
| 738 | + | |
| 739 | + migrationBuilder.CreateIndex( | |
| 740 | + name: "IX_ExpenseSplits_UserId", | |
| 741 | + table: "ExpenseSplits", | |
| 742 | + column: "UserId"); | |
| 743 | + | |
| 744 | + migrationBuilder.CreateIndex( | |
| 745 | + name: "IX_RefreshTokens_AppUserId", | |
| 746 | + table: "RefreshTokens", | |
| 747 | + column: "AppUserId"); | |
| 748 | + | |
| 749 | + migrationBuilder.CreateIndex( | |
| 750 | + name: "IX_SettlementPayments_FromUserId", | |
| 751 | + table: "SettlementPayments", | |
| 752 | + column: "FromUserId"); | |
| 753 | + | |
| 754 | + migrationBuilder.CreateIndex( | |
| 755 | + name: "IX_SettlementPayments_SettlementPlanId", | |
| 756 | + table: "SettlementPayments", | |
| 757 | + column: "SettlementPlanId"); | |
| 758 | + | |
| 759 | + migrationBuilder.CreateIndex( | |
| 760 | + name: "IX_SettlementPayments_ToUserId", | |
| 761 | + table: "SettlementPayments", | |
| 762 | + column: "ToUserId"); | |
| 763 | + | |
| 764 | + migrationBuilder.CreateIndex( | |
| 765 | + name: "IX_SettlementPlans_CreatedByUserId", | |
| 766 | + table: "SettlementPlans", | |
| 767 | + column: "CreatedByUserId"); | |
| 768 | + | |
| 769 | + migrationBuilder.CreateIndex( | |
| 770 | + name: "IX_SettlementPlans_TripId", | |
| 771 | + table: "SettlementPlans", | |
| 772 | + column: "TripId"); | |
| 773 | + | |
| 774 | + migrationBuilder.CreateIndex( | |
| 775 | + name: "IX_SplitPresetMembers_SplitPresetId", | |
| 776 | + table: "SplitPresetMembers", | |
| 777 | + column: "SplitPresetId"); | |
| 778 | + | |
| 779 | + migrationBuilder.CreateIndex( | |
| 780 | + name: "IX_SplitPresetMembers_UserId", | |
| 781 | + table: "SplitPresetMembers", | |
| 782 | + column: "UserId"); | |
| 783 | + | |
| 784 | + migrationBuilder.CreateIndex( | |
| 785 | + name: "IX_SplitPresets_CreatedById", | |
| 786 | + table: "SplitPresets", | |
| 787 | + column: "CreatedById"); | |
| 788 | + | |
| 789 | + migrationBuilder.CreateIndex( | |
| 790 | + name: "IX_SplitPresets_TripId", | |
| 791 | + table: "SplitPresets", | |
| 792 | + column: "TripId"); | |
| 793 | + | |
| 794 | + migrationBuilder.CreateIndex( | |
| 795 | + name: "IX_TripInvitations_InvitedByUserId", | |
| 796 | + table: "TripInvitations", | |
| 797 | + column: "InvitedByUserId"); | |
| 798 | + | |
| 799 | + migrationBuilder.CreateIndex( | |
| 800 | + name: "IX_TripInvitations_Token", | |
| 801 | + table: "TripInvitations", | |
| 802 | + column: "Token", | |
| 803 | + unique: true); | |
| 804 | + | |
| 805 | + migrationBuilder.CreateIndex( | |
| 806 | + name: "IX_TripInvitations_TripId", | |
| 807 | + table: "TripInvitations", | |
| 808 | + column: "TripId"); | |
| 809 | + | |
| 810 | + migrationBuilder.CreateIndex( | |
| 811 | + name: "IX_TripParticipants_TripId_UserId", | |
| 812 | + table: "TripParticipants", | |
| 813 | + columns: new[] { "TripId", "UserId" }, | |
| 814 | + unique: true); | |
| 815 | + | |
| 816 | + migrationBuilder.CreateIndex( | |
| 817 | + name: "IX_TripParticipants_UserId", | |
| 818 | + table: "TripParticipants", | |
| 819 | + column: "UserId"); | |
| 820 | + | |
| 821 | + migrationBuilder.CreateIndex( | |
| 822 | + name: "IX_TripPollOptions_PollId", | |
| 823 | + table: "TripPollOptions", | |
| 824 | + column: "PollId"); | |
| 825 | + | |
| 826 | + migrationBuilder.CreateIndex( | |
| 827 | + name: "IX_TripPolls_CreatedByUserId", | |
| 828 | + table: "TripPolls", | |
| 829 | + column: "CreatedByUserId"); | |
| 830 | + | |
| 831 | + migrationBuilder.CreateIndex( | |
| 832 | + name: "IX_TripPolls_TripId", | |
| 833 | + table: "TripPolls", | |
| 834 | + column: "TripId"); | |
| 835 | + | |
| 836 | + migrationBuilder.CreateIndex( | |
| 837 | + name: "IX_TripPollVotes_PollOptionId_UserId", | |
| 838 | + table: "TripPollVotes", | |
| 839 | + columns: new[] { "PollOptionId", "UserId" }, | |
| 840 | + unique: true); | |
| 841 | + | |
| 842 | + migrationBuilder.CreateIndex( | |
| 843 | + name: "IX_TripPollVotes_UserId", | |
| 844 | + table: "TripPollVotes", | |
| 845 | + column: "UserId"); | |
| 846 | + | |
| 847 | + migrationBuilder.CreateIndex( | |
| 848 | + name: "IX_Trips_CreatedById", | |
| 849 | + table: "Trips", | |
| 850 | + column: "CreatedById"); | |
| 851 | + | |
| 852 | + migrationBuilder.CreateIndex( | |
| 853 | + name: "IX_Trips_DefaultCurrencyId", | |
| 854 | + table: "Trips", | |
| 855 | + column: "DefaultCurrencyId"); | |
| 856 | + | |
| 857 | + migrationBuilder.CreateIndex( | |
| 858 | + name: "IX_TripWishlistItems_AddedByUserId", | |
| 859 | + table: "TripWishlistItems", | |
| 860 | + column: "AddedByUserId"); | |
| 861 | + | |
| 862 | + migrationBuilder.CreateIndex( | |
| 863 | + name: "IX_TripWishlistItems_TripId", | |
| 864 | + table: "TripWishlistItems", | |
| 865 | + column: "TripId"); | |
| 866 | + | |
| 867 | + migrationBuilder.CreateIndex( | |
| 868 | + name: "IX_TripWishlistVotes_UserId", | |
| 869 | + table: "TripWishlistVotes", | |
| 870 | + column: "UserId"); | |
| 871 | + | |
| 872 | + migrationBuilder.CreateIndex( | |
| 873 | + name: "IX_TripWishlistVotes_WishlistItemId_UserId", | |
| 874 | + table: "TripWishlistVotes", | |
| 875 | + columns: new[] { "WishlistItemId", "UserId" }, | |
| 876 | + unique: true); | |
| 877 | + } | |
| 878 | + | |
| 879 | + /// <inheritdoc /> | |
| 880 | + protected override void Down(MigrationBuilder migrationBuilder) | |
| 881 | + { | |
| 882 | + migrationBuilder.DropTable( | |
| 883 | + name: "AspNetRoleClaims"); | |
| 884 | + | |
| 885 | + migrationBuilder.DropTable( | |
| 886 | + name: "AspNetUserClaims"); | |
| 887 | + | |
| 888 | + migrationBuilder.DropTable( | |
| 889 | + name: "AspNetUserLogins"); | |
| 890 | + | |
| 891 | + migrationBuilder.DropTable( | |
| 892 | + name: "AspNetUserRoles"); | |
| 893 | + | |
| 894 | + migrationBuilder.DropTable( | |
| 895 | + name: "AspNetUserTokens"); | |
| 896 | + | |
| 897 | + migrationBuilder.DropTable( | |
| 898 | + name: "BudgetCategoryTranslations"); | |
| 899 | + | |
| 900 | + migrationBuilder.DropTable( | |
| 901 | + name: "DataProtectionKeys"); | |
| 902 | + | |
| 903 | + migrationBuilder.DropTable( | |
| 904 | + name: "ExpenseSplits"); | |
| 905 | + | |
| 906 | + migrationBuilder.DropTable( | |
| 907 | + name: "RefreshTokens"); | |
| 908 | + | |
| 909 | + migrationBuilder.DropTable( | |
| 910 | + name: "SettlementPayments"); | |
| 911 | + | |
| 912 | + migrationBuilder.DropTable( | |
| 913 | + name: "SplitPresetMembers"); | |
| 914 | + | |
| 915 | + migrationBuilder.DropTable( | |
| 916 | + name: "TripInvitations"); | |
| 917 | + | |
| 918 | + migrationBuilder.DropTable( | |
| 919 | + name: "TripParticipants"); | |
| 920 | + | |
| 921 | + migrationBuilder.DropTable( | |
| 922 | + name: "TripPollVotes"); | |
| 923 | + | |
| 924 | + migrationBuilder.DropTable( | |
| 925 | + name: "TripWishlistVotes"); | |
| 926 | + | |
| 927 | + migrationBuilder.DropTable( | |
| 928 | + name: "AspNetRoles"); | |
| 929 | + | |
| 930 | + migrationBuilder.DropTable( | |
| 931 | + name: "Expenses"); | |
| 932 | + | |
| 933 | + migrationBuilder.DropTable( | |
| 934 | + name: "SettlementPlans"); | |
| 935 | + | |
| 936 | + migrationBuilder.DropTable( | |
| 937 | + name: "SplitPresets"); | |
| 938 | + | |
| 939 | + migrationBuilder.DropTable( | |
| 940 | + name: "TripPollOptions"); | |
| 941 | + | |
| 942 | + migrationBuilder.DropTable( | |
| 943 | + name: "TripWishlistItems"); | |
| 944 | + | |
| 945 | + migrationBuilder.DropTable( | |
| 946 | + name: "BudgetCategories"); | |
| 947 | + | |
| 948 | + migrationBuilder.DropTable( | |
| 949 | + name: "TripPolls"); | |
| 950 | + | |
| 951 | + migrationBuilder.DropTable( | |
| 952 | + name: "Trips"); | |
| 953 | + | |
| 954 | + migrationBuilder.DropTable( | |
| 955 | + name: "AspNetUsers"); | |
| 956 | + | |
| 957 | + migrationBuilder.DropTable( | |
| 958 | + name: "Currencies"); | |
| 959 | + } | |
| 960 | + } | |
| 961 | +} |
added SplitApp/App.DAL.EF/Migrations/20260328161224_AddBaseEntityTimestamps.Designer.cs +1375 −0
| @@ -0,0 +1,1375 @@ | ||
| 1 | +// <auto-generated /> | |
| 2 | +using System; | |
| 3 | +using App.DAL.EF; | |
| 4 | +using Microsoft.EntityFrameworkCore; | |
| 5 | +using Microsoft.EntityFrameworkCore.Infrastructure; | |
| 6 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 7 | +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | |
| 8 | +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | |
| 9 | + | |
| 10 | +#nullable disable | |
| 11 | + | |
| 12 | +namespace App.DAL.EF.Migrations | |
| 13 | +{ | |
| 14 | + [DbContext(typeof(AppDbContext))] | |
| 15 | + [Migration("20260328161224_AddBaseEntityTimestamps")] | |
| 16 | + partial class AddBaseEntityTimestamps | |
| 17 | + { | |
| 18 | + /// <inheritdoc /> | |
| 19 | + protected override void BuildTargetModel(ModelBuilder modelBuilder) | |
| 20 | + { | |
| 21 | +#pragma warning disable 612, 618 | |
| 22 | + modelBuilder | |
| 23 | + .HasAnnotation("ProductVersion", "10.0.5") | |
| 24 | + .HasAnnotation("Relational:MaxIdentifierLength", 63); | |
| 25 | + | |
| 26 | + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | |
| 27 | + | |
| 28 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 29 | + { | |
| 30 | + b.Property<Guid>("Id") | |
| 31 | + .ValueGeneratedOnAdd() | |
| 32 | + .HasColumnType("uuid"); | |
| 33 | + | |
| 34 | + b.Property<DateTime>("CreatedAt") | |
| 35 | + .HasColumnType("timestamp with time zone"); | |
| 36 | + | |
| 37 | + b.Property<int>("DisplayOrder") | |
| 38 | + .HasColumnType("integer"); | |
| 39 | + | |
| 40 | + b.Property<string>("IconName") | |
| 41 | + .HasMaxLength(100) | |
| 42 | + .HasColumnType("character varying(100)"); | |
| 43 | + | |
| 44 | + b.Property<string>("Name") | |
| 45 | + .IsRequired() | |
| 46 | + .HasMaxLength(100) | |
| 47 | + .HasColumnType("character varying(100)"); | |
| 48 | + | |
| 49 | + b.Property<decimal?>("PlannedAmount") | |
| 50 | + .HasColumnType("numeric"); | |
| 51 | + | |
| 52 | + b.Property<Guid>("TripId") | |
| 53 | + .HasColumnType("uuid"); | |
| 54 | + | |
| 55 | + b.Property<DateTime>("UpdatedAt") | |
| 56 | + .HasColumnType("timestamp with time zone"); | |
| 57 | + | |
| 58 | + b.HasKey("Id"); | |
| 59 | + | |
| 60 | + b.HasIndex("TripId"); | |
| 61 | + | |
| 62 | + b.ToTable("BudgetCategories"); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + modelBuilder.Entity("App.Domain.BudgetCategoryTranslation", b => | |
| 66 | + { | |
| 67 | + b.Property<Guid>("Id") | |
| 68 | + .ValueGeneratedOnAdd() | |
| 69 | + .HasColumnType("uuid"); | |
| 70 | + | |
| 71 | + b.Property<Guid>("BudgetCategoryId") | |
| 72 | + .HasColumnType("uuid"); | |
| 73 | + | |
| 74 | + b.Property<DateTime>("CreatedAt") | |
| 75 | + .HasColumnType("timestamp with time zone"); | |
| 76 | + | |
| 77 | + b.Property<string>("Culture") | |
| 78 | + .IsRequired() | |
| 79 | + .HasMaxLength(5) | |
| 80 | + .HasColumnType("character varying(5)"); | |
| 81 | + | |
| 82 | + b.Property<string>("Name") | |
| 83 | + .IsRequired() | |
| 84 | + .HasMaxLength(100) | |
| 85 | + .HasColumnType("character varying(100)"); | |
| 86 | + | |
| 87 | + b.Property<DateTime>("UpdatedAt") | |
| 88 | + .HasColumnType("timestamp with time zone"); | |
| 89 | + | |
| 90 | + b.HasKey("Id"); | |
| 91 | + | |
| 92 | + b.HasIndex("BudgetCategoryId"); | |
| 93 | + | |
| 94 | + b.ToTable("BudgetCategoryTranslations"); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + modelBuilder.Entity("App.Domain.Currency", b => | |
| 98 | + { | |
| 99 | + b.Property<Guid>("Id") | |
| 100 | + .ValueGeneratedOnAdd() | |
| 101 | + .HasColumnType("uuid"); | |
| 102 | + | |
| 103 | + b.Property<string>("Code") | |
| 104 | + .IsRequired() | |
| 105 | + .HasMaxLength(3) | |
| 106 | + .HasColumnType("character varying(3)"); | |
| 107 | + | |
| 108 | + b.Property<DateTime>("CreatedAt") | |
| 109 | + .HasColumnType("timestamp with time zone"); | |
| 110 | + | |
| 111 | + b.Property<string>("Name") | |
| 112 | + .IsRequired() | |
| 113 | + .HasMaxLength(100) | |
| 114 | + .HasColumnType("character varying(100)"); | |
| 115 | + | |
| 116 | + b.Property<string>("Symbol") | |
| 117 | + .IsRequired() | |
| 118 | + .HasMaxLength(10) | |
| 119 | + .HasColumnType("character varying(10)"); | |
| 120 | + | |
| 121 | + b.Property<DateTime>("UpdatedAt") | |
| 122 | + .HasColumnType("timestamp with time zone"); | |
| 123 | + | |
| 124 | + b.HasKey("Id"); | |
| 125 | + | |
| 126 | + b.ToTable("Currencies"); | |
| 127 | + }); | |
| 128 | + | |
| 129 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 130 | + { | |
| 131 | + b.Property<Guid>("Id") | |
| 132 | + .ValueGeneratedOnAdd() | |
| 133 | + .HasColumnType("uuid"); | |
| 134 | + | |
| 135 | + b.Property<decimal>("Amount") | |
| 136 | + .HasColumnType("numeric"); | |
| 137 | + | |
| 138 | + b.Property<Guid?>("BudgetCategoryId") | |
| 139 | + .HasColumnType("uuid"); | |
| 140 | + | |
| 141 | + b.Property<DateTime>("CreatedAt") | |
| 142 | + .HasColumnType("timestamp with time zone"); | |
| 143 | + | |
| 144 | + b.Property<Guid?>("CurrencyId") | |
| 145 | + .HasColumnType("uuid"); | |
| 146 | + | |
| 147 | + b.Property<string>("Description") | |
| 148 | + .HasMaxLength(500) | |
| 149 | + .HasColumnType("character varying(500)"); | |
| 150 | + | |
| 151 | + b.Property<DateTime>("ExpenseDate") | |
| 152 | + .HasColumnType("timestamp with time zone"); | |
| 153 | + | |
| 154 | + b.Property<Guid>("PaidByUserId") | |
| 155 | + .HasColumnType("uuid"); | |
| 156 | + | |
| 157 | + b.Property<int>("SplitMethod") | |
| 158 | + .HasColumnType("integer"); | |
| 159 | + | |
| 160 | + b.Property<Guid>("TripId") | |
| 161 | + .HasColumnType("uuid"); | |
| 162 | + | |
| 163 | + b.Property<DateTime>("UpdatedAt") | |
| 164 | + .HasColumnType("timestamp with time zone"); | |
| 165 | + | |
| 166 | + b.HasKey("Id"); | |
| 167 | + | |
| 168 | + b.HasIndex("BudgetCategoryId"); | |
| 169 | + | |
| 170 | + b.HasIndex("CurrencyId"); | |
| 171 | + | |
| 172 | + b.HasIndex("PaidByUserId"); | |
| 173 | + | |
| 174 | + b.HasIndex("TripId"); | |
| 175 | + | |
| 176 | + b.ToTable("Expenses"); | |
| 177 | + }); | |
| 178 | + | |
| 179 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 180 | + { | |
| 181 | + b.Property<Guid>("Id") | |
| 182 | + .ValueGeneratedOnAdd() | |
| 183 | + .HasColumnType("uuid"); | |
| 184 | + | |
| 185 | + b.Property<decimal>("Amount") | |
| 186 | + .HasColumnType("numeric"); | |
| 187 | + | |
| 188 | + b.Property<DateTime>("CreatedAt") | |
| 189 | + .HasColumnType("timestamp with time zone"); | |
| 190 | + | |
| 191 | + b.Property<Guid>("ExpenseId") | |
| 192 | + .HasColumnType("uuid"); | |
| 193 | + | |
| 194 | + b.Property<decimal?>("Percentage") | |
| 195 | + .HasColumnType("numeric"); | |
| 196 | + | |
| 197 | + b.Property<DateTime>("UpdatedAt") | |
| 198 | + .HasColumnType("timestamp with time zone"); | |
| 199 | + | |
| 200 | + b.Property<Guid>("UserId") | |
| 201 | + .HasColumnType("uuid"); | |
| 202 | + | |
| 203 | + b.HasKey("Id"); | |
| 204 | + | |
| 205 | + b.HasIndex("ExpenseId"); | |
| 206 | + | |
| 207 | + b.HasIndex("UserId"); | |
| 208 | + | |
| 209 | + b.ToTable("ExpenseSplits"); | |
| 210 | + }); | |
| 211 | + | |
| 212 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 213 | + { | |
| 214 | + b.Property<Guid>("Id") | |
| 215 | + .ValueGeneratedOnAdd() | |
| 216 | + .HasColumnType("uuid"); | |
| 217 | + | |
| 218 | + b.Property<Guid>("AppUserId") | |
| 219 | + .HasColumnType("uuid"); | |
| 220 | + | |
| 221 | + b.Property<DateTime>("CreatedAt") | |
| 222 | + .HasColumnType("timestamp with time zone"); | |
| 223 | + | |
| 224 | + b.Property<DateTime>("ExpirationDT") | |
| 225 | + .HasColumnType("timestamp with time zone"); | |
| 226 | + | |
| 227 | + b.Property<DateTime>("PreviousExpirationDT") | |
| 228 | + .HasColumnType("timestamp with time zone"); | |
| 229 | + | |
| 230 | + b.Property<string>("PreviousRefreshToken") | |
| 231 | + .HasMaxLength(64) | |
| 232 | + .HasColumnType("character varying(64)"); | |
| 233 | + | |
| 234 | + b.Property<string>("RefreshToken") | |
| 235 | + .IsRequired() | |
| 236 | + .HasMaxLength(64) | |
| 237 | + .HasColumnType("character varying(64)"); | |
| 238 | + | |
| 239 | + b.Property<DateTime>("UpdatedAt") | |
| 240 | + .HasColumnType("timestamp with time zone"); | |
| 241 | + | |
| 242 | + b.HasKey("Id"); | |
| 243 | + | |
| 244 | + b.HasIndex("AppUserId"); | |
| 245 | + | |
| 246 | + b.ToTable("RefreshTokens"); | |
| 247 | + }); | |
| 248 | + | |
| 249 | + modelBuilder.Entity("App.Domain.Identity.AppRole", b => | |
| 250 | + { | |
| 251 | + b.Property<Guid>("Id") | |
| 252 | + .ValueGeneratedOnAdd() | |
| 253 | + .HasColumnType("uuid"); | |
| 254 | + | |
| 255 | + b.Property<string>("ConcurrencyStamp") | |
| 256 | + .IsConcurrencyToken() | |
| 257 | + .HasColumnType("text"); | |
| 258 | + | |
| 259 | + b.Property<string>("Name") | |
| 260 | + .HasMaxLength(256) | |
| 261 | + .HasColumnType("character varying(256)"); | |
| 262 | + | |
| 263 | + b.Property<string>("NormalizedName") | |
| 264 | + .HasMaxLength(256) | |
| 265 | + .HasColumnType("character varying(256)"); | |
| 266 | + | |
| 267 | + b.HasKey("Id"); | |
| 268 | + | |
| 269 | + b.HasIndex("NormalizedName") | |
| 270 | + .IsUnique() | |
| 271 | + .HasDatabaseName("RoleNameIndex"); | |
| 272 | + | |
| 273 | + b.ToTable("AspNetRoles", (string)null); | |
| 274 | + }); | |
| 275 | + | |
| 276 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 277 | + { | |
| 278 | + b.Property<Guid>("Id") | |
| 279 | + .ValueGeneratedOnAdd() | |
| 280 | + .HasColumnType("uuid"); | |
| 281 | + | |
| 282 | + b.Property<int>("AccessFailedCount") | |
| 283 | + .HasColumnType("integer"); | |
| 284 | + | |
| 285 | + b.Property<string>("ConcurrencyStamp") | |
| 286 | + .IsConcurrencyToken() | |
| 287 | + .HasColumnType("text"); | |
| 288 | + | |
| 289 | + b.Property<string>("Email") | |
| 290 | + .HasMaxLength(256) | |
| 291 | + .HasColumnType("character varying(256)"); | |
| 292 | + | |
| 293 | + b.Property<bool>("EmailConfirmed") | |
| 294 | + .HasColumnType("boolean"); | |
| 295 | + | |
| 296 | + b.Property<string>("FirstName") | |
| 297 | + .IsRequired() | |
| 298 | + .HasMaxLength(128) | |
| 299 | + .HasColumnType("character varying(128)"); | |
| 300 | + | |
| 301 | + b.Property<string>("LastName") | |
| 302 | + .IsRequired() | |
| 303 | + .HasMaxLength(128) | |
| 304 | + .HasColumnType("character varying(128)"); | |
| 305 | + | |
| 306 | + b.Property<bool>("LockoutEnabled") | |
| 307 | + .HasColumnType("boolean"); | |
| 308 | + | |
| 309 | + b.Property<DateTimeOffset?>("LockoutEnd") | |
| 310 | + .HasColumnType("timestamp with time zone"); | |
| 311 | + | |
| 312 | + b.Property<string>("NormalizedEmail") | |
| 313 | + .HasMaxLength(256) | |
| 314 | + .HasColumnType("character varying(256)"); | |
| 315 | + | |
| 316 | + b.Property<string>("NormalizedUserName") | |
| 317 | + .HasMaxLength(256) | |
| 318 | + .HasColumnType("character varying(256)"); | |
| 319 | + | |
| 320 | + b.Property<string>("PasswordHash") | |
| 321 | + .HasColumnType("text"); | |
| 322 | + | |
| 323 | + b.Property<string>("PhoneNumber") | |
| 324 | + .HasColumnType("text"); | |
| 325 | + | |
| 326 | + b.Property<bool>("PhoneNumberConfirmed") | |
| 327 | + .HasColumnType("boolean"); | |
| 328 | + | |
| 329 | + b.Property<string>("SecurityStamp") | |
| 330 | + .HasColumnType("text"); | |
| 331 | + | |
| 332 | + b.Property<bool>("TwoFactorEnabled") | |
| 333 | + .HasColumnType("boolean"); | |
| 334 | + | |
| 335 | + b.Property<string>("UserName") | |
| 336 | + .HasMaxLength(256) | |
| 337 | + .HasColumnType("character varying(256)"); | |
| 338 | + | |
| 339 | + b.HasKey("Id"); | |
| 340 | + | |
| 341 | + b.HasIndex("NormalizedEmail") | |
| 342 | + .HasDatabaseName("EmailIndex"); | |
| 343 | + | |
| 344 | + b.HasIndex("NormalizedUserName") | |
| 345 | + .IsUnique() | |
| 346 | + .HasDatabaseName("UserNameIndex"); | |
| 347 | + | |
| 348 | + b.ToTable("AspNetUsers", (string)null); | |
| 349 | + }); | |
| 350 | + | |
| 351 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 352 | + { | |
| 353 | + b.Property<Guid>("Id") | |
| 354 | + .ValueGeneratedOnAdd() | |
| 355 | + .HasColumnType("uuid"); | |
| 356 | + | |
| 357 | + b.Property<decimal>("Amount") | |
| 358 | + .HasColumnType("numeric"); | |
| 359 | + | |
| 360 | + b.Property<DateTime?>("ConfirmedAt") | |
| 361 | + .HasColumnType("timestamp with time zone"); | |
| 362 | + | |
| 363 | + b.Property<DateTime>("CreatedAt") | |
| 364 | + .HasColumnType("timestamp with time zone"); | |
| 365 | + | |
| 366 | + b.Property<Guid>("FromUserId") | |
| 367 | + .HasColumnType("uuid"); | |
| 368 | + | |
| 369 | + b.Property<DateTime?>("MarkedPaidAt") | |
| 370 | + .HasColumnType("timestamp with time zone"); | |
| 371 | + | |
| 372 | + b.Property<Guid>("SettlementPlanId") | |
| 373 | + .HasColumnType("uuid"); | |
| 374 | + | |
| 375 | + b.Property<int>("Status") | |
| 376 | + .HasColumnType("integer"); | |
| 377 | + | |
| 378 | + b.Property<Guid>("ToUserId") | |
| 379 | + .HasColumnType("uuid"); | |
| 380 | + | |
| 381 | + b.Property<DateTime>("UpdatedAt") | |
| 382 | + .HasColumnType("timestamp with time zone"); | |
| 383 | + | |
| 384 | + b.HasKey("Id"); | |
| 385 | + | |
| 386 | + b.HasIndex("FromUserId"); | |
| 387 | + | |
| 388 | + b.HasIndex("SettlementPlanId"); | |
| 389 | + | |
| 390 | + b.HasIndex("ToUserId"); | |
| 391 | + | |
| 392 | + b.ToTable("SettlementPayments"); | |
| 393 | + }); | |
| 394 | + | |
| 395 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 396 | + { | |
| 397 | + b.Property<Guid>("Id") | |
| 398 | + .ValueGeneratedOnAdd() | |
| 399 | + .HasColumnType("uuid"); | |
| 400 | + | |
| 401 | + b.Property<DateTime?>("CompletedAt") | |
| 402 | + .HasColumnType("timestamp with time zone"); | |
| 403 | + | |
| 404 | + b.Property<DateTime>("CreatedAt") | |
| 405 | + .HasColumnType("timestamp with time zone"); | |
| 406 | + | |
| 407 | + b.Property<Guid>("CreatedByUserId") | |
| 408 | + .HasColumnType("uuid"); | |
| 409 | + | |
| 410 | + b.Property<int>("Status") | |
| 411 | + .HasColumnType("integer"); | |
| 412 | + | |
| 413 | + b.Property<decimal>("TotalAmount") | |
| 414 | + .HasColumnType("numeric"); | |
| 415 | + | |
| 416 | + b.Property<Guid>("TripId") | |
| 417 | + .HasColumnType("uuid"); | |
| 418 | + | |
| 419 | + b.Property<DateTime>("UpdatedAt") | |
| 420 | + .HasColumnType("timestamp with time zone"); | |
| 421 | + | |
| 422 | + b.HasKey("Id"); | |
| 423 | + | |
| 424 | + b.HasIndex("CreatedByUserId"); | |
| 425 | + | |
| 426 | + b.HasIndex("TripId"); | |
| 427 | + | |
| 428 | + b.ToTable("SettlementPlans"); | |
| 429 | + }); | |
| 430 | + | |
| 431 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 432 | + { | |
| 433 | + b.Property<Guid>("Id") | |
| 434 | + .ValueGeneratedOnAdd() | |
| 435 | + .HasColumnType("uuid"); | |
| 436 | + | |
| 437 | + b.Property<DateTime>("CreatedAt") | |
| 438 | + .HasColumnType("timestamp with time zone"); | |
| 439 | + | |
| 440 | + b.Property<Guid>("CreatedById") | |
| 441 | + .HasColumnType("uuid"); | |
| 442 | + | |
| 443 | + b.Property<string>("Name") | |
| 444 | + .IsRequired() | |
| 445 | + .HasMaxLength(200) | |
| 446 | + .HasColumnType("character varying(200)"); | |
| 447 | + | |
| 448 | + b.Property<int>("SplitMethod") | |
| 449 | + .HasColumnType("integer"); | |
| 450 | + | |
| 451 | + b.Property<Guid>("TripId") | |
| 452 | + .HasColumnType("uuid"); | |
| 453 | + | |
| 454 | + b.Property<DateTime>("UpdatedAt") | |
| 455 | + .HasColumnType("timestamp with time zone"); | |
| 456 | + | |
| 457 | + b.HasKey("Id"); | |
| 458 | + | |
| 459 | + b.HasIndex("CreatedById"); | |
| 460 | + | |
| 461 | + b.HasIndex("TripId"); | |
| 462 | + | |
| 463 | + b.ToTable("SplitPresets"); | |
| 464 | + }); | |
| 465 | + | |
| 466 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 467 | + { | |
| 468 | + b.Property<Guid>("Id") | |
| 469 | + .ValueGeneratedOnAdd() | |
| 470 | + .HasColumnType("uuid"); | |
| 471 | + | |
| 472 | + b.Property<DateTime>("CreatedAt") | |
| 473 | + .HasColumnType("timestamp with time zone"); | |
| 474 | + | |
| 475 | + b.Property<decimal?>("Percentage") | |
| 476 | + .HasColumnType("numeric"); | |
| 477 | + | |
| 478 | + b.Property<decimal?>("ShareWeight") | |
| 479 | + .HasColumnType("numeric"); | |
| 480 | + | |
| 481 | + b.Property<Guid>("SplitPresetId") | |
| 482 | + .HasColumnType("uuid"); | |
| 483 | + | |
| 484 | + b.Property<DateTime>("UpdatedAt") | |
| 485 | + .HasColumnType("timestamp with time zone"); | |
| 486 | + | |
| 487 | + b.Property<Guid>("UserId") | |
| 488 | + .HasColumnType("uuid"); | |
| 489 | + | |
| 490 | + b.HasKey("Id"); | |
| 491 | + | |
| 492 | + b.HasIndex("SplitPresetId"); | |
| 493 | + | |
| 494 | + b.HasIndex("UserId"); | |
| 495 | + | |
| 496 | + b.ToTable("SplitPresetMembers"); | |
| 497 | + }); | |
| 498 | + | |
| 499 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 500 | + { | |
| 501 | + b.Property<Guid>("Id") | |
| 502 | + .ValueGeneratedOnAdd() | |
| 503 | + .HasColumnType("uuid"); | |
| 504 | + | |
| 505 | + b.Property<DateTime>("CreatedAt") | |
| 506 | + .HasColumnType("timestamp with time zone"); | |
| 507 | + | |
| 508 | + b.Property<Guid>("CreatedById") | |
| 509 | + .HasColumnType("uuid"); | |
| 510 | + | |
| 511 | + b.Property<Guid>("DefaultCurrencyId") | |
| 512 | + .HasColumnType("uuid"); | |
| 513 | + | |
| 514 | + b.Property<string>("Description") | |
| 515 | + .HasColumnType("text"); | |
| 516 | + | |
| 517 | + b.Property<string>("Destination") | |
| 518 | + .HasMaxLength(200) | |
| 519 | + .HasColumnType("character varying(200)"); | |
| 520 | + | |
| 521 | + b.Property<DateTime?>("EndDate") | |
| 522 | + .HasColumnType("timestamp with time zone"); | |
| 523 | + | |
| 524 | + b.Property<string>("Name") | |
| 525 | + .IsRequired() | |
| 526 | + .HasMaxLength(200) | |
| 527 | + .HasColumnType("character varying(200)"); | |
| 528 | + | |
| 529 | + b.Property<DateTime?>("StartDate") | |
| 530 | + .HasColumnType("timestamp with time zone"); | |
| 531 | + | |
| 532 | + b.Property<int>("Status") | |
| 533 | + .HasColumnType("integer"); | |
| 534 | + | |
| 535 | + b.Property<DateTime>("UpdatedAt") | |
| 536 | + .HasColumnType("timestamp with time zone"); | |
| 537 | + | |
| 538 | + b.HasKey("Id"); | |
| 539 | + | |
| 540 | + b.HasIndex("CreatedById"); | |
| 541 | + | |
| 542 | + b.HasIndex("DefaultCurrencyId"); | |
| 543 | + | |
| 544 | + b.ToTable("Trips"); | |
| 545 | + }); | |
| 546 | + | |
| 547 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 548 | + { | |
| 549 | + b.Property<Guid>("Id") | |
| 550 | + .ValueGeneratedOnAdd() | |
| 551 | + .HasColumnType("uuid"); | |
| 552 | + | |
| 553 | + b.Property<DateTime>("CreatedAt") | |
| 554 | + .HasColumnType("timestamp with time zone"); | |
| 555 | + | |
| 556 | + b.Property<DateTime>("ExpiresAt") | |
| 557 | + .HasColumnType("timestamp with time zone"); | |
| 558 | + | |
| 559 | + b.Property<Guid>("InvitedByUserId") | |
| 560 | + .HasColumnType("uuid"); | |
| 561 | + | |
| 562 | + b.Property<DateTime?>("RespondedAt") | |
| 563 | + .HasColumnType("timestamp with time zone"); | |
| 564 | + | |
| 565 | + b.Property<int>("Status") | |
| 566 | + .HasColumnType("integer"); | |
| 567 | + | |
| 568 | + b.Property<string>("Token") | |
| 569 | + .IsRequired() | |
| 570 | + .HasMaxLength(256) | |
| 571 | + .HasColumnType("character varying(256)"); | |
| 572 | + | |
| 573 | + b.Property<Guid>("TripId") | |
| 574 | + .HasColumnType("uuid"); | |
| 575 | + | |
| 576 | + b.Property<DateTime>("UpdatedAt") | |
| 577 | + .HasColumnType("timestamp with time zone"); | |
| 578 | + | |
| 579 | + b.HasKey("Id"); | |
| 580 | + | |
| 581 | + b.HasIndex("InvitedByUserId"); | |
| 582 | + | |
| 583 | + b.HasIndex("Token") | |
| 584 | + .IsUnique(); | |
| 585 | + | |
| 586 | + b.HasIndex("TripId"); | |
| 587 | + | |
| 588 | + b.ToTable("TripInvitations"); | |
| 589 | + }); | |
| 590 | + | |
| 591 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 592 | + { | |
| 593 | + b.Property<Guid>("Id") | |
| 594 | + .ValueGeneratedOnAdd() | |
| 595 | + .HasColumnType("uuid"); | |
| 596 | + | |
| 597 | + b.Property<DateTime>("CreatedAt") | |
| 598 | + .HasColumnType("timestamp with time zone"); | |
| 599 | + | |
| 600 | + b.Property<bool>("IsActive") | |
| 601 | + .HasColumnType("boolean"); | |
| 602 | + | |
| 603 | + b.Property<DateTime>("JoinedAt") | |
| 604 | + .HasColumnType("timestamp with time zone"); | |
| 605 | + | |
| 606 | + b.Property<DateTime?>("LeftAt") | |
| 607 | + .HasColumnType("timestamp with time zone"); | |
| 608 | + | |
| 609 | + b.Property<string>("Nickname") | |
| 610 | + .HasMaxLength(100) | |
| 611 | + .HasColumnType("character varying(100)"); | |
| 612 | + | |
| 613 | + b.Property<int>("Role") | |
| 614 | + .HasColumnType("integer"); | |
| 615 | + | |
| 616 | + b.Property<Guid>("TripId") | |
| 617 | + .HasColumnType("uuid"); | |
| 618 | + | |
| 619 | + b.Property<DateTime>("UpdatedAt") | |
| 620 | + .HasColumnType("timestamp with time zone"); | |
| 621 | + | |
| 622 | + b.Property<Guid>("UserId") | |
| 623 | + .HasColumnType("uuid"); | |
| 624 | + | |
| 625 | + b.HasKey("Id"); | |
| 626 | + | |
| 627 | + b.HasIndex("UserId"); | |
| 628 | + | |
| 629 | + b.HasIndex("TripId", "UserId") | |
| 630 | + .IsUnique(); | |
| 631 | + | |
| 632 | + b.ToTable("TripParticipants"); | |
| 633 | + }); | |
| 634 | + | |
| 635 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 636 | + { | |
| 637 | + b.Property<Guid>("Id") | |
| 638 | + .ValueGeneratedOnAdd() | |
| 639 | + .HasColumnType("uuid"); | |
| 640 | + | |
| 641 | + b.Property<bool>("AllowMultipleVotes") | |
| 642 | + .HasColumnType("boolean"); | |
| 643 | + | |
| 644 | + b.Property<DateTime?>("ClosedAt") | |
| 645 | + .HasColumnType("timestamp with time zone"); | |
| 646 | + | |
| 647 | + b.Property<DateTime>("CreatedAt") | |
| 648 | + .HasColumnType("timestamp with time zone"); | |
| 649 | + | |
| 650 | + b.Property<Guid>("CreatedByUserId") | |
| 651 | + .HasColumnType("uuid"); | |
| 652 | + | |
| 653 | + b.Property<bool>("IsAnonymous") | |
| 654 | + .HasColumnType("boolean"); | |
| 655 | + | |
| 656 | + b.Property<string>("Question") | |
| 657 | + .IsRequired() | |
| 658 | + .HasMaxLength(500) | |
| 659 | + .HasColumnType("character varying(500)"); | |
| 660 | + | |
| 661 | + b.Property<Guid>("TripId") | |
| 662 | + .HasColumnType("uuid"); | |
| 663 | + | |
| 664 | + b.Property<DateTime>("UpdatedAt") | |
| 665 | + .HasColumnType("timestamp with time zone"); | |
| 666 | + | |
| 667 | + b.HasKey("Id"); | |
| 668 | + | |
| 669 | + b.HasIndex("CreatedByUserId"); | |
| 670 | + | |
| 671 | + b.HasIndex("TripId"); | |
| 672 | + | |
| 673 | + b.ToTable("TripPolls"); | |
| 674 | + }); | |
| 675 | + | |
| 676 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 677 | + { | |
| 678 | + b.Property<Guid>("Id") | |
| 679 | + .ValueGeneratedOnAdd() | |
| 680 | + .HasColumnType("uuid"); | |
| 681 | + | |
| 682 | + b.Property<DateTime>("CreatedAt") | |
| 683 | + .HasColumnType("timestamp with time zone"); | |
| 684 | + | |
| 685 | + b.Property<int>("DisplayOrder") | |
| 686 | + .HasColumnType("integer"); | |
| 687 | + | |
| 688 | + b.Property<Guid>("PollId") | |
| 689 | + .HasColumnType("uuid"); | |
| 690 | + | |
| 691 | + b.Property<string>("Text") | |
| 692 | + .IsRequired() | |
| 693 | + .HasMaxLength(300) | |
| 694 | + .HasColumnType("character varying(300)"); | |
| 695 | + | |
| 696 | + b.Property<DateTime>("UpdatedAt") | |
| 697 | + .HasColumnType("timestamp with time zone"); | |
| 698 | + | |
| 699 | + b.HasKey("Id"); | |
| 700 | + | |
| 701 | + b.HasIndex("PollId"); | |
| 702 | + | |
| 703 | + b.ToTable("TripPollOptions"); | |
| 704 | + }); | |
| 705 | + | |
| 706 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 707 | + { | |
| 708 | + b.Property<Guid>("Id") | |
| 709 | + .ValueGeneratedOnAdd() | |
| 710 | + .HasColumnType("uuid"); | |
| 711 | + | |
| 712 | + b.Property<DateTime>("CreatedAt") | |
| 713 | + .HasColumnType("timestamp with time zone"); | |
| 714 | + | |
| 715 | + b.Property<Guid>("PollOptionId") | |
| 716 | + .HasColumnType("uuid"); | |
| 717 | + | |
| 718 | + b.Property<DateTime>("UpdatedAt") | |
| 719 | + .HasColumnType("timestamp with time zone"); | |
| 720 | + | |
| 721 | + b.Property<Guid>("UserId") | |
| 722 | + .HasColumnType("uuid"); | |
| 723 | + | |
| 724 | + b.HasKey("Id"); | |
| 725 | + | |
| 726 | + b.HasIndex("UserId"); | |
| 727 | + | |
| 728 | + b.HasIndex("PollOptionId", "UserId") | |
| 729 | + .IsUnique(); | |
| 730 | + | |
| 731 | + b.ToTable("TripPollVotes"); | |
| 732 | + }); | |
| 733 | + | |
| 734 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 735 | + { | |
| 736 | + b.Property<Guid>("Id") | |
| 737 | + .ValueGeneratedOnAdd() | |
| 738 | + .HasColumnType("uuid"); | |
| 739 | + | |
| 740 | + b.Property<Guid>("AddedByUserId") | |
| 741 | + .HasColumnType("uuid"); | |
| 742 | + | |
| 743 | + b.Property<int>("Category") | |
| 744 | + .HasColumnType("integer"); | |
| 745 | + | |
| 746 | + b.Property<DateTime?>("CompletedAt") | |
| 747 | + .HasColumnType("timestamp with time zone"); | |
| 748 | + | |
| 749 | + b.Property<DateTime>("CreatedAt") | |
| 750 | + .HasColumnType("timestamp with time zone"); | |
| 751 | + | |
| 752 | + b.Property<string>("Description") | |
| 753 | + .HasColumnType("text"); | |
| 754 | + | |
| 755 | + b.Property<int>("DisplayOrder") | |
| 756 | + .HasColumnType("integer"); | |
| 757 | + | |
| 758 | + b.Property<decimal?>("EstimatedCost") | |
| 759 | + .HasColumnType("numeric"); | |
| 760 | + | |
| 761 | + b.Property<bool>("IsCompleted") | |
| 762 | + .HasColumnType("boolean"); | |
| 763 | + | |
| 764 | + b.Property<string>("Location") | |
| 765 | + .HasMaxLength(300) | |
| 766 | + .HasColumnType("character varying(300)"); | |
| 767 | + | |
| 768 | + b.Property<int>("Priority") | |
| 769 | + .HasColumnType("integer"); | |
| 770 | + | |
| 771 | + b.Property<string>("Title") | |
| 772 | + .IsRequired() | |
| 773 | + .HasMaxLength(200) | |
| 774 | + .HasColumnType("character varying(200)"); | |
| 775 | + | |
| 776 | + b.Property<Guid>("TripId") | |
| 777 | + .HasColumnType("uuid"); | |
| 778 | + | |
| 779 | + b.Property<DateTime>("UpdatedAt") | |
| 780 | + .HasColumnType("timestamp with time zone"); | |
| 781 | + | |
| 782 | + b.Property<string>("Url") | |
| 783 | + .HasMaxLength(500) | |
| 784 | + .HasColumnType("character varying(500)"); | |
| 785 | + | |
| 786 | + b.HasKey("Id"); | |
| 787 | + | |
| 788 | + b.HasIndex("AddedByUserId"); | |
| 789 | + | |
| 790 | + b.HasIndex("TripId"); | |
| 791 | + | |
| 792 | + b.ToTable("TripWishlistItems"); | |
| 793 | + }); | |
| 794 | + | |
| 795 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 796 | + { | |
| 797 | + b.Property<Guid>("Id") | |
| 798 | + .ValueGeneratedOnAdd() | |
| 799 | + .HasColumnType("uuid"); | |
| 800 | + | |
| 801 | + b.Property<DateTime>("CreatedAt") | |
| 802 | + .HasColumnType("timestamp with time zone"); | |
| 803 | + | |
| 804 | + b.Property<bool>("IsInterested") | |
| 805 | + .HasColumnType("boolean"); | |
| 806 | + | |
| 807 | + b.Property<DateTime>("UpdatedAt") | |
| 808 | + .HasColumnType("timestamp with time zone"); | |
| 809 | + | |
| 810 | + b.Property<Guid>("UserId") | |
| 811 | + .HasColumnType("uuid"); | |
| 812 | + | |
| 813 | + b.Property<Guid>("WishlistItemId") | |
| 814 | + .HasColumnType("uuid"); | |
| 815 | + | |
| 816 | + b.HasKey("Id"); | |
| 817 | + | |
| 818 | + b.HasIndex("UserId"); | |
| 819 | + | |
| 820 | + b.HasIndex("WishlistItemId", "UserId") | |
| 821 | + .IsUnique(); | |
| 822 | + | |
| 823 | + b.ToTable("TripWishlistVotes"); | |
| 824 | + }); | |
| 825 | + | |
| 826 | + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => | |
| 827 | + { | |
| 828 | + b.Property<int>("Id") | |
| 829 | + .ValueGeneratedOnAdd() | |
| 830 | + .HasColumnType("integer"); | |
| 831 | + | |
| 832 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 833 | + | |
| 834 | + b.Property<string>("FriendlyName") | |
| 835 | + .HasColumnType("text"); | |
| 836 | + | |
| 837 | + b.Property<string>("Xml") | |
| 838 | + .HasColumnType("text"); | |
| 839 | + | |
| 840 | + b.HasKey("Id"); | |
| 841 | + | |
| 842 | + b.ToTable("DataProtectionKeys"); | |
| 843 | + }); | |
| 844 | + | |
| 845 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 846 | + { | |
| 847 | + b.Property<int>("Id") | |
| 848 | + .ValueGeneratedOnAdd() | |
| 849 | + .HasColumnType("integer"); | |
| 850 | + | |
| 851 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 852 | + | |
| 853 | + b.Property<string>("ClaimType") | |
| 854 | + .HasColumnType("text"); | |
| 855 | + | |
| 856 | + b.Property<string>("ClaimValue") | |
| 857 | + .HasColumnType("text"); | |
| 858 | + | |
| 859 | + b.Property<Guid>("RoleId") | |
| 860 | + .HasColumnType("uuid"); | |
| 861 | + | |
| 862 | + b.HasKey("Id"); | |
| 863 | + | |
| 864 | + b.HasIndex("RoleId"); | |
| 865 | + | |
| 866 | + b.ToTable("AspNetRoleClaims", (string)null); | |
| 867 | + }); | |
| 868 | + | |
| 869 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 870 | + { | |
| 871 | + b.Property<int>("Id") | |
| 872 | + .ValueGeneratedOnAdd() | |
| 873 | + .HasColumnType("integer"); | |
| 874 | + | |
| 875 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 876 | + | |
| 877 | + b.Property<string>("ClaimType") | |
| 878 | + .HasColumnType("text"); | |
| 879 | + | |
| 880 | + b.Property<string>("ClaimValue") | |
| 881 | + .HasColumnType("text"); | |
| 882 | + | |
| 883 | + b.Property<Guid>("UserId") | |
| 884 | + .HasColumnType("uuid"); | |
| 885 | + | |
| 886 | + b.HasKey("Id"); | |
| 887 | + | |
| 888 | + b.HasIndex("UserId"); | |
| 889 | + | |
| 890 | + b.ToTable("AspNetUserClaims", (string)null); | |
| 891 | + }); | |
| 892 | + | |
| 893 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 894 | + { | |
| 895 | + b.Property<string>("LoginProvider") | |
| 896 | + .HasColumnType("text"); | |
| 897 | + | |
| 898 | + b.Property<string>("ProviderKey") | |
| 899 | + .HasColumnType("text"); | |
| 900 | + | |
| 901 | + b.Property<string>("ProviderDisplayName") | |
| 902 | + .HasColumnType("text"); | |
| 903 | + | |
| 904 | + b.Property<Guid>("UserId") | |
| 905 | + .HasColumnType("uuid"); | |
| 906 | + | |
| 907 | + b.HasKey("LoginProvider", "ProviderKey"); | |
| 908 | + | |
| 909 | + b.HasIndex("UserId"); | |
| 910 | + | |
| 911 | + b.ToTable("AspNetUserLogins", (string)null); | |
| 912 | + }); | |
| 913 | + | |
| 914 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 915 | + { | |
| 916 | + b.Property<Guid>("UserId") | |
| 917 | + .HasColumnType("uuid"); | |
| 918 | + | |
| 919 | + b.Property<Guid>("RoleId") | |
| 920 | + .HasColumnType("uuid"); | |
| 921 | + | |
| 922 | + b.HasKey("UserId", "RoleId"); | |
| 923 | + | |
| 924 | + b.HasIndex("RoleId"); | |
| 925 | + | |
| 926 | + b.ToTable("AspNetUserRoles", (string)null); | |
| 927 | + }); | |
| 928 | + | |
| 929 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 930 | + { | |
| 931 | + b.Property<Guid>("UserId") | |
| 932 | + .HasColumnType("uuid"); | |
| 933 | + | |
| 934 | + b.Property<string>("LoginProvider") | |
| 935 | + .HasColumnType("text"); | |
| 936 | + | |
| 937 | + b.Property<string>("Name") | |
| 938 | + .HasColumnType("text"); | |
| 939 | + | |
| 940 | + b.Property<string>("Value") | |
| 941 | + .HasColumnType("text"); | |
| 942 | + | |
| 943 | + b.HasKey("UserId", "LoginProvider", "Name"); | |
| 944 | + | |
| 945 | + b.ToTable("AspNetUserTokens", (string)null); | |
| 946 | + }); | |
| 947 | + | |
| 948 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 949 | + { | |
| 950 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 951 | + .WithMany("BudgetCategories") | |
| 952 | + .HasForeignKey("TripId") | |
| 953 | + .OnDelete(DeleteBehavior.Restrict) | |
| 954 | + .IsRequired(); | |
| 955 | + | |
| 956 | + b.Navigation("Trip"); | |
| 957 | + }); | |
| 958 | + | |
| 959 | + modelBuilder.Entity("App.Domain.BudgetCategoryTranslation", b => | |
| 960 | + { | |
| 961 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 962 | + .WithMany("Translations") | |
| 963 | + .HasForeignKey("BudgetCategoryId") | |
| 964 | + .OnDelete(DeleteBehavior.Restrict) | |
| 965 | + .IsRequired(); | |
| 966 | + | |
| 967 | + b.Navigation("BudgetCategory"); | |
| 968 | + }); | |
| 969 | + | |
| 970 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 971 | + { | |
| 972 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 973 | + .WithMany("Expenses") | |
| 974 | + .HasForeignKey("BudgetCategoryId") | |
| 975 | + .OnDelete(DeleteBehavior.Restrict); | |
| 976 | + | |
| 977 | + b.HasOne("App.Domain.Currency", "Currency") | |
| 978 | + .WithMany() | |
| 979 | + .HasForeignKey("CurrencyId") | |
| 980 | + .OnDelete(DeleteBehavior.Restrict); | |
| 981 | + | |
| 982 | + b.HasOne("App.Domain.Identity.AppUser", "PaidByUser") | |
| 983 | + .WithMany() | |
| 984 | + .HasForeignKey("PaidByUserId") | |
| 985 | + .OnDelete(DeleteBehavior.Restrict) | |
| 986 | + .IsRequired(); | |
| 987 | + | |
| 988 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 989 | + .WithMany("Expenses") | |
| 990 | + .HasForeignKey("TripId") | |
| 991 | + .OnDelete(DeleteBehavior.Restrict) | |
| 992 | + .IsRequired(); | |
| 993 | + | |
| 994 | + b.Navigation("BudgetCategory"); | |
| 995 | + | |
| 996 | + b.Navigation("Currency"); | |
| 997 | + | |
| 998 | + b.Navigation("PaidByUser"); | |
| 999 | + | |
| 1000 | + b.Navigation("Trip"); | |
| 1001 | + }); | |
| 1002 | + | |
| 1003 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 1004 | + { | |
| 1005 | + b.HasOne("App.Domain.Expense", "Expense") | |
| 1006 | + .WithMany("Splits") | |
| 1007 | + .HasForeignKey("ExpenseId") | |
| 1008 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1009 | + .IsRequired(); | |
| 1010 | + | |
| 1011 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1012 | + .WithMany() | |
| 1013 | + .HasForeignKey("UserId") | |
| 1014 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1015 | + .IsRequired(); | |
| 1016 | + | |
| 1017 | + b.Navigation("Expense"); | |
| 1018 | + | |
| 1019 | + b.Navigation("User"); | |
| 1020 | + }); | |
| 1021 | + | |
| 1022 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 1023 | + { | |
| 1024 | + b.HasOne("App.Domain.Identity.AppUser", "AppUser") | |
| 1025 | + .WithMany("RefreshTokens") | |
| 1026 | + .HasForeignKey("AppUserId") | |
| 1027 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1028 | + .IsRequired(); | |
| 1029 | + | |
| 1030 | + b.Navigation("AppUser"); | |
| 1031 | + }); | |
| 1032 | + | |
| 1033 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 1034 | + { | |
| 1035 | + b.HasOne("App.Domain.Identity.AppUser", "FromUser") | |
| 1036 | + .WithMany() | |
| 1037 | + .HasForeignKey("FromUserId") | |
| 1038 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1039 | + .IsRequired(); | |
| 1040 | + | |
| 1041 | + b.HasOne("App.Domain.SettlementPlan", "SettlementPlan") | |
| 1042 | + .WithMany("Payments") | |
| 1043 | + .HasForeignKey("SettlementPlanId") | |
| 1044 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1045 | + .IsRequired(); | |
| 1046 | + | |
| 1047 | + b.HasOne("App.Domain.Identity.AppUser", "ToUser") | |
| 1048 | + .WithMany() | |
| 1049 | + .HasForeignKey("ToUserId") | |
| 1050 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1051 | + .IsRequired(); | |
| 1052 | + | |
| 1053 | + b.Navigation("FromUser"); | |
| 1054 | + | |
| 1055 | + b.Navigation("SettlementPlan"); | |
| 1056 | + | |
| 1057 | + b.Navigation("ToUser"); | |
| 1058 | + }); | |
| 1059 | + | |
| 1060 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1061 | + { | |
| 1062 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1063 | + .WithMany() | |
| 1064 | + .HasForeignKey("CreatedByUserId") | |
| 1065 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1066 | + .IsRequired(); | |
| 1067 | + | |
| 1068 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1069 | + .WithMany("SettlementPlans") | |
| 1070 | + .HasForeignKey("TripId") | |
| 1071 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1072 | + .IsRequired(); | |
| 1073 | + | |
| 1074 | + b.Navigation("CreatedByUser"); | |
| 1075 | + | |
| 1076 | + b.Navigation("Trip"); | |
| 1077 | + }); | |
| 1078 | + | |
| 1079 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1080 | + { | |
| 1081 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1082 | + .WithMany() | |
| 1083 | + .HasForeignKey("CreatedById") | |
| 1084 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1085 | + .IsRequired(); | |
| 1086 | + | |
| 1087 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1088 | + .WithMany() | |
| 1089 | + .HasForeignKey("TripId") | |
| 1090 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1091 | + .IsRequired(); | |
| 1092 | + | |
| 1093 | + b.Navigation("CreatedBy"); | |
| 1094 | + | |
| 1095 | + b.Navigation("Trip"); | |
| 1096 | + }); | |
| 1097 | + | |
| 1098 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 1099 | + { | |
| 1100 | + b.HasOne("App.Domain.SplitPreset", "SplitPreset") | |
| 1101 | + .WithMany("Members") | |
| 1102 | + .HasForeignKey("SplitPresetId") | |
| 1103 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1104 | + .IsRequired(); | |
| 1105 | + | |
| 1106 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1107 | + .WithMany() | |
| 1108 | + .HasForeignKey("UserId") | |
| 1109 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1110 | + .IsRequired(); | |
| 1111 | + | |
| 1112 | + b.Navigation("SplitPreset"); | |
| 1113 | + | |
| 1114 | + b.Navigation("User"); | |
| 1115 | + }); | |
| 1116 | + | |
| 1117 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1118 | + { | |
| 1119 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1120 | + .WithMany() | |
| 1121 | + .HasForeignKey("CreatedById") | |
| 1122 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1123 | + .IsRequired(); | |
| 1124 | + | |
| 1125 | + b.HasOne("App.Domain.Currency", "DefaultCurrency") | |
| 1126 | + .WithMany() | |
| 1127 | + .HasForeignKey("DefaultCurrencyId") | |
| 1128 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1129 | + .IsRequired(); | |
| 1130 | + | |
| 1131 | + b.Navigation("CreatedBy"); | |
| 1132 | + | |
| 1133 | + b.Navigation("DefaultCurrency"); | |
| 1134 | + }); | |
| 1135 | + | |
| 1136 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 1137 | + { | |
| 1138 | + b.HasOne("App.Domain.Identity.AppUser", "InvitedByUser") | |
| 1139 | + .WithMany() | |
| 1140 | + .HasForeignKey("InvitedByUserId") | |
| 1141 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1142 | + .IsRequired(); | |
| 1143 | + | |
| 1144 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1145 | + .WithMany("Invitations") | |
| 1146 | + .HasForeignKey("TripId") | |
| 1147 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1148 | + .IsRequired(); | |
| 1149 | + | |
| 1150 | + b.Navigation("InvitedByUser"); | |
| 1151 | + | |
| 1152 | + b.Navigation("Trip"); | |
| 1153 | + }); | |
| 1154 | + | |
| 1155 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 1156 | + { | |
| 1157 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1158 | + .WithMany("Participants") | |
| 1159 | + .HasForeignKey("TripId") | |
| 1160 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1161 | + .IsRequired(); | |
| 1162 | + | |
| 1163 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1164 | + .WithMany("TripParticipants") | |
| 1165 | + .HasForeignKey("UserId") | |
| 1166 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1167 | + .IsRequired(); | |
| 1168 | + | |
| 1169 | + b.Navigation("Trip"); | |
| 1170 | + | |
| 1171 | + b.Navigation("User"); | |
| 1172 | + }); | |
| 1173 | + | |
| 1174 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1175 | + { | |
| 1176 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1177 | + .WithMany() | |
| 1178 | + .HasForeignKey("CreatedByUserId") | |
| 1179 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1180 | + .IsRequired(); | |
| 1181 | + | |
| 1182 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1183 | + .WithMany("Polls") | |
| 1184 | + .HasForeignKey("TripId") | |
| 1185 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1186 | + .IsRequired(); | |
| 1187 | + | |
| 1188 | + b.Navigation("CreatedByUser"); | |
| 1189 | + | |
| 1190 | + b.Navigation("Trip"); | |
| 1191 | + }); | |
| 1192 | + | |
| 1193 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1194 | + { | |
| 1195 | + b.HasOne("App.Domain.TripPoll", "Poll") | |
| 1196 | + .WithMany("Options") | |
| 1197 | + .HasForeignKey("PollId") | |
| 1198 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1199 | + .IsRequired(); | |
| 1200 | + | |
| 1201 | + b.Navigation("Poll"); | |
| 1202 | + }); | |
| 1203 | + | |
| 1204 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 1205 | + { | |
| 1206 | + b.HasOne("App.Domain.TripPollOption", "PollOption") | |
| 1207 | + .WithMany("Votes") | |
| 1208 | + .HasForeignKey("PollOptionId") | |
| 1209 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1210 | + .IsRequired(); | |
| 1211 | + | |
| 1212 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1213 | + .WithMany() | |
| 1214 | + .HasForeignKey("UserId") | |
| 1215 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1216 | + .IsRequired(); | |
| 1217 | + | |
| 1218 | + b.Navigation("PollOption"); | |
| 1219 | + | |
| 1220 | + b.Navigation("User"); | |
| 1221 | + }); | |
| 1222 | + | |
| 1223 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1224 | + { | |
| 1225 | + b.HasOne("App.Domain.Identity.AppUser", "AddedByUser") | |
| 1226 | + .WithMany() | |
| 1227 | + .HasForeignKey("AddedByUserId") | |
| 1228 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1229 | + .IsRequired(); | |
| 1230 | + | |
| 1231 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1232 | + .WithMany("WishlistItems") | |
| 1233 | + .HasForeignKey("TripId") | |
| 1234 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1235 | + .IsRequired(); | |
| 1236 | + | |
| 1237 | + b.Navigation("AddedByUser"); | |
| 1238 | + | |
| 1239 | + b.Navigation("Trip"); | |
| 1240 | + }); | |
| 1241 | + | |
| 1242 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 1243 | + { | |
| 1244 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1245 | + .WithMany() | |
| 1246 | + .HasForeignKey("UserId") | |
| 1247 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1248 | + .IsRequired(); | |
| 1249 | + | |
| 1250 | + b.HasOne("App.Domain.TripWishlistItem", "WishlistItem") | |
| 1251 | + .WithMany("Votes") | |
| 1252 | + .HasForeignKey("WishlistItemId") | |
| 1253 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1254 | + .IsRequired(); | |
| 1255 | + | |
| 1256 | + b.Navigation("User"); | |
| 1257 | + | |
| 1258 | + b.Navigation("WishlistItem"); | |
| 1259 | + }); | |
| 1260 | + | |
| 1261 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 1262 | + { | |
| 1263 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1264 | + .WithMany() | |
| 1265 | + .HasForeignKey("RoleId") | |
| 1266 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1267 | + .IsRequired(); | |
| 1268 | + }); | |
| 1269 | + | |
| 1270 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 1271 | + { | |
| 1272 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1273 | + .WithMany() | |
| 1274 | + .HasForeignKey("UserId") | |
| 1275 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1276 | + .IsRequired(); | |
| 1277 | + }); | |
| 1278 | + | |
| 1279 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 1280 | + { | |
| 1281 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1282 | + .WithMany() | |
| 1283 | + .HasForeignKey("UserId") | |
| 1284 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1285 | + .IsRequired(); | |
| 1286 | + }); | |
| 1287 | + | |
| 1288 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 1289 | + { | |
| 1290 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1291 | + .WithMany() | |
| 1292 | + .HasForeignKey("RoleId") | |
| 1293 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1294 | + .IsRequired(); | |
| 1295 | + | |
| 1296 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1297 | + .WithMany() | |
| 1298 | + .HasForeignKey("UserId") | |
| 1299 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1300 | + .IsRequired(); | |
| 1301 | + }); | |
| 1302 | + | |
| 1303 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 1304 | + { | |
| 1305 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1306 | + .WithMany() | |
| 1307 | + .HasForeignKey("UserId") | |
| 1308 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1309 | + .IsRequired(); | |
| 1310 | + }); | |
| 1311 | + | |
| 1312 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 1313 | + { | |
| 1314 | + b.Navigation("Expenses"); | |
| 1315 | + | |
| 1316 | + b.Navigation("Translations"); | |
| 1317 | + }); | |
| 1318 | + | |
| 1319 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 1320 | + { | |
| 1321 | + b.Navigation("Splits"); | |
| 1322 | + }); | |
| 1323 | + | |
| 1324 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 1325 | + { | |
| 1326 | + b.Navigation("RefreshTokens"); | |
| 1327 | + | |
| 1328 | + b.Navigation("TripParticipants"); | |
| 1329 | + }); | |
| 1330 | + | |
| 1331 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1332 | + { | |
| 1333 | + b.Navigation("Payments"); | |
| 1334 | + }); | |
| 1335 | + | |
| 1336 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1337 | + { | |
| 1338 | + b.Navigation("Members"); | |
| 1339 | + }); | |
| 1340 | + | |
| 1341 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1342 | + { | |
| 1343 | + b.Navigation("BudgetCategories"); | |
| 1344 | + | |
| 1345 | + b.Navigation("Expenses"); | |
| 1346 | + | |
| 1347 | + b.Navigation("Invitations"); | |
| 1348 | + | |
| 1349 | + b.Navigation("Participants"); | |
| 1350 | + | |
| 1351 | + b.Navigation("Polls"); | |
| 1352 | + | |
| 1353 | + b.Navigation("SettlementPlans"); | |
| 1354 | + | |
| 1355 | + b.Navigation("WishlistItems"); | |
| 1356 | + }); | |
| 1357 | + | |
| 1358 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1359 | + { | |
| 1360 | + b.Navigation("Options"); | |
| 1361 | + }); | |
| 1362 | + | |
| 1363 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1364 | + { | |
| 1365 | + b.Navigation("Votes"); | |
| 1366 | + }); | |
| 1367 | + | |
| 1368 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1369 | + { | |
| 1370 | + b.Navigation("Votes"); | |
| 1371 | + }); | |
| 1372 | +#pragma warning restore 612, 618 | |
| 1373 | + } | |
| 1374 | + } | |
| 1375 | +} |
added SplitApp/App.DAL.EF/Migrations/20260328161224_AddBaseEntityTimestamps.cs +415 −0
| @@ -0,0 +1,415 @@ | ||
| 1 | +using System; | |
| 2 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 3 | + | |
| 4 | +#nullable disable | |
| 5 | + | |
| 6 | +namespace App.DAL.EF.Migrations | |
| 7 | +{ | |
| 8 | + /// <inheritdoc /> | |
| 9 | + public partial class AddBaseEntityTimestamps : Migration | |
| 10 | + { | |
| 11 | + /// <inheritdoc /> | |
| 12 | + protected override void Up(MigrationBuilder migrationBuilder) | |
| 13 | + { | |
| 14 | + migrationBuilder.AddColumn<DateTime>( | |
| 15 | + name: "CreatedAt", | |
| 16 | + table: "TripWishlistVotes", | |
| 17 | + type: "timestamp with time zone", | |
| 18 | + nullable: false, | |
| 19 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 20 | + | |
| 21 | + migrationBuilder.AddColumn<DateTime>( | |
| 22 | + name: "UpdatedAt", | |
| 23 | + table: "TripWishlistVotes", | |
| 24 | + type: "timestamp with time zone", | |
| 25 | + nullable: false, | |
| 26 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 27 | + | |
| 28 | + migrationBuilder.AddColumn<DateTime>( | |
| 29 | + name: "CreatedAt", | |
| 30 | + table: "TripWishlistItems", | |
| 31 | + type: "timestamp with time zone", | |
| 32 | + nullable: false, | |
| 33 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 34 | + | |
| 35 | + migrationBuilder.AddColumn<DateTime>( | |
| 36 | + name: "UpdatedAt", | |
| 37 | + table: "TripWishlistItems", | |
| 38 | + type: "timestamp with time zone", | |
| 39 | + nullable: false, | |
| 40 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 41 | + | |
| 42 | + migrationBuilder.AddColumn<DateTime>( | |
| 43 | + name: "CreatedAt", | |
| 44 | + table: "Trips", | |
| 45 | + type: "timestamp with time zone", | |
| 46 | + nullable: false, | |
| 47 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 48 | + | |
| 49 | + migrationBuilder.AddColumn<DateTime>( | |
| 50 | + name: "UpdatedAt", | |
| 51 | + table: "Trips", | |
| 52 | + type: "timestamp with time zone", | |
| 53 | + nullable: false, | |
| 54 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 55 | + | |
| 56 | + migrationBuilder.AddColumn<DateTime>( | |
| 57 | + name: "CreatedAt", | |
| 58 | + table: "TripPollVotes", | |
| 59 | + type: "timestamp with time zone", | |
| 60 | + nullable: false, | |
| 61 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 62 | + | |
| 63 | + migrationBuilder.AddColumn<DateTime>( | |
| 64 | + name: "UpdatedAt", | |
| 65 | + table: "TripPollVotes", | |
| 66 | + type: "timestamp with time zone", | |
| 67 | + nullable: false, | |
| 68 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 69 | + | |
| 70 | + migrationBuilder.AddColumn<DateTime>( | |
| 71 | + name: "CreatedAt", | |
| 72 | + table: "TripPolls", | |
| 73 | + type: "timestamp with time zone", | |
| 74 | + nullable: false, | |
| 75 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 76 | + | |
| 77 | + migrationBuilder.AddColumn<DateTime>( | |
| 78 | + name: "UpdatedAt", | |
| 79 | + table: "TripPolls", | |
| 80 | + type: "timestamp with time zone", | |
| 81 | + nullable: false, | |
| 82 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 83 | + | |
| 84 | + migrationBuilder.AddColumn<DateTime>( | |
| 85 | + name: "CreatedAt", | |
| 86 | + table: "TripPollOptions", | |
| 87 | + type: "timestamp with time zone", | |
| 88 | + nullable: false, | |
| 89 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 90 | + | |
| 91 | + migrationBuilder.AddColumn<DateTime>( | |
| 92 | + name: "UpdatedAt", | |
| 93 | + table: "TripPollOptions", | |
| 94 | + type: "timestamp with time zone", | |
| 95 | + nullable: false, | |
| 96 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 97 | + | |
| 98 | + migrationBuilder.AddColumn<DateTime>( | |
| 99 | + name: "CreatedAt", | |
| 100 | + table: "TripParticipants", | |
| 101 | + type: "timestamp with time zone", | |
| 102 | + nullable: false, | |
| 103 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 104 | + | |
| 105 | + migrationBuilder.AddColumn<DateTime>( | |
| 106 | + name: "UpdatedAt", | |
| 107 | + table: "TripParticipants", | |
| 108 | + type: "timestamp with time zone", | |
| 109 | + nullable: false, | |
| 110 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 111 | + | |
| 112 | + migrationBuilder.AddColumn<DateTime>( | |
| 113 | + name: "CreatedAt", | |
| 114 | + table: "TripInvitations", | |
| 115 | + type: "timestamp with time zone", | |
| 116 | + nullable: false, | |
| 117 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 118 | + | |
| 119 | + migrationBuilder.AddColumn<DateTime>( | |
| 120 | + name: "UpdatedAt", | |
| 121 | + table: "TripInvitations", | |
| 122 | + type: "timestamp with time zone", | |
| 123 | + nullable: false, | |
| 124 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 125 | + | |
| 126 | + migrationBuilder.AddColumn<DateTime>( | |
| 127 | + name: "CreatedAt", | |
| 128 | + table: "SplitPresets", | |
| 129 | + type: "timestamp with time zone", | |
| 130 | + nullable: false, | |
| 131 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 132 | + | |
| 133 | + migrationBuilder.AddColumn<DateTime>( | |
| 134 | + name: "UpdatedAt", | |
| 135 | + table: "SplitPresets", | |
| 136 | + type: "timestamp with time zone", | |
| 137 | + nullable: false, | |
| 138 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 139 | + | |
| 140 | + migrationBuilder.AddColumn<DateTime>( | |
| 141 | + name: "CreatedAt", | |
| 142 | + table: "SplitPresetMembers", | |
| 143 | + type: "timestamp with time zone", | |
| 144 | + nullable: false, | |
| 145 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 146 | + | |
| 147 | + migrationBuilder.AddColumn<DateTime>( | |
| 148 | + name: "UpdatedAt", | |
| 149 | + table: "SplitPresetMembers", | |
| 150 | + type: "timestamp with time zone", | |
| 151 | + nullable: false, | |
| 152 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 153 | + | |
| 154 | + migrationBuilder.AddColumn<DateTime>( | |
| 155 | + name: "CreatedAt", | |
| 156 | + table: "SettlementPlans", | |
| 157 | + type: "timestamp with time zone", | |
| 158 | + nullable: false, | |
| 159 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 160 | + | |
| 161 | + migrationBuilder.AddColumn<DateTime>( | |
| 162 | + name: "UpdatedAt", | |
| 163 | + table: "SettlementPlans", | |
| 164 | + type: "timestamp with time zone", | |
| 165 | + nullable: false, | |
| 166 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 167 | + | |
| 168 | + migrationBuilder.AddColumn<DateTime>( | |
| 169 | + name: "CreatedAt", | |
| 170 | + table: "SettlementPayments", | |
| 171 | + type: "timestamp with time zone", | |
| 172 | + nullable: false, | |
| 173 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 174 | + | |
| 175 | + migrationBuilder.AddColumn<DateTime>( | |
| 176 | + name: "UpdatedAt", | |
| 177 | + table: "SettlementPayments", | |
| 178 | + type: "timestamp with time zone", | |
| 179 | + nullable: false, | |
| 180 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 181 | + | |
| 182 | + migrationBuilder.AddColumn<DateTime>( | |
| 183 | + name: "CreatedAt", | |
| 184 | + table: "RefreshTokens", | |
| 185 | + type: "timestamp with time zone", | |
| 186 | + nullable: false, | |
| 187 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 188 | + | |
| 189 | + migrationBuilder.AddColumn<DateTime>( | |
| 190 | + name: "UpdatedAt", | |
| 191 | + table: "RefreshTokens", | |
| 192 | + type: "timestamp with time zone", | |
| 193 | + nullable: false, | |
| 194 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 195 | + | |
| 196 | + migrationBuilder.AddColumn<DateTime>( | |
| 197 | + name: "CreatedAt", | |
| 198 | + table: "ExpenseSplits", | |
| 199 | + type: "timestamp with time zone", | |
| 200 | + nullable: false, | |
| 201 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 202 | + | |
| 203 | + migrationBuilder.AddColumn<DateTime>( | |
| 204 | + name: "UpdatedAt", | |
| 205 | + table: "ExpenseSplits", | |
| 206 | + type: "timestamp with time zone", | |
| 207 | + nullable: false, | |
| 208 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 209 | + | |
| 210 | + migrationBuilder.AddColumn<DateTime>( | |
| 211 | + name: "CreatedAt", | |
| 212 | + table: "Expenses", | |
| 213 | + type: "timestamp with time zone", | |
| 214 | + nullable: false, | |
| 215 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 216 | + | |
| 217 | + migrationBuilder.AddColumn<DateTime>( | |
| 218 | + name: "UpdatedAt", | |
| 219 | + table: "Expenses", | |
| 220 | + type: "timestamp with time zone", | |
| 221 | + nullable: false, | |
| 222 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 223 | + | |
| 224 | + migrationBuilder.AddColumn<DateTime>( | |
| 225 | + name: "CreatedAt", | |
| 226 | + table: "Currencies", | |
| 227 | + type: "timestamp with time zone", | |
| 228 | + nullable: false, | |
| 229 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 230 | + | |
| 231 | + migrationBuilder.AddColumn<DateTime>( | |
| 232 | + name: "UpdatedAt", | |
| 233 | + table: "Currencies", | |
| 234 | + type: "timestamp with time zone", | |
| 235 | + nullable: false, | |
| 236 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 237 | + | |
| 238 | + migrationBuilder.AddColumn<DateTime>( | |
| 239 | + name: "CreatedAt", | |
| 240 | + table: "BudgetCategoryTranslations", | |
| 241 | + type: "timestamp with time zone", | |
| 242 | + nullable: false, | |
| 243 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 244 | + | |
| 245 | + migrationBuilder.AddColumn<DateTime>( | |
| 246 | + name: "UpdatedAt", | |
| 247 | + table: "BudgetCategoryTranslations", | |
| 248 | + type: "timestamp with time zone", | |
| 249 | + nullable: false, | |
| 250 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 251 | + | |
| 252 | + migrationBuilder.AddColumn<DateTime>( | |
| 253 | + name: "CreatedAt", | |
| 254 | + table: "BudgetCategories", | |
| 255 | + type: "timestamp with time zone", | |
| 256 | + nullable: false, | |
| 257 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 258 | + | |
| 259 | + migrationBuilder.AddColumn<DateTime>( | |
| 260 | + name: "UpdatedAt", | |
| 261 | + table: "BudgetCategories", | |
| 262 | + type: "timestamp with time zone", | |
| 263 | + nullable: false, | |
| 264 | + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); | |
| 265 | + } | |
| 266 | + | |
| 267 | + /// <inheritdoc /> | |
| 268 | + protected override void Down(MigrationBuilder migrationBuilder) | |
| 269 | + { | |
| 270 | + migrationBuilder.DropColumn( | |
| 271 | + name: "CreatedAt", | |
| 272 | + table: "TripWishlistVotes"); | |
| 273 | + | |
| 274 | + migrationBuilder.DropColumn( | |
| 275 | + name: "UpdatedAt", | |
| 276 | + table: "TripWishlistVotes"); | |
| 277 | + | |
| 278 | + migrationBuilder.DropColumn( | |
| 279 | + name: "CreatedAt", | |
| 280 | + table: "TripWishlistItems"); | |
| 281 | + | |
| 282 | + migrationBuilder.DropColumn( | |
| 283 | + name: "UpdatedAt", | |
| 284 | + table: "TripWishlistItems"); | |
| 285 | + | |
| 286 | + migrationBuilder.DropColumn( | |
| 287 | + name: "CreatedAt", | |
| 288 | + table: "Trips"); | |
| 289 | + | |
| 290 | + migrationBuilder.DropColumn( | |
| 291 | + name: "UpdatedAt", | |
| 292 | + table: "Trips"); | |
| 293 | + | |
| 294 | + migrationBuilder.DropColumn( | |
| 295 | + name: "CreatedAt", | |
| 296 | + table: "TripPollVotes"); | |
| 297 | + | |
| 298 | + migrationBuilder.DropColumn( | |
| 299 | + name: "UpdatedAt", | |
| 300 | + table: "TripPollVotes"); | |
| 301 | + | |
| 302 | + migrationBuilder.DropColumn( | |
| 303 | + name: "CreatedAt", | |
| 304 | + table: "TripPolls"); | |
| 305 | + | |
| 306 | + migrationBuilder.DropColumn( | |
| 307 | + name: "UpdatedAt", | |
| 308 | + table: "TripPolls"); | |
| 309 | + | |
| 310 | + migrationBuilder.DropColumn( | |
| 311 | + name: "CreatedAt", | |
| 312 | + table: "TripPollOptions"); | |
| 313 | + | |
| 314 | + migrationBuilder.DropColumn( | |
| 315 | + name: "UpdatedAt", | |
| 316 | + table: "TripPollOptions"); | |
| 317 | + | |
| 318 | + migrationBuilder.DropColumn( | |
| 319 | + name: "CreatedAt", | |
| 320 | + table: "TripParticipants"); | |
| 321 | + | |
| 322 | + migrationBuilder.DropColumn( | |
| 323 | + name: "UpdatedAt", | |
| 324 | + table: "TripParticipants"); | |
| 325 | + | |
| 326 | + migrationBuilder.DropColumn( | |
| 327 | + name: "CreatedAt", | |
| 328 | + table: "TripInvitations"); | |
| 329 | + | |
| 330 | + migrationBuilder.DropColumn( | |
| 331 | + name: "UpdatedAt", | |
| 332 | + table: "TripInvitations"); | |
| 333 | + | |
| 334 | + migrationBuilder.DropColumn( | |
| 335 | + name: "CreatedAt", | |
| 336 | + table: "SplitPresets"); | |
| 337 | + | |
| 338 | + migrationBuilder.DropColumn( | |
| 339 | + name: "UpdatedAt", | |
| 340 | + table: "SplitPresets"); | |
| 341 | + | |
| 342 | + migrationBuilder.DropColumn( | |
| 343 | + name: "CreatedAt", | |
| 344 | + table: "SplitPresetMembers"); | |
| 345 | + | |
| 346 | + migrationBuilder.DropColumn( | |
| 347 | + name: "UpdatedAt", | |
| 348 | + table: "SplitPresetMembers"); | |
| 349 | + | |
| 350 | + migrationBuilder.DropColumn( | |
| 351 | + name: "CreatedAt", | |
| 352 | + table: "SettlementPlans"); | |
| 353 | + | |
| 354 | + migrationBuilder.DropColumn( | |
| 355 | + name: "UpdatedAt", | |
| 356 | + table: "SettlementPlans"); | |
| 357 | + | |
| 358 | + migrationBuilder.DropColumn( | |
| 359 | + name: "CreatedAt", | |
| 360 | + table: "SettlementPayments"); | |
| 361 | + | |
| 362 | + migrationBuilder.DropColumn( | |
| 363 | + name: "UpdatedAt", | |
| 364 | + table: "SettlementPayments"); | |
| 365 | + | |
| 366 | + migrationBuilder.DropColumn( | |
| 367 | + name: "CreatedAt", | |
| 368 | + table: "RefreshTokens"); | |
| 369 | + | |
| 370 | + migrationBuilder.DropColumn( | |
| 371 | + name: "UpdatedAt", | |
| 372 | + table: "RefreshTokens"); | |
| 373 | + | |
| 374 | + migrationBuilder.DropColumn( | |
| 375 | + name: "CreatedAt", | |
| 376 | + table: "ExpenseSplits"); | |
| 377 | + | |
| 378 | + migrationBuilder.DropColumn( | |
| 379 | + name: "UpdatedAt", | |
| 380 | + table: "ExpenseSplits"); | |
| 381 | + | |
| 382 | + migrationBuilder.DropColumn( | |
| 383 | + name: "CreatedAt", | |
| 384 | + table: "Expenses"); | |
| 385 | + | |
| 386 | + migrationBuilder.DropColumn( | |
| 387 | + name: "UpdatedAt", | |
| 388 | + table: "Expenses"); | |
| 389 | + | |
| 390 | + migrationBuilder.DropColumn( | |
| 391 | + name: "CreatedAt", | |
| 392 | + table: "Currencies"); | |
| 393 | + | |
| 394 | + migrationBuilder.DropColumn( | |
| 395 | + name: "UpdatedAt", | |
| 396 | + table: "Currencies"); | |
| 397 | + | |
| 398 | + migrationBuilder.DropColumn( | |
| 399 | + name: "CreatedAt", | |
| 400 | + table: "BudgetCategoryTranslations"); | |
| 401 | + | |
| 402 | + migrationBuilder.DropColumn( | |
| 403 | + name: "UpdatedAt", | |
| 404 | + table: "BudgetCategoryTranslations"); | |
| 405 | + | |
| 406 | + migrationBuilder.DropColumn( | |
| 407 | + name: "CreatedAt", | |
| 408 | + table: "BudgetCategories"); | |
| 409 | + | |
| 410 | + migrationBuilder.DropColumn( | |
| 411 | + name: "UpdatedAt", | |
| 412 | + table: "BudgetCategories"); | |
| 413 | + } | |
| 414 | + } | |
| 415 | +} |
added SplitApp/App.DAL.EF/Migrations/20260329141138_CurrencyNameToLangStr.Designer.cs +1375 −0
| @@ -0,0 +1,1375 @@ | ||
| 1 | +// <auto-generated /> | |
| 2 | +using System; | |
| 3 | +using App.DAL.EF; | |
| 4 | +using Microsoft.EntityFrameworkCore; | |
| 5 | +using Microsoft.EntityFrameworkCore.Infrastructure; | |
| 6 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 7 | +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | |
| 8 | +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | |
| 9 | + | |
| 10 | +#nullable disable | |
| 11 | + | |
| 12 | +namespace App.DAL.EF.Migrations | |
| 13 | +{ | |
| 14 | + [DbContext(typeof(AppDbContext))] | |
| 15 | + [Migration("20260329141138_CurrencyNameToLangStr")] | |
| 16 | + partial class CurrencyNameToLangStr | |
| 17 | + { | |
| 18 | + /// <inheritdoc /> | |
| 19 | + protected override void BuildTargetModel(ModelBuilder modelBuilder) | |
| 20 | + { | |
| 21 | +#pragma warning disable 612, 618 | |
| 22 | + modelBuilder | |
| 23 | + .HasAnnotation("ProductVersion", "10.0.5") | |
| 24 | + .HasAnnotation("Relational:MaxIdentifierLength", 63); | |
| 25 | + | |
| 26 | + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | |
| 27 | + | |
| 28 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 29 | + { | |
| 30 | + b.Property<Guid>("Id") | |
| 31 | + .ValueGeneratedOnAdd() | |
| 32 | + .HasColumnType("uuid"); | |
| 33 | + | |
| 34 | + b.Property<DateTime>("CreatedAt") | |
| 35 | + .HasColumnType("timestamp with time zone"); | |
| 36 | + | |
| 37 | + b.Property<int>("DisplayOrder") | |
| 38 | + .HasColumnType("integer"); | |
| 39 | + | |
| 40 | + b.Property<string>("IconName") | |
| 41 | + .HasMaxLength(100) | |
| 42 | + .HasColumnType("character varying(100)"); | |
| 43 | + | |
| 44 | + b.Property<string>("Name") | |
| 45 | + .IsRequired() | |
| 46 | + .HasMaxLength(100) | |
| 47 | + .HasColumnType("character varying(100)"); | |
| 48 | + | |
| 49 | + b.Property<decimal?>("PlannedAmount") | |
| 50 | + .HasColumnType("numeric"); | |
| 51 | + | |
| 52 | + b.Property<Guid>("TripId") | |
| 53 | + .HasColumnType("uuid"); | |
| 54 | + | |
| 55 | + b.Property<DateTime>("UpdatedAt") | |
| 56 | + .HasColumnType("timestamp with time zone"); | |
| 57 | + | |
| 58 | + b.HasKey("Id"); | |
| 59 | + | |
| 60 | + b.HasIndex("TripId"); | |
| 61 | + | |
| 62 | + b.ToTable("BudgetCategories"); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + modelBuilder.Entity("App.Domain.BudgetCategoryTranslation", b => | |
| 66 | + { | |
| 67 | + b.Property<Guid>("Id") | |
| 68 | + .ValueGeneratedOnAdd() | |
| 69 | + .HasColumnType("uuid"); | |
| 70 | + | |
| 71 | + b.Property<Guid>("BudgetCategoryId") | |
| 72 | + .HasColumnType("uuid"); | |
| 73 | + | |
| 74 | + b.Property<DateTime>("CreatedAt") | |
| 75 | + .HasColumnType("timestamp with time zone"); | |
| 76 | + | |
| 77 | + b.Property<string>("Culture") | |
| 78 | + .IsRequired() | |
| 79 | + .HasMaxLength(5) | |
| 80 | + .HasColumnType("character varying(5)"); | |
| 81 | + | |
| 82 | + b.Property<string>("Name") | |
| 83 | + .IsRequired() | |
| 84 | + .HasMaxLength(100) | |
| 85 | + .HasColumnType("character varying(100)"); | |
| 86 | + | |
| 87 | + b.Property<DateTime>("UpdatedAt") | |
| 88 | + .HasColumnType("timestamp with time zone"); | |
| 89 | + | |
| 90 | + b.HasKey("Id"); | |
| 91 | + | |
| 92 | + b.HasIndex("BudgetCategoryId"); | |
| 93 | + | |
| 94 | + b.ToTable("BudgetCategoryTranslations"); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + modelBuilder.Entity("App.Domain.Currency", b => | |
| 98 | + { | |
| 99 | + b.Property<Guid>("Id") | |
| 100 | + .ValueGeneratedOnAdd() | |
| 101 | + .HasColumnType("uuid"); | |
| 102 | + | |
| 103 | + b.Property<string>("Code") | |
| 104 | + .IsRequired() | |
| 105 | + .HasMaxLength(3) | |
| 106 | + .HasColumnType("character varying(3)"); | |
| 107 | + | |
| 108 | + b.Property<DateTime>("CreatedAt") | |
| 109 | + .HasColumnType("timestamp with time zone"); | |
| 110 | + | |
| 111 | + b.Property<string>("Name") | |
| 112 | + .IsRequired() | |
| 113 | + .HasMaxLength(1024) | |
| 114 | + .HasColumnType("character varying(1024)"); | |
| 115 | + | |
| 116 | + b.Property<string>("Symbol") | |
| 117 | + .IsRequired() | |
| 118 | + .HasMaxLength(10) | |
| 119 | + .HasColumnType("character varying(10)"); | |
| 120 | + | |
| 121 | + b.Property<DateTime>("UpdatedAt") | |
| 122 | + .HasColumnType("timestamp with time zone"); | |
| 123 | + | |
| 124 | + b.HasKey("Id"); | |
| 125 | + | |
| 126 | + b.ToTable("Currencies"); | |
| 127 | + }); | |
| 128 | + | |
| 129 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 130 | + { | |
| 131 | + b.Property<Guid>("Id") | |
| 132 | + .ValueGeneratedOnAdd() | |
| 133 | + .HasColumnType("uuid"); | |
| 134 | + | |
| 135 | + b.Property<decimal>("Amount") | |
| 136 | + .HasColumnType("numeric"); | |
| 137 | + | |
| 138 | + b.Property<Guid?>("BudgetCategoryId") | |
| 139 | + .HasColumnType("uuid"); | |
| 140 | + | |
| 141 | + b.Property<DateTime>("CreatedAt") | |
| 142 | + .HasColumnType("timestamp with time zone"); | |
| 143 | + | |
| 144 | + b.Property<Guid?>("CurrencyId") | |
| 145 | + .HasColumnType("uuid"); | |
| 146 | + | |
| 147 | + b.Property<string>("Description") | |
| 148 | + .HasMaxLength(500) | |
| 149 | + .HasColumnType("character varying(500)"); | |
| 150 | + | |
| 151 | + b.Property<DateTime>("ExpenseDate") | |
| 152 | + .HasColumnType("timestamp with time zone"); | |
| 153 | + | |
| 154 | + b.Property<Guid>("PaidByUserId") | |
| 155 | + .HasColumnType("uuid"); | |
| 156 | + | |
| 157 | + b.Property<int>("SplitMethod") | |
| 158 | + .HasColumnType("integer"); | |
| 159 | + | |
| 160 | + b.Property<Guid>("TripId") | |
| 161 | + .HasColumnType("uuid"); | |
| 162 | + | |
| 163 | + b.Property<DateTime>("UpdatedAt") | |
| 164 | + .HasColumnType("timestamp with time zone"); | |
| 165 | + | |
| 166 | + b.HasKey("Id"); | |
| 167 | + | |
| 168 | + b.HasIndex("BudgetCategoryId"); | |
| 169 | + | |
| 170 | + b.HasIndex("CurrencyId"); | |
| 171 | + | |
| 172 | + b.HasIndex("PaidByUserId"); | |
| 173 | + | |
| 174 | + b.HasIndex("TripId"); | |
| 175 | + | |
| 176 | + b.ToTable("Expenses"); | |
| 177 | + }); | |
| 178 | + | |
| 179 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 180 | + { | |
| 181 | + b.Property<Guid>("Id") | |
| 182 | + .ValueGeneratedOnAdd() | |
| 183 | + .HasColumnType("uuid"); | |
| 184 | + | |
| 185 | + b.Property<decimal>("Amount") | |
| 186 | + .HasColumnType("numeric"); | |
| 187 | + | |
| 188 | + b.Property<DateTime>("CreatedAt") | |
| 189 | + .HasColumnType("timestamp with time zone"); | |
| 190 | + | |
| 191 | + b.Property<Guid>("ExpenseId") | |
| 192 | + .HasColumnType("uuid"); | |
| 193 | + | |
| 194 | + b.Property<decimal?>("Percentage") | |
| 195 | + .HasColumnType("numeric"); | |
| 196 | + | |
| 197 | + b.Property<DateTime>("UpdatedAt") | |
| 198 | + .HasColumnType("timestamp with time zone"); | |
| 199 | + | |
| 200 | + b.Property<Guid>("UserId") | |
| 201 | + .HasColumnType("uuid"); | |
| 202 | + | |
| 203 | + b.HasKey("Id"); | |
| 204 | + | |
| 205 | + b.HasIndex("ExpenseId"); | |
| 206 | + | |
| 207 | + b.HasIndex("UserId"); | |
| 208 | + | |
| 209 | + b.ToTable("ExpenseSplits"); | |
| 210 | + }); | |
| 211 | + | |
| 212 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 213 | + { | |
| 214 | + b.Property<Guid>("Id") | |
| 215 | + .ValueGeneratedOnAdd() | |
| 216 | + .HasColumnType("uuid"); | |
| 217 | + | |
| 218 | + b.Property<Guid>("AppUserId") | |
| 219 | + .HasColumnType("uuid"); | |
| 220 | + | |
| 221 | + b.Property<DateTime>("CreatedAt") | |
| 222 | + .HasColumnType("timestamp with time zone"); | |
| 223 | + | |
| 224 | + b.Property<DateTime>("ExpirationDT") | |
| 225 | + .HasColumnType("timestamp with time zone"); | |
| 226 | + | |
| 227 | + b.Property<DateTime>("PreviousExpirationDT") | |
| 228 | + .HasColumnType("timestamp with time zone"); | |
| 229 | + | |
| 230 | + b.Property<string>("PreviousRefreshToken") | |
| 231 | + .HasMaxLength(64) | |
| 232 | + .HasColumnType("character varying(64)"); | |
| 233 | + | |
| 234 | + b.Property<string>("RefreshToken") | |
| 235 | + .IsRequired() | |
| 236 | + .HasMaxLength(64) | |
| 237 | + .HasColumnType("character varying(64)"); | |
| 238 | + | |
| 239 | + b.Property<DateTime>("UpdatedAt") | |
| 240 | + .HasColumnType("timestamp with time zone"); | |
| 241 | + | |
| 242 | + b.HasKey("Id"); | |
| 243 | + | |
| 244 | + b.HasIndex("AppUserId"); | |
| 245 | + | |
| 246 | + b.ToTable("RefreshTokens"); | |
| 247 | + }); | |
| 248 | + | |
| 249 | + modelBuilder.Entity("App.Domain.Identity.AppRole", b => | |
| 250 | + { | |
| 251 | + b.Property<Guid>("Id") | |
| 252 | + .ValueGeneratedOnAdd() | |
| 253 | + .HasColumnType("uuid"); | |
| 254 | + | |
| 255 | + b.Property<string>("ConcurrencyStamp") | |
| 256 | + .IsConcurrencyToken() | |
| 257 | + .HasColumnType("text"); | |
| 258 | + | |
| 259 | + b.Property<string>("Name") | |
| 260 | + .HasMaxLength(256) | |
| 261 | + .HasColumnType("character varying(256)"); | |
| 262 | + | |
| 263 | + b.Property<string>("NormalizedName") | |
| 264 | + .HasMaxLength(256) | |
| 265 | + .HasColumnType("character varying(256)"); | |
| 266 | + | |
| 267 | + b.HasKey("Id"); | |
| 268 | + | |
| 269 | + b.HasIndex("NormalizedName") | |
| 270 | + .IsUnique() | |
| 271 | + .HasDatabaseName("RoleNameIndex"); | |
| 272 | + | |
| 273 | + b.ToTable("AspNetRoles", (string)null); | |
| 274 | + }); | |
| 275 | + | |
| 276 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 277 | + { | |
| 278 | + b.Property<Guid>("Id") | |
| 279 | + .ValueGeneratedOnAdd() | |
| 280 | + .HasColumnType("uuid"); | |
| 281 | + | |
| 282 | + b.Property<int>("AccessFailedCount") | |
| 283 | + .HasColumnType("integer"); | |
| 284 | + | |
| 285 | + b.Property<string>("ConcurrencyStamp") | |
| 286 | + .IsConcurrencyToken() | |
| 287 | + .HasColumnType("text"); | |
| 288 | + | |
| 289 | + b.Property<string>("Email") | |
| 290 | + .HasMaxLength(256) | |
| 291 | + .HasColumnType("character varying(256)"); | |
| 292 | + | |
| 293 | + b.Property<bool>("EmailConfirmed") | |
| 294 | + .HasColumnType("boolean"); | |
| 295 | + | |
| 296 | + b.Property<string>("FirstName") | |
| 297 | + .IsRequired() | |
| 298 | + .HasMaxLength(128) | |
| 299 | + .HasColumnType("character varying(128)"); | |
| 300 | + | |
| 301 | + b.Property<string>("LastName") | |
| 302 | + .IsRequired() | |
| 303 | + .HasMaxLength(128) | |
| 304 | + .HasColumnType("character varying(128)"); | |
| 305 | + | |
| 306 | + b.Property<bool>("LockoutEnabled") | |
| 307 | + .HasColumnType("boolean"); | |
| 308 | + | |
| 309 | + b.Property<DateTimeOffset?>("LockoutEnd") | |
| 310 | + .HasColumnType("timestamp with time zone"); | |
| 311 | + | |
| 312 | + b.Property<string>("NormalizedEmail") | |
| 313 | + .HasMaxLength(256) | |
| 314 | + .HasColumnType("character varying(256)"); | |
| 315 | + | |
| 316 | + b.Property<string>("NormalizedUserName") | |
| 317 | + .HasMaxLength(256) | |
| 318 | + .HasColumnType("character varying(256)"); | |
| 319 | + | |
| 320 | + b.Property<string>("PasswordHash") | |
| 321 | + .HasColumnType("text"); | |
| 322 | + | |
| 323 | + b.Property<string>("PhoneNumber") | |
| 324 | + .HasColumnType("text"); | |
| 325 | + | |
| 326 | + b.Property<bool>("PhoneNumberConfirmed") | |
| 327 | + .HasColumnType("boolean"); | |
| 328 | + | |
| 329 | + b.Property<string>("SecurityStamp") | |
| 330 | + .HasColumnType("text"); | |
| 331 | + | |
| 332 | + b.Property<bool>("TwoFactorEnabled") | |
| 333 | + .HasColumnType("boolean"); | |
| 334 | + | |
| 335 | + b.Property<string>("UserName") | |
| 336 | + .HasMaxLength(256) | |
| 337 | + .HasColumnType("character varying(256)"); | |
| 338 | + | |
| 339 | + b.HasKey("Id"); | |
| 340 | + | |
| 341 | + b.HasIndex("NormalizedEmail") | |
| 342 | + .HasDatabaseName("EmailIndex"); | |
| 343 | + | |
| 344 | + b.HasIndex("NormalizedUserName") | |
| 345 | + .IsUnique() | |
| 346 | + .HasDatabaseName("UserNameIndex"); | |
| 347 | + | |
| 348 | + b.ToTable("AspNetUsers", (string)null); | |
| 349 | + }); | |
| 350 | + | |
| 351 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 352 | + { | |
| 353 | + b.Property<Guid>("Id") | |
| 354 | + .ValueGeneratedOnAdd() | |
| 355 | + .HasColumnType("uuid"); | |
| 356 | + | |
| 357 | + b.Property<decimal>("Amount") | |
| 358 | + .HasColumnType("numeric"); | |
| 359 | + | |
| 360 | + b.Property<DateTime?>("ConfirmedAt") | |
| 361 | + .HasColumnType("timestamp with time zone"); | |
| 362 | + | |
| 363 | + b.Property<DateTime>("CreatedAt") | |
| 364 | + .HasColumnType("timestamp with time zone"); | |
| 365 | + | |
| 366 | + b.Property<Guid>("FromUserId") | |
| 367 | + .HasColumnType("uuid"); | |
| 368 | + | |
| 369 | + b.Property<DateTime?>("MarkedPaidAt") | |
| 370 | + .HasColumnType("timestamp with time zone"); | |
| 371 | + | |
| 372 | + b.Property<Guid>("SettlementPlanId") | |
| 373 | + .HasColumnType("uuid"); | |
| 374 | + | |
| 375 | + b.Property<int>("Status") | |
| 376 | + .HasColumnType("integer"); | |
| 377 | + | |
| 378 | + b.Property<Guid>("ToUserId") | |
| 379 | + .HasColumnType("uuid"); | |
| 380 | + | |
| 381 | + b.Property<DateTime>("UpdatedAt") | |
| 382 | + .HasColumnType("timestamp with time zone"); | |
| 383 | + | |
| 384 | + b.HasKey("Id"); | |
| 385 | + | |
| 386 | + b.HasIndex("FromUserId"); | |
| 387 | + | |
| 388 | + b.HasIndex("SettlementPlanId"); | |
| 389 | + | |
| 390 | + b.HasIndex("ToUserId"); | |
| 391 | + | |
| 392 | + b.ToTable("SettlementPayments"); | |
| 393 | + }); | |
| 394 | + | |
| 395 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 396 | + { | |
| 397 | + b.Property<Guid>("Id") | |
| 398 | + .ValueGeneratedOnAdd() | |
| 399 | + .HasColumnType("uuid"); | |
| 400 | + | |
| 401 | + b.Property<DateTime?>("CompletedAt") | |
| 402 | + .HasColumnType("timestamp with time zone"); | |
| 403 | + | |
| 404 | + b.Property<DateTime>("CreatedAt") | |
| 405 | + .HasColumnType("timestamp with time zone"); | |
| 406 | + | |
| 407 | + b.Property<Guid>("CreatedByUserId") | |
| 408 | + .HasColumnType("uuid"); | |
| 409 | + | |
| 410 | + b.Property<int>("Status") | |
| 411 | + .HasColumnType("integer"); | |
| 412 | + | |
| 413 | + b.Property<decimal>("TotalAmount") | |
| 414 | + .HasColumnType("numeric"); | |
| 415 | + | |
| 416 | + b.Property<Guid>("TripId") | |
| 417 | + .HasColumnType("uuid"); | |
| 418 | + | |
| 419 | + b.Property<DateTime>("UpdatedAt") | |
| 420 | + .HasColumnType("timestamp with time zone"); | |
| 421 | + | |
| 422 | + b.HasKey("Id"); | |
| 423 | + | |
| 424 | + b.HasIndex("CreatedByUserId"); | |
| 425 | + | |
| 426 | + b.HasIndex("TripId"); | |
| 427 | + | |
| 428 | + b.ToTable("SettlementPlans"); | |
| 429 | + }); | |
| 430 | + | |
| 431 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 432 | + { | |
| 433 | + b.Property<Guid>("Id") | |
| 434 | + .ValueGeneratedOnAdd() | |
| 435 | + .HasColumnType("uuid"); | |
| 436 | + | |
| 437 | + b.Property<DateTime>("CreatedAt") | |
| 438 | + .HasColumnType("timestamp with time zone"); | |
| 439 | + | |
| 440 | + b.Property<Guid>("CreatedById") | |
| 441 | + .HasColumnType("uuid"); | |
| 442 | + | |
| 443 | + b.Property<string>("Name") | |
| 444 | + .IsRequired() | |
| 445 | + .HasMaxLength(200) | |
| 446 | + .HasColumnType("character varying(200)"); | |
| 447 | + | |
| 448 | + b.Property<int>("SplitMethod") | |
| 449 | + .HasColumnType("integer"); | |
| 450 | + | |
| 451 | + b.Property<Guid>("TripId") | |
| 452 | + .HasColumnType("uuid"); | |
| 453 | + | |
| 454 | + b.Property<DateTime>("UpdatedAt") | |
| 455 | + .HasColumnType("timestamp with time zone"); | |
| 456 | + | |
| 457 | + b.HasKey("Id"); | |
| 458 | + | |
| 459 | + b.HasIndex("CreatedById"); | |
| 460 | + | |
| 461 | + b.HasIndex("TripId"); | |
| 462 | + | |
| 463 | + b.ToTable("SplitPresets"); | |
| 464 | + }); | |
| 465 | + | |
| 466 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 467 | + { | |
| 468 | + b.Property<Guid>("Id") | |
| 469 | + .ValueGeneratedOnAdd() | |
| 470 | + .HasColumnType("uuid"); | |
| 471 | + | |
| 472 | + b.Property<DateTime>("CreatedAt") | |
| 473 | + .HasColumnType("timestamp with time zone"); | |
| 474 | + | |
| 475 | + b.Property<decimal?>("Percentage") | |
| 476 | + .HasColumnType("numeric"); | |
| 477 | + | |
| 478 | + b.Property<decimal?>("ShareWeight") | |
| 479 | + .HasColumnType("numeric"); | |
| 480 | + | |
| 481 | + b.Property<Guid>("SplitPresetId") | |
| 482 | + .HasColumnType("uuid"); | |
| 483 | + | |
| 484 | + b.Property<DateTime>("UpdatedAt") | |
| 485 | + .HasColumnType("timestamp with time zone"); | |
| 486 | + | |
| 487 | + b.Property<Guid>("UserId") | |
| 488 | + .HasColumnType("uuid"); | |
| 489 | + | |
| 490 | + b.HasKey("Id"); | |
| 491 | + | |
| 492 | + b.HasIndex("SplitPresetId"); | |
| 493 | + | |
| 494 | + b.HasIndex("UserId"); | |
| 495 | + | |
| 496 | + b.ToTable("SplitPresetMembers"); | |
| 497 | + }); | |
| 498 | + | |
| 499 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 500 | + { | |
| 501 | + b.Property<Guid>("Id") | |
| 502 | + .ValueGeneratedOnAdd() | |
| 503 | + .HasColumnType("uuid"); | |
| 504 | + | |
| 505 | + b.Property<DateTime>("CreatedAt") | |
| 506 | + .HasColumnType("timestamp with time zone"); | |
| 507 | + | |
| 508 | + b.Property<Guid>("CreatedById") | |
| 509 | + .HasColumnType("uuid"); | |
| 510 | + | |
| 511 | + b.Property<Guid>("DefaultCurrencyId") | |
| 512 | + .HasColumnType("uuid"); | |
| 513 | + | |
| 514 | + b.Property<string>("Description") | |
| 515 | + .HasColumnType("text"); | |
| 516 | + | |
| 517 | + b.Property<string>("Destination") | |
| 518 | + .HasMaxLength(200) | |
| 519 | + .HasColumnType("character varying(200)"); | |
| 520 | + | |
| 521 | + b.Property<DateTime?>("EndDate") | |
| 522 | + .HasColumnType("timestamp with time zone"); | |
| 523 | + | |
| 524 | + b.Property<string>("Name") | |
| 525 | + .IsRequired() | |
| 526 | + .HasMaxLength(200) | |
| 527 | + .HasColumnType("character varying(200)"); | |
| 528 | + | |
| 529 | + b.Property<DateTime?>("StartDate") | |
| 530 | + .HasColumnType("timestamp with time zone"); | |
| 531 | + | |
| 532 | + b.Property<int>("Status") | |
| 533 | + .HasColumnType("integer"); | |
| 534 | + | |
| 535 | + b.Property<DateTime>("UpdatedAt") | |
| 536 | + .HasColumnType("timestamp with time zone"); | |
| 537 | + | |
| 538 | + b.HasKey("Id"); | |
| 539 | + | |
| 540 | + b.HasIndex("CreatedById"); | |
| 541 | + | |
| 542 | + b.HasIndex("DefaultCurrencyId"); | |
| 543 | + | |
| 544 | + b.ToTable("Trips"); | |
| 545 | + }); | |
| 546 | + | |
| 547 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 548 | + { | |
| 549 | + b.Property<Guid>("Id") | |
| 550 | + .ValueGeneratedOnAdd() | |
| 551 | + .HasColumnType("uuid"); | |
| 552 | + | |
| 553 | + b.Property<DateTime>("CreatedAt") | |
| 554 | + .HasColumnType("timestamp with time zone"); | |
| 555 | + | |
| 556 | + b.Property<DateTime>("ExpiresAt") | |
| 557 | + .HasColumnType("timestamp with time zone"); | |
| 558 | + | |
| 559 | + b.Property<Guid>("InvitedByUserId") | |
| 560 | + .HasColumnType("uuid"); | |
| 561 | + | |
| 562 | + b.Property<DateTime?>("RespondedAt") | |
| 563 | + .HasColumnType("timestamp with time zone"); | |
| 564 | + | |
| 565 | + b.Property<int>("Status") | |
| 566 | + .HasColumnType("integer"); | |
| 567 | + | |
| 568 | + b.Property<string>("Token") | |
| 569 | + .IsRequired() | |
| 570 | + .HasMaxLength(256) | |
| 571 | + .HasColumnType("character varying(256)"); | |
| 572 | + | |
| 573 | + b.Property<Guid>("TripId") | |
| 574 | + .HasColumnType("uuid"); | |
| 575 | + | |
| 576 | + b.Property<DateTime>("UpdatedAt") | |
| 577 | + .HasColumnType("timestamp with time zone"); | |
| 578 | + | |
| 579 | + b.HasKey("Id"); | |
| 580 | + | |
| 581 | + b.HasIndex("InvitedByUserId"); | |
| 582 | + | |
| 583 | + b.HasIndex("Token") | |
| 584 | + .IsUnique(); | |
| 585 | + | |
| 586 | + b.HasIndex("TripId"); | |
| 587 | + | |
| 588 | + b.ToTable("TripInvitations"); | |
| 589 | + }); | |
| 590 | + | |
| 591 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 592 | + { | |
| 593 | + b.Property<Guid>("Id") | |
| 594 | + .ValueGeneratedOnAdd() | |
| 595 | + .HasColumnType("uuid"); | |
| 596 | + | |
| 597 | + b.Property<DateTime>("CreatedAt") | |
| 598 | + .HasColumnType("timestamp with time zone"); | |
| 599 | + | |
| 600 | + b.Property<bool>("IsActive") | |
| 601 | + .HasColumnType("boolean"); | |
| 602 | + | |
| 603 | + b.Property<DateTime>("JoinedAt") | |
| 604 | + .HasColumnType("timestamp with time zone"); | |
| 605 | + | |
| 606 | + b.Property<DateTime?>("LeftAt") | |
| 607 | + .HasColumnType("timestamp with time zone"); | |
| 608 | + | |
| 609 | + b.Property<string>("Nickname") | |
| 610 | + .HasMaxLength(100) | |
| 611 | + .HasColumnType("character varying(100)"); | |
| 612 | + | |
| 613 | + b.Property<int>("Role") | |
| 614 | + .HasColumnType("integer"); | |
| 615 | + | |
| 616 | + b.Property<Guid>("TripId") | |
| 617 | + .HasColumnType("uuid"); | |
| 618 | + | |
| 619 | + b.Property<DateTime>("UpdatedAt") | |
| 620 | + .HasColumnType("timestamp with time zone"); | |
| 621 | + | |
| 622 | + b.Property<Guid>("UserId") | |
| 623 | + .HasColumnType("uuid"); | |
| 624 | + | |
| 625 | + b.HasKey("Id"); | |
| 626 | + | |
| 627 | + b.HasIndex("UserId"); | |
| 628 | + | |
| 629 | + b.HasIndex("TripId", "UserId") | |
| 630 | + .IsUnique(); | |
| 631 | + | |
| 632 | + b.ToTable("TripParticipants"); | |
| 633 | + }); | |
| 634 | + | |
| 635 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 636 | + { | |
| 637 | + b.Property<Guid>("Id") | |
| 638 | + .ValueGeneratedOnAdd() | |
| 639 | + .HasColumnType("uuid"); | |
| 640 | + | |
| 641 | + b.Property<bool>("AllowMultipleVotes") | |
| 642 | + .HasColumnType("boolean"); | |
| 643 | + | |
| 644 | + b.Property<DateTime?>("ClosedAt") | |
| 645 | + .HasColumnType("timestamp with time zone"); | |
| 646 | + | |
| 647 | + b.Property<DateTime>("CreatedAt") | |
| 648 | + .HasColumnType("timestamp with time zone"); | |
| 649 | + | |
| 650 | + b.Property<Guid>("CreatedByUserId") | |
| 651 | + .HasColumnType("uuid"); | |
| 652 | + | |
| 653 | + b.Property<bool>("IsAnonymous") | |
| 654 | + .HasColumnType("boolean"); | |
| 655 | + | |
| 656 | + b.Property<string>("Question") | |
| 657 | + .IsRequired() | |
| 658 | + .HasMaxLength(500) | |
| 659 | + .HasColumnType("character varying(500)"); | |
| 660 | + | |
| 661 | + b.Property<Guid>("TripId") | |
| 662 | + .HasColumnType("uuid"); | |
| 663 | + | |
| 664 | + b.Property<DateTime>("UpdatedAt") | |
| 665 | + .HasColumnType("timestamp with time zone"); | |
| 666 | + | |
| 667 | + b.HasKey("Id"); | |
| 668 | + | |
| 669 | + b.HasIndex("CreatedByUserId"); | |
| 670 | + | |
| 671 | + b.HasIndex("TripId"); | |
| 672 | + | |
| 673 | + b.ToTable("TripPolls"); | |
| 674 | + }); | |
| 675 | + | |
| 676 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 677 | + { | |
| 678 | + b.Property<Guid>("Id") | |
| 679 | + .ValueGeneratedOnAdd() | |
| 680 | + .HasColumnType("uuid"); | |
| 681 | + | |
| 682 | + b.Property<DateTime>("CreatedAt") | |
| 683 | + .HasColumnType("timestamp with time zone"); | |
| 684 | + | |
| 685 | + b.Property<int>("DisplayOrder") | |
| 686 | + .HasColumnType("integer"); | |
| 687 | + | |
| 688 | + b.Property<Guid>("PollId") | |
| 689 | + .HasColumnType("uuid"); | |
| 690 | + | |
| 691 | + b.Property<string>("Text") | |
| 692 | + .IsRequired() | |
| 693 | + .HasMaxLength(300) | |
| 694 | + .HasColumnType("character varying(300)"); | |
| 695 | + | |
| 696 | + b.Property<DateTime>("UpdatedAt") | |
| 697 | + .HasColumnType("timestamp with time zone"); | |
| 698 | + | |
| 699 | + b.HasKey("Id"); | |
| 700 | + | |
| 701 | + b.HasIndex("PollId"); | |
| 702 | + | |
| 703 | + b.ToTable("TripPollOptions"); | |
| 704 | + }); | |
| 705 | + | |
| 706 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 707 | + { | |
| 708 | + b.Property<Guid>("Id") | |
| 709 | + .ValueGeneratedOnAdd() | |
| 710 | + .HasColumnType("uuid"); | |
| 711 | + | |
| 712 | + b.Property<DateTime>("CreatedAt") | |
| 713 | + .HasColumnType("timestamp with time zone"); | |
| 714 | + | |
| 715 | + b.Property<Guid>("PollOptionId") | |
| 716 | + .HasColumnType("uuid"); | |
| 717 | + | |
| 718 | + b.Property<DateTime>("UpdatedAt") | |
| 719 | + .HasColumnType("timestamp with time zone"); | |
| 720 | + | |
| 721 | + b.Property<Guid>("UserId") | |
| 722 | + .HasColumnType("uuid"); | |
| 723 | + | |
| 724 | + b.HasKey("Id"); | |
| 725 | + | |
| 726 | + b.HasIndex("UserId"); | |
| 727 | + | |
| 728 | + b.HasIndex("PollOptionId", "UserId") | |
| 729 | + .IsUnique(); | |
| 730 | + | |
| 731 | + b.ToTable("TripPollVotes"); | |
| 732 | + }); | |
| 733 | + | |
| 734 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 735 | + { | |
| 736 | + b.Property<Guid>("Id") | |
| 737 | + .ValueGeneratedOnAdd() | |
| 738 | + .HasColumnType("uuid"); | |
| 739 | + | |
| 740 | + b.Property<Guid>("AddedByUserId") | |
| 741 | + .HasColumnType("uuid"); | |
| 742 | + | |
| 743 | + b.Property<int>("Category") | |
| 744 | + .HasColumnType("integer"); | |
| 745 | + | |
| 746 | + b.Property<DateTime?>("CompletedAt") | |
| 747 | + .HasColumnType("timestamp with time zone"); | |
| 748 | + | |
| 749 | + b.Property<DateTime>("CreatedAt") | |
| 750 | + .HasColumnType("timestamp with time zone"); | |
| 751 | + | |
| 752 | + b.Property<string>("Description") | |
| 753 | + .HasColumnType("text"); | |
| 754 | + | |
| 755 | + b.Property<int>("DisplayOrder") | |
| 756 | + .HasColumnType("integer"); | |
| 757 | + | |
| 758 | + b.Property<decimal?>("EstimatedCost") | |
| 759 | + .HasColumnType("numeric"); | |
| 760 | + | |
| 761 | + b.Property<bool>("IsCompleted") | |
| 762 | + .HasColumnType("boolean"); | |
| 763 | + | |
| 764 | + b.Property<string>("Location") | |
| 765 | + .HasMaxLength(300) | |
| 766 | + .HasColumnType("character varying(300)"); | |
| 767 | + | |
| 768 | + b.Property<int>("Priority") | |
| 769 | + .HasColumnType("integer"); | |
| 770 | + | |
| 771 | + b.Property<string>("Title") | |
| 772 | + .IsRequired() | |
| 773 | + .HasMaxLength(200) | |
| 774 | + .HasColumnType("character varying(200)"); | |
| 775 | + | |
| 776 | + b.Property<Guid>("TripId") | |
| 777 | + .HasColumnType("uuid"); | |
| 778 | + | |
| 779 | + b.Property<DateTime>("UpdatedAt") | |
| 780 | + .HasColumnType("timestamp with time zone"); | |
| 781 | + | |
| 782 | + b.Property<string>("Url") | |
| 783 | + .HasMaxLength(500) | |
| 784 | + .HasColumnType("character varying(500)"); | |
| 785 | + | |
| 786 | + b.HasKey("Id"); | |
| 787 | + | |
| 788 | + b.HasIndex("AddedByUserId"); | |
| 789 | + | |
| 790 | + b.HasIndex("TripId"); | |
| 791 | + | |
| 792 | + b.ToTable("TripWishlistItems"); | |
| 793 | + }); | |
| 794 | + | |
| 795 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 796 | + { | |
| 797 | + b.Property<Guid>("Id") | |
| 798 | + .ValueGeneratedOnAdd() | |
| 799 | + .HasColumnType("uuid"); | |
| 800 | + | |
| 801 | + b.Property<DateTime>("CreatedAt") | |
| 802 | + .HasColumnType("timestamp with time zone"); | |
| 803 | + | |
| 804 | + b.Property<bool>("IsInterested") | |
| 805 | + .HasColumnType("boolean"); | |
| 806 | + | |
| 807 | + b.Property<DateTime>("UpdatedAt") | |
| 808 | + .HasColumnType("timestamp with time zone"); | |
| 809 | + | |
| 810 | + b.Property<Guid>("UserId") | |
| 811 | + .HasColumnType("uuid"); | |
| 812 | + | |
| 813 | + b.Property<Guid>("WishlistItemId") | |
| 814 | + .HasColumnType("uuid"); | |
| 815 | + | |
| 816 | + b.HasKey("Id"); | |
| 817 | + | |
| 818 | + b.HasIndex("UserId"); | |
| 819 | + | |
| 820 | + b.HasIndex("WishlistItemId", "UserId") | |
| 821 | + .IsUnique(); | |
| 822 | + | |
| 823 | + b.ToTable("TripWishlistVotes"); | |
| 824 | + }); | |
| 825 | + | |
| 826 | + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => | |
| 827 | + { | |
| 828 | + b.Property<int>("Id") | |
| 829 | + .ValueGeneratedOnAdd() | |
| 830 | + .HasColumnType("integer"); | |
| 831 | + | |
| 832 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 833 | + | |
| 834 | + b.Property<string>("FriendlyName") | |
| 835 | + .HasColumnType("text"); | |
| 836 | + | |
| 837 | + b.Property<string>("Xml") | |
| 838 | + .HasColumnType("text"); | |
| 839 | + | |
| 840 | + b.HasKey("Id"); | |
| 841 | + | |
| 842 | + b.ToTable("DataProtectionKeys"); | |
| 843 | + }); | |
| 844 | + | |
| 845 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 846 | + { | |
| 847 | + b.Property<int>("Id") | |
| 848 | + .ValueGeneratedOnAdd() | |
| 849 | + .HasColumnType("integer"); | |
| 850 | + | |
| 851 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 852 | + | |
| 853 | + b.Property<string>("ClaimType") | |
| 854 | + .HasColumnType("text"); | |
| 855 | + | |
| 856 | + b.Property<string>("ClaimValue") | |
| 857 | + .HasColumnType("text"); | |
| 858 | + | |
| 859 | + b.Property<Guid>("RoleId") | |
| 860 | + .HasColumnType("uuid"); | |
| 861 | + | |
| 862 | + b.HasKey("Id"); | |
| 863 | + | |
| 864 | + b.HasIndex("RoleId"); | |
| 865 | + | |
| 866 | + b.ToTable("AspNetRoleClaims", (string)null); | |
| 867 | + }); | |
| 868 | + | |
| 869 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 870 | + { | |
| 871 | + b.Property<int>("Id") | |
| 872 | + .ValueGeneratedOnAdd() | |
| 873 | + .HasColumnType("integer"); | |
| 874 | + | |
| 875 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 876 | + | |
| 877 | + b.Property<string>("ClaimType") | |
| 878 | + .HasColumnType("text"); | |
| 879 | + | |
| 880 | + b.Property<string>("ClaimValue") | |
| 881 | + .HasColumnType("text"); | |
| 882 | + | |
| 883 | + b.Property<Guid>("UserId") | |
| 884 | + .HasColumnType("uuid"); | |
| 885 | + | |
| 886 | + b.HasKey("Id"); | |
| 887 | + | |
| 888 | + b.HasIndex("UserId"); | |
| 889 | + | |
| 890 | + b.ToTable("AspNetUserClaims", (string)null); | |
| 891 | + }); | |
| 892 | + | |
| 893 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 894 | + { | |
| 895 | + b.Property<string>("LoginProvider") | |
| 896 | + .HasColumnType("text"); | |
| 897 | + | |
| 898 | + b.Property<string>("ProviderKey") | |
| 899 | + .HasColumnType("text"); | |
| 900 | + | |
| 901 | + b.Property<string>("ProviderDisplayName") | |
| 902 | + .HasColumnType("text"); | |
| 903 | + | |
| 904 | + b.Property<Guid>("UserId") | |
| 905 | + .HasColumnType("uuid"); | |
| 906 | + | |
| 907 | + b.HasKey("LoginProvider", "ProviderKey"); | |
| 908 | + | |
| 909 | + b.HasIndex("UserId"); | |
| 910 | + | |
| 911 | + b.ToTable("AspNetUserLogins", (string)null); | |
| 912 | + }); | |
| 913 | + | |
| 914 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 915 | + { | |
| 916 | + b.Property<Guid>("UserId") | |
| 917 | + .HasColumnType("uuid"); | |
| 918 | + | |
| 919 | + b.Property<Guid>("RoleId") | |
| 920 | + .HasColumnType("uuid"); | |
| 921 | + | |
| 922 | + b.HasKey("UserId", "RoleId"); | |
| 923 | + | |
| 924 | + b.HasIndex("RoleId"); | |
| 925 | + | |
| 926 | + b.ToTable("AspNetUserRoles", (string)null); | |
| 927 | + }); | |
| 928 | + | |
| 929 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 930 | + { | |
| 931 | + b.Property<Guid>("UserId") | |
| 932 | + .HasColumnType("uuid"); | |
| 933 | + | |
| 934 | + b.Property<string>("LoginProvider") | |
| 935 | + .HasColumnType("text"); | |
| 936 | + | |
| 937 | + b.Property<string>("Name") | |
| 938 | + .HasColumnType("text"); | |
| 939 | + | |
| 940 | + b.Property<string>("Value") | |
| 941 | + .HasColumnType("text"); | |
| 942 | + | |
| 943 | + b.HasKey("UserId", "LoginProvider", "Name"); | |
| 944 | + | |
| 945 | + b.ToTable("AspNetUserTokens", (string)null); | |
| 946 | + }); | |
| 947 | + | |
| 948 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 949 | + { | |
| 950 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 951 | + .WithMany("BudgetCategories") | |
| 952 | + .HasForeignKey("TripId") | |
| 953 | + .OnDelete(DeleteBehavior.Restrict) | |
| 954 | + .IsRequired(); | |
| 955 | + | |
| 956 | + b.Navigation("Trip"); | |
| 957 | + }); | |
| 958 | + | |
| 959 | + modelBuilder.Entity("App.Domain.BudgetCategoryTranslation", b => | |
| 960 | + { | |
| 961 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 962 | + .WithMany("Translations") | |
| 963 | + .HasForeignKey("BudgetCategoryId") | |
| 964 | + .OnDelete(DeleteBehavior.Restrict) | |
| 965 | + .IsRequired(); | |
| 966 | + | |
| 967 | + b.Navigation("BudgetCategory"); | |
| 968 | + }); | |
| 969 | + | |
| 970 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 971 | + { | |
| 972 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 973 | + .WithMany("Expenses") | |
| 974 | + .HasForeignKey("BudgetCategoryId") | |
| 975 | + .OnDelete(DeleteBehavior.Restrict); | |
| 976 | + | |
| 977 | + b.HasOne("App.Domain.Currency", "Currency") | |
| 978 | + .WithMany() | |
| 979 | + .HasForeignKey("CurrencyId") | |
| 980 | + .OnDelete(DeleteBehavior.Restrict); | |
| 981 | + | |
| 982 | + b.HasOne("App.Domain.Identity.AppUser", "PaidByUser") | |
| 983 | + .WithMany() | |
| 984 | + .HasForeignKey("PaidByUserId") | |
| 985 | + .OnDelete(DeleteBehavior.Restrict) | |
| 986 | + .IsRequired(); | |
| 987 | + | |
| 988 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 989 | + .WithMany("Expenses") | |
| 990 | + .HasForeignKey("TripId") | |
| 991 | + .OnDelete(DeleteBehavior.Restrict) | |
| 992 | + .IsRequired(); | |
| 993 | + | |
| 994 | + b.Navigation("BudgetCategory"); | |
| 995 | + | |
| 996 | + b.Navigation("Currency"); | |
| 997 | + | |
| 998 | + b.Navigation("PaidByUser"); | |
| 999 | + | |
| 1000 | + b.Navigation("Trip"); | |
| 1001 | + }); | |
| 1002 | + | |
| 1003 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 1004 | + { | |
| 1005 | + b.HasOne("App.Domain.Expense", "Expense") | |
| 1006 | + .WithMany("Splits") | |
| 1007 | + .HasForeignKey("ExpenseId") | |
| 1008 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1009 | + .IsRequired(); | |
| 1010 | + | |
| 1011 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1012 | + .WithMany() | |
| 1013 | + .HasForeignKey("UserId") | |
| 1014 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1015 | + .IsRequired(); | |
| 1016 | + | |
| 1017 | + b.Navigation("Expense"); | |
| 1018 | + | |
| 1019 | + b.Navigation("User"); | |
| 1020 | + }); | |
| 1021 | + | |
| 1022 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 1023 | + { | |
| 1024 | + b.HasOne("App.Domain.Identity.AppUser", "AppUser") | |
| 1025 | + .WithMany("RefreshTokens") | |
| 1026 | + .HasForeignKey("AppUserId") | |
| 1027 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1028 | + .IsRequired(); | |
| 1029 | + | |
| 1030 | + b.Navigation("AppUser"); | |
| 1031 | + }); | |
| 1032 | + | |
| 1033 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 1034 | + { | |
| 1035 | + b.HasOne("App.Domain.Identity.AppUser", "FromUser") | |
| 1036 | + .WithMany() | |
| 1037 | + .HasForeignKey("FromUserId") | |
| 1038 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1039 | + .IsRequired(); | |
| 1040 | + | |
| 1041 | + b.HasOne("App.Domain.SettlementPlan", "SettlementPlan") | |
| 1042 | + .WithMany("Payments") | |
| 1043 | + .HasForeignKey("SettlementPlanId") | |
| 1044 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1045 | + .IsRequired(); | |
| 1046 | + | |
| 1047 | + b.HasOne("App.Domain.Identity.AppUser", "ToUser") | |
| 1048 | + .WithMany() | |
| 1049 | + .HasForeignKey("ToUserId") | |
| 1050 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1051 | + .IsRequired(); | |
| 1052 | + | |
| 1053 | + b.Navigation("FromUser"); | |
| 1054 | + | |
| 1055 | + b.Navigation("SettlementPlan"); | |
| 1056 | + | |
| 1057 | + b.Navigation("ToUser"); | |
| 1058 | + }); | |
| 1059 | + | |
| 1060 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1061 | + { | |
| 1062 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1063 | + .WithMany() | |
| 1064 | + .HasForeignKey("CreatedByUserId") | |
| 1065 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1066 | + .IsRequired(); | |
| 1067 | + | |
| 1068 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1069 | + .WithMany("SettlementPlans") | |
| 1070 | + .HasForeignKey("TripId") | |
| 1071 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1072 | + .IsRequired(); | |
| 1073 | + | |
| 1074 | + b.Navigation("CreatedByUser"); | |
| 1075 | + | |
| 1076 | + b.Navigation("Trip"); | |
| 1077 | + }); | |
| 1078 | + | |
| 1079 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1080 | + { | |
| 1081 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1082 | + .WithMany() | |
| 1083 | + .HasForeignKey("CreatedById") | |
| 1084 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1085 | + .IsRequired(); | |
| 1086 | + | |
| 1087 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1088 | + .WithMany() | |
| 1089 | + .HasForeignKey("TripId") | |
| 1090 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1091 | + .IsRequired(); | |
| 1092 | + | |
| 1093 | + b.Navigation("CreatedBy"); | |
| 1094 | + | |
| 1095 | + b.Navigation("Trip"); | |
| 1096 | + }); | |
| 1097 | + | |
| 1098 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 1099 | + { | |
| 1100 | + b.HasOne("App.Domain.SplitPreset", "SplitPreset") | |
| 1101 | + .WithMany("Members") | |
| 1102 | + .HasForeignKey("SplitPresetId") | |
| 1103 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1104 | + .IsRequired(); | |
| 1105 | + | |
| 1106 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1107 | + .WithMany() | |
| 1108 | + .HasForeignKey("UserId") | |
| 1109 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1110 | + .IsRequired(); | |
| 1111 | + | |
| 1112 | + b.Navigation("SplitPreset"); | |
| 1113 | + | |
| 1114 | + b.Navigation("User"); | |
| 1115 | + }); | |
| 1116 | + | |
| 1117 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1118 | + { | |
| 1119 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1120 | + .WithMany() | |
| 1121 | + .HasForeignKey("CreatedById") | |
| 1122 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1123 | + .IsRequired(); | |
| 1124 | + | |
| 1125 | + b.HasOne("App.Domain.Currency", "DefaultCurrency") | |
| 1126 | + .WithMany() | |
| 1127 | + .HasForeignKey("DefaultCurrencyId") | |
| 1128 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1129 | + .IsRequired(); | |
| 1130 | + | |
| 1131 | + b.Navigation("CreatedBy"); | |
| 1132 | + | |
| 1133 | + b.Navigation("DefaultCurrency"); | |
| 1134 | + }); | |
| 1135 | + | |
| 1136 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 1137 | + { | |
| 1138 | + b.HasOne("App.Domain.Identity.AppUser", "InvitedByUser") | |
| 1139 | + .WithMany() | |
| 1140 | + .HasForeignKey("InvitedByUserId") | |
| 1141 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1142 | + .IsRequired(); | |
| 1143 | + | |
| 1144 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1145 | + .WithMany("Invitations") | |
| 1146 | + .HasForeignKey("TripId") | |
| 1147 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1148 | + .IsRequired(); | |
| 1149 | + | |
| 1150 | + b.Navigation("InvitedByUser"); | |
| 1151 | + | |
| 1152 | + b.Navigation("Trip"); | |
| 1153 | + }); | |
| 1154 | + | |
| 1155 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 1156 | + { | |
| 1157 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1158 | + .WithMany("Participants") | |
| 1159 | + .HasForeignKey("TripId") | |
| 1160 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1161 | + .IsRequired(); | |
| 1162 | + | |
| 1163 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1164 | + .WithMany("TripParticipants") | |
| 1165 | + .HasForeignKey("UserId") | |
| 1166 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1167 | + .IsRequired(); | |
| 1168 | + | |
| 1169 | + b.Navigation("Trip"); | |
| 1170 | + | |
| 1171 | + b.Navigation("User"); | |
| 1172 | + }); | |
| 1173 | + | |
| 1174 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1175 | + { | |
| 1176 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1177 | + .WithMany() | |
| 1178 | + .HasForeignKey("CreatedByUserId") | |
| 1179 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1180 | + .IsRequired(); | |
| 1181 | + | |
| 1182 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1183 | + .WithMany("Polls") | |
| 1184 | + .HasForeignKey("TripId") | |
| 1185 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1186 | + .IsRequired(); | |
| 1187 | + | |
| 1188 | + b.Navigation("CreatedByUser"); | |
| 1189 | + | |
| 1190 | + b.Navigation("Trip"); | |
| 1191 | + }); | |
| 1192 | + | |
| 1193 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1194 | + { | |
| 1195 | + b.HasOne("App.Domain.TripPoll", "Poll") | |
| 1196 | + .WithMany("Options") | |
| 1197 | + .HasForeignKey("PollId") | |
| 1198 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1199 | + .IsRequired(); | |
| 1200 | + | |
| 1201 | + b.Navigation("Poll"); | |
| 1202 | + }); | |
| 1203 | + | |
| 1204 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 1205 | + { | |
| 1206 | + b.HasOne("App.Domain.TripPollOption", "PollOption") | |
| 1207 | + .WithMany("Votes") | |
| 1208 | + .HasForeignKey("PollOptionId") | |
| 1209 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1210 | + .IsRequired(); | |
| 1211 | + | |
| 1212 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1213 | + .WithMany() | |
| 1214 | + .HasForeignKey("UserId") | |
| 1215 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1216 | + .IsRequired(); | |
| 1217 | + | |
| 1218 | + b.Navigation("PollOption"); | |
| 1219 | + | |
| 1220 | + b.Navigation("User"); | |
| 1221 | + }); | |
| 1222 | + | |
| 1223 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1224 | + { | |
| 1225 | + b.HasOne("App.Domain.Identity.AppUser", "AddedByUser") | |
| 1226 | + .WithMany() | |
| 1227 | + .HasForeignKey("AddedByUserId") | |
| 1228 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1229 | + .IsRequired(); | |
| 1230 | + | |
| 1231 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1232 | + .WithMany("WishlistItems") | |
| 1233 | + .HasForeignKey("TripId") | |
| 1234 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1235 | + .IsRequired(); | |
| 1236 | + | |
| 1237 | + b.Navigation("AddedByUser"); | |
| 1238 | + | |
| 1239 | + b.Navigation("Trip"); | |
| 1240 | + }); | |
| 1241 | + | |
| 1242 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 1243 | + { | |
| 1244 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1245 | + .WithMany() | |
| 1246 | + .HasForeignKey("UserId") | |
| 1247 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1248 | + .IsRequired(); | |
| 1249 | + | |
| 1250 | + b.HasOne("App.Domain.TripWishlistItem", "WishlistItem") | |
| 1251 | + .WithMany("Votes") | |
| 1252 | + .HasForeignKey("WishlistItemId") | |
| 1253 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1254 | + .IsRequired(); | |
| 1255 | + | |
| 1256 | + b.Navigation("User"); | |
| 1257 | + | |
| 1258 | + b.Navigation("WishlistItem"); | |
| 1259 | + }); | |
| 1260 | + | |
| 1261 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 1262 | + { | |
| 1263 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1264 | + .WithMany() | |
| 1265 | + .HasForeignKey("RoleId") | |
| 1266 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1267 | + .IsRequired(); | |
| 1268 | + }); | |
| 1269 | + | |
| 1270 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 1271 | + { | |
| 1272 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1273 | + .WithMany() | |
| 1274 | + .HasForeignKey("UserId") | |
| 1275 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1276 | + .IsRequired(); | |
| 1277 | + }); | |
| 1278 | + | |
| 1279 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 1280 | + { | |
| 1281 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1282 | + .WithMany() | |
| 1283 | + .HasForeignKey("UserId") | |
| 1284 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1285 | + .IsRequired(); | |
| 1286 | + }); | |
| 1287 | + | |
| 1288 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 1289 | + { | |
| 1290 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1291 | + .WithMany() | |
| 1292 | + .HasForeignKey("RoleId") | |
| 1293 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1294 | + .IsRequired(); | |
| 1295 | + | |
| 1296 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1297 | + .WithMany() | |
| 1298 | + .HasForeignKey("UserId") | |
| 1299 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1300 | + .IsRequired(); | |
| 1301 | + }); | |
| 1302 | + | |
| 1303 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 1304 | + { | |
| 1305 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1306 | + .WithMany() | |
| 1307 | + .HasForeignKey("UserId") | |
| 1308 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1309 | + .IsRequired(); | |
| 1310 | + }); | |
| 1311 | + | |
| 1312 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 1313 | + { | |
| 1314 | + b.Navigation("Expenses"); | |
| 1315 | + | |
| 1316 | + b.Navigation("Translations"); | |
| 1317 | + }); | |
| 1318 | + | |
| 1319 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 1320 | + { | |
| 1321 | + b.Navigation("Splits"); | |
| 1322 | + }); | |
| 1323 | + | |
| 1324 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 1325 | + { | |
| 1326 | + b.Navigation("RefreshTokens"); | |
| 1327 | + | |
| 1328 | + b.Navigation("TripParticipants"); | |
| 1329 | + }); | |
| 1330 | + | |
| 1331 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1332 | + { | |
| 1333 | + b.Navigation("Payments"); | |
| 1334 | + }); | |
| 1335 | + | |
| 1336 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1337 | + { | |
| 1338 | + b.Navigation("Members"); | |
| 1339 | + }); | |
| 1340 | + | |
| 1341 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1342 | + { | |
| 1343 | + b.Navigation("BudgetCategories"); | |
| 1344 | + | |
| 1345 | + b.Navigation("Expenses"); | |
| 1346 | + | |
| 1347 | + b.Navigation("Invitations"); | |
| 1348 | + | |
| 1349 | + b.Navigation("Participants"); | |
| 1350 | + | |
| 1351 | + b.Navigation("Polls"); | |
| 1352 | + | |
| 1353 | + b.Navigation("SettlementPlans"); | |
| 1354 | + | |
| 1355 | + b.Navigation("WishlistItems"); | |
| 1356 | + }); | |
| 1357 | + | |
| 1358 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1359 | + { | |
| 1360 | + b.Navigation("Options"); | |
| 1361 | + }); | |
| 1362 | + | |
| 1363 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1364 | + { | |
| 1365 | + b.Navigation("Votes"); | |
| 1366 | + }); | |
| 1367 | + | |
| 1368 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1369 | + { | |
| 1370 | + b.Navigation("Votes"); | |
| 1371 | + }); | |
| 1372 | +#pragma warning restore 612, 618 | |
| 1373 | + } | |
| 1374 | + } | |
| 1375 | +} |
added SplitApp/App.DAL.EF/Migrations/20260329141138_CurrencyNameToLangStr.cs +38 −0
| @@ -0,0 +1,38 @@ | ||
| 1 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 2 | + | |
| 3 | +#nullable disable | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Migrations | |
| 6 | +{ | |
| 7 | + /// <inheritdoc /> | |
| 8 | + public partial class CurrencyNameToLangStr : Migration | |
| 9 | + { | |
| 10 | + /// <inheritdoc /> | |
| 11 | + protected override void Up(MigrationBuilder migrationBuilder) | |
| 12 | + { | |
| 13 | + migrationBuilder.AlterColumn<string>( | |
| 14 | + name: "Name", | |
| 15 | + table: "Currencies", | |
| 16 | + type: "character varying(1024)", | |
| 17 | + maxLength: 1024, | |
| 18 | + nullable: false, | |
| 19 | + oldClrType: typeof(string), | |
| 20 | + oldType: "character varying(100)", | |
| 21 | + oldMaxLength: 100); | |
| 22 | + } | |
| 23 | + | |
| 24 | + /// <inheritdoc /> | |
| 25 | + protected override void Down(MigrationBuilder migrationBuilder) | |
| 26 | + { | |
| 27 | + migrationBuilder.AlterColumn<string>( | |
| 28 | + name: "Name", | |
| 29 | + table: "Currencies", | |
| 30 | + type: "character varying(100)", | |
| 31 | + maxLength: 100, | |
| 32 | + nullable: false, | |
| 33 | + oldClrType: typeof(string), | |
| 34 | + oldType: "character varying(1024)", | |
| 35 | + oldMaxLength: 1024); | |
| 36 | + } | |
| 37 | + } | |
| 38 | +} |
added SplitApp/App.DAL.EF/Migrations/20260402104505_RemoveUnusedBudgetCategoryTranslations.Designer.cs +1330 −0
| @@ -0,0 +1,1330 @@ | ||
| 1 | +// <auto-generated /> | |
| 2 | +using System; | |
| 3 | +using App.DAL.EF; | |
| 4 | +using Microsoft.EntityFrameworkCore; | |
| 5 | +using Microsoft.EntityFrameworkCore.Infrastructure; | |
| 6 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 7 | +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | |
| 8 | +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | |
| 9 | + | |
| 10 | +#nullable disable | |
| 11 | + | |
| 12 | +namespace App.DAL.EF.Migrations | |
| 13 | +{ | |
| 14 | + [DbContext(typeof(AppDbContext))] | |
| 15 | + [Migration("20260402104505_RemoveUnusedBudgetCategoryTranslations")] | |
| 16 | + partial class RemoveUnusedBudgetCategoryTranslations | |
| 17 | + { | |
| 18 | + /// <inheritdoc /> | |
| 19 | + protected override void BuildTargetModel(ModelBuilder modelBuilder) | |
| 20 | + { | |
| 21 | +#pragma warning disable 612, 618 | |
| 22 | + modelBuilder | |
| 23 | + .HasAnnotation("ProductVersion", "10.0.5") | |
| 24 | + .HasAnnotation("Relational:MaxIdentifierLength", 63); | |
| 25 | + | |
| 26 | + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | |
| 27 | + | |
| 28 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 29 | + { | |
| 30 | + b.Property<Guid>("Id") | |
| 31 | + .ValueGeneratedOnAdd() | |
| 32 | + .HasColumnType("uuid"); | |
| 33 | + | |
| 34 | + b.Property<DateTime>("CreatedAt") | |
| 35 | + .HasColumnType("timestamp with time zone"); | |
| 36 | + | |
| 37 | + b.Property<int>("DisplayOrder") | |
| 38 | + .HasColumnType("integer"); | |
| 39 | + | |
| 40 | + b.Property<string>("IconName") | |
| 41 | + .HasMaxLength(100) | |
| 42 | + .HasColumnType("character varying(100)"); | |
| 43 | + | |
| 44 | + b.Property<string>("Name") | |
| 45 | + .IsRequired() | |
| 46 | + .HasMaxLength(100) | |
| 47 | + .HasColumnType("character varying(100)"); | |
| 48 | + | |
| 49 | + b.Property<decimal?>("PlannedAmount") | |
| 50 | + .HasColumnType("numeric"); | |
| 51 | + | |
| 52 | + b.Property<Guid>("TripId") | |
| 53 | + .HasColumnType("uuid"); | |
| 54 | + | |
| 55 | + b.Property<DateTime>("UpdatedAt") | |
| 56 | + .HasColumnType("timestamp with time zone"); | |
| 57 | + | |
| 58 | + b.HasKey("Id"); | |
| 59 | + | |
| 60 | + b.HasIndex("TripId"); | |
| 61 | + | |
| 62 | + b.ToTable("BudgetCategories"); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + modelBuilder.Entity("App.Domain.Currency", b => | |
| 66 | + { | |
| 67 | + b.Property<Guid>("Id") | |
| 68 | + .ValueGeneratedOnAdd() | |
| 69 | + .HasColumnType("uuid"); | |
| 70 | + | |
| 71 | + b.Property<string>("Code") | |
| 72 | + .IsRequired() | |
| 73 | + .HasMaxLength(3) | |
| 74 | + .HasColumnType("character varying(3)"); | |
| 75 | + | |
| 76 | + b.Property<DateTime>("CreatedAt") | |
| 77 | + .HasColumnType("timestamp with time zone"); | |
| 78 | + | |
| 79 | + b.Property<string>("Name") | |
| 80 | + .IsRequired() | |
| 81 | + .HasMaxLength(1024) | |
| 82 | + .HasColumnType("character varying(1024)"); | |
| 83 | + | |
| 84 | + b.Property<string>("Symbol") | |
| 85 | + .IsRequired() | |
| 86 | + .HasMaxLength(10) | |
| 87 | + .HasColumnType("character varying(10)"); | |
| 88 | + | |
| 89 | + b.Property<DateTime>("UpdatedAt") | |
| 90 | + .HasColumnType("timestamp with time zone"); | |
| 91 | + | |
| 92 | + b.HasKey("Id"); | |
| 93 | + | |
| 94 | + b.ToTable("Currencies"); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 98 | + { | |
| 99 | + b.Property<Guid>("Id") | |
| 100 | + .ValueGeneratedOnAdd() | |
| 101 | + .HasColumnType("uuid"); | |
| 102 | + | |
| 103 | + b.Property<decimal>("Amount") | |
| 104 | + .HasColumnType("numeric"); | |
| 105 | + | |
| 106 | + b.Property<Guid?>("BudgetCategoryId") | |
| 107 | + .HasColumnType("uuid"); | |
| 108 | + | |
| 109 | + b.Property<DateTime>("CreatedAt") | |
| 110 | + .HasColumnType("timestamp with time zone"); | |
| 111 | + | |
| 112 | + b.Property<Guid?>("CurrencyId") | |
| 113 | + .HasColumnType("uuid"); | |
| 114 | + | |
| 115 | + b.Property<string>("Description") | |
| 116 | + .HasMaxLength(500) | |
| 117 | + .HasColumnType("character varying(500)"); | |
| 118 | + | |
| 119 | + b.Property<DateTime>("ExpenseDate") | |
| 120 | + .HasColumnType("timestamp with time zone"); | |
| 121 | + | |
| 122 | + b.Property<Guid>("PaidByUserId") | |
| 123 | + .HasColumnType("uuid"); | |
| 124 | + | |
| 125 | + b.Property<int>("SplitMethod") | |
| 126 | + .HasColumnType("integer"); | |
| 127 | + | |
| 128 | + b.Property<Guid>("TripId") | |
| 129 | + .HasColumnType("uuid"); | |
| 130 | + | |
| 131 | + b.Property<DateTime>("UpdatedAt") | |
| 132 | + .HasColumnType("timestamp with time zone"); | |
| 133 | + | |
| 134 | + b.HasKey("Id"); | |
| 135 | + | |
| 136 | + b.HasIndex("BudgetCategoryId"); | |
| 137 | + | |
| 138 | + b.HasIndex("CurrencyId"); | |
| 139 | + | |
| 140 | + b.HasIndex("PaidByUserId"); | |
| 141 | + | |
| 142 | + b.HasIndex("TripId"); | |
| 143 | + | |
| 144 | + b.ToTable("Expenses"); | |
| 145 | + }); | |
| 146 | + | |
| 147 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 148 | + { | |
| 149 | + b.Property<Guid>("Id") | |
| 150 | + .ValueGeneratedOnAdd() | |
| 151 | + .HasColumnType("uuid"); | |
| 152 | + | |
| 153 | + b.Property<decimal>("Amount") | |
| 154 | + .HasColumnType("numeric"); | |
| 155 | + | |
| 156 | + b.Property<DateTime>("CreatedAt") | |
| 157 | + .HasColumnType("timestamp with time zone"); | |
| 158 | + | |
| 159 | + b.Property<Guid>("ExpenseId") | |
| 160 | + .HasColumnType("uuid"); | |
| 161 | + | |
| 162 | + b.Property<decimal?>("Percentage") | |
| 163 | + .HasColumnType("numeric"); | |
| 164 | + | |
| 165 | + b.Property<DateTime>("UpdatedAt") | |
| 166 | + .HasColumnType("timestamp with time zone"); | |
| 167 | + | |
| 168 | + b.Property<Guid>("UserId") | |
| 169 | + .HasColumnType("uuid"); | |
| 170 | + | |
| 171 | + b.HasKey("Id"); | |
| 172 | + | |
| 173 | + b.HasIndex("ExpenseId"); | |
| 174 | + | |
| 175 | + b.HasIndex("UserId"); | |
| 176 | + | |
| 177 | + b.ToTable("ExpenseSplits"); | |
| 178 | + }); | |
| 179 | + | |
| 180 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 181 | + { | |
| 182 | + b.Property<Guid>("Id") | |
| 183 | + .ValueGeneratedOnAdd() | |
| 184 | + .HasColumnType("uuid"); | |
| 185 | + | |
| 186 | + b.Property<Guid>("AppUserId") | |
| 187 | + .HasColumnType("uuid"); | |
| 188 | + | |
| 189 | + b.Property<DateTime>("CreatedAt") | |
| 190 | + .HasColumnType("timestamp with time zone"); | |
| 191 | + | |
| 192 | + b.Property<DateTime>("ExpirationDT") | |
| 193 | + .HasColumnType("timestamp with time zone"); | |
| 194 | + | |
| 195 | + b.Property<DateTime>("PreviousExpirationDT") | |
| 196 | + .HasColumnType("timestamp with time zone"); | |
| 197 | + | |
| 198 | + b.Property<string>("PreviousRefreshToken") | |
| 199 | + .HasMaxLength(64) | |
| 200 | + .HasColumnType("character varying(64)"); | |
| 201 | + | |
| 202 | + b.Property<string>("RefreshToken") | |
| 203 | + .IsRequired() | |
| 204 | + .HasMaxLength(64) | |
| 205 | + .HasColumnType("character varying(64)"); | |
| 206 | + | |
| 207 | + b.Property<DateTime>("UpdatedAt") | |
| 208 | + .HasColumnType("timestamp with time zone"); | |
| 209 | + | |
| 210 | + b.HasKey("Id"); | |
| 211 | + | |
| 212 | + b.HasIndex("AppUserId"); | |
| 213 | + | |
| 214 | + b.ToTable("RefreshTokens"); | |
| 215 | + }); | |
| 216 | + | |
| 217 | + modelBuilder.Entity("App.Domain.Identity.AppRole", b => | |
| 218 | + { | |
| 219 | + b.Property<Guid>("Id") | |
| 220 | + .ValueGeneratedOnAdd() | |
| 221 | + .HasColumnType("uuid"); | |
| 222 | + | |
| 223 | + b.Property<string>("ConcurrencyStamp") | |
| 224 | + .IsConcurrencyToken() | |
| 225 | + .HasColumnType("text"); | |
| 226 | + | |
| 227 | + b.Property<string>("Name") | |
| 228 | + .HasMaxLength(256) | |
| 229 | + .HasColumnType("character varying(256)"); | |
| 230 | + | |
| 231 | + b.Property<string>("NormalizedName") | |
| 232 | + .HasMaxLength(256) | |
| 233 | + .HasColumnType("character varying(256)"); | |
| 234 | + | |
| 235 | + b.HasKey("Id"); | |
| 236 | + | |
| 237 | + b.HasIndex("NormalizedName") | |
| 238 | + .IsUnique() | |
| 239 | + .HasDatabaseName("RoleNameIndex"); | |
| 240 | + | |
| 241 | + b.ToTable("AspNetRoles", (string)null); | |
| 242 | + }); | |
| 243 | + | |
| 244 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 245 | + { | |
| 246 | + b.Property<Guid>("Id") | |
| 247 | + .ValueGeneratedOnAdd() | |
| 248 | + .HasColumnType("uuid"); | |
| 249 | + | |
| 250 | + b.Property<int>("AccessFailedCount") | |
| 251 | + .HasColumnType("integer"); | |
| 252 | + | |
| 253 | + b.Property<string>("ConcurrencyStamp") | |
| 254 | + .IsConcurrencyToken() | |
| 255 | + .HasColumnType("text"); | |
| 256 | + | |
| 257 | + b.Property<string>("Email") | |
| 258 | + .HasMaxLength(256) | |
| 259 | + .HasColumnType("character varying(256)"); | |
| 260 | + | |
| 261 | + b.Property<bool>("EmailConfirmed") | |
| 262 | + .HasColumnType("boolean"); | |
| 263 | + | |
| 264 | + b.Property<string>("FirstName") | |
| 265 | + .IsRequired() | |
| 266 | + .HasMaxLength(128) | |
| 267 | + .HasColumnType("character varying(128)"); | |
| 268 | + | |
| 269 | + b.Property<string>("LastName") | |
| 270 | + .IsRequired() | |
| 271 | + .HasMaxLength(128) | |
| 272 | + .HasColumnType("character varying(128)"); | |
| 273 | + | |
| 274 | + b.Property<bool>("LockoutEnabled") | |
| 275 | + .HasColumnType("boolean"); | |
| 276 | + | |
| 277 | + b.Property<DateTimeOffset?>("LockoutEnd") | |
| 278 | + .HasColumnType("timestamp with time zone"); | |
| 279 | + | |
| 280 | + b.Property<string>("NormalizedEmail") | |
| 281 | + .HasMaxLength(256) | |
| 282 | + .HasColumnType("character varying(256)"); | |
| 283 | + | |
| 284 | + b.Property<string>("NormalizedUserName") | |
| 285 | + .HasMaxLength(256) | |
| 286 | + .HasColumnType("character varying(256)"); | |
| 287 | + | |
| 288 | + b.Property<string>("PasswordHash") | |
| 289 | + .HasColumnType("text"); | |
| 290 | + | |
| 291 | + b.Property<string>("PhoneNumber") | |
| 292 | + .HasColumnType("text"); | |
| 293 | + | |
| 294 | + b.Property<bool>("PhoneNumberConfirmed") | |
| 295 | + .HasColumnType("boolean"); | |
| 296 | + | |
| 297 | + b.Property<string>("SecurityStamp") | |
| 298 | + .HasColumnType("text"); | |
| 299 | + | |
| 300 | + b.Property<bool>("TwoFactorEnabled") | |
| 301 | + .HasColumnType("boolean"); | |
| 302 | + | |
| 303 | + b.Property<string>("UserName") | |
| 304 | + .HasMaxLength(256) | |
| 305 | + .HasColumnType("character varying(256)"); | |
| 306 | + | |
| 307 | + b.HasKey("Id"); | |
| 308 | + | |
| 309 | + b.HasIndex("NormalizedEmail") | |
| 310 | + .HasDatabaseName("EmailIndex"); | |
| 311 | + | |
| 312 | + b.HasIndex("NormalizedUserName") | |
| 313 | + .IsUnique() | |
| 314 | + .HasDatabaseName("UserNameIndex"); | |
| 315 | + | |
| 316 | + b.ToTable("AspNetUsers", (string)null); | |
| 317 | + }); | |
| 318 | + | |
| 319 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 320 | + { | |
| 321 | + b.Property<Guid>("Id") | |
| 322 | + .ValueGeneratedOnAdd() | |
| 323 | + .HasColumnType("uuid"); | |
| 324 | + | |
| 325 | + b.Property<decimal>("Amount") | |
| 326 | + .HasColumnType("numeric"); | |
| 327 | + | |
| 328 | + b.Property<DateTime?>("ConfirmedAt") | |
| 329 | + .HasColumnType("timestamp with time zone"); | |
| 330 | + | |
| 331 | + b.Property<DateTime>("CreatedAt") | |
| 332 | + .HasColumnType("timestamp with time zone"); | |
| 333 | + | |
| 334 | + b.Property<Guid>("FromUserId") | |
| 335 | + .HasColumnType("uuid"); | |
| 336 | + | |
| 337 | + b.Property<DateTime?>("MarkedPaidAt") | |
| 338 | + .HasColumnType("timestamp with time zone"); | |
| 339 | + | |
| 340 | + b.Property<Guid>("SettlementPlanId") | |
| 341 | + .HasColumnType("uuid"); | |
| 342 | + | |
| 343 | + b.Property<int>("Status") | |
| 344 | + .HasColumnType("integer"); | |
| 345 | + | |
| 346 | + b.Property<Guid>("ToUserId") | |
| 347 | + .HasColumnType("uuid"); | |
| 348 | + | |
| 349 | + b.Property<DateTime>("UpdatedAt") | |
| 350 | + .HasColumnType("timestamp with time zone"); | |
| 351 | + | |
| 352 | + b.HasKey("Id"); | |
| 353 | + | |
| 354 | + b.HasIndex("FromUserId"); | |
| 355 | + | |
| 356 | + b.HasIndex("SettlementPlanId"); | |
| 357 | + | |
| 358 | + b.HasIndex("ToUserId"); | |
| 359 | + | |
| 360 | + b.ToTable("SettlementPayments"); | |
| 361 | + }); | |
| 362 | + | |
| 363 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 364 | + { | |
| 365 | + b.Property<Guid>("Id") | |
| 366 | + .ValueGeneratedOnAdd() | |
| 367 | + .HasColumnType("uuid"); | |
| 368 | + | |
| 369 | + b.Property<DateTime?>("CompletedAt") | |
| 370 | + .HasColumnType("timestamp with time zone"); | |
| 371 | + | |
| 372 | + b.Property<DateTime>("CreatedAt") | |
| 373 | + .HasColumnType("timestamp with time zone"); | |
| 374 | + | |
| 375 | + b.Property<Guid>("CreatedByUserId") | |
| 376 | + .HasColumnType("uuid"); | |
| 377 | + | |
| 378 | + b.Property<int>("Status") | |
| 379 | + .HasColumnType("integer"); | |
| 380 | + | |
| 381 | + b.Property<decimal>("TotalAmount") | |
| 382 | + .HasColumnType("numeric"); | |
| 383 | + | |
| 384 | + b.Property<Guid>("TripId") | |
| 385 | + .HasColumnType("uuid"); | |
| 386 | + | |
| 387 | + b.Property<DateTime>("UpdatedAt") | |
| 388 | + .HasColumnType("timestamp with time zone"); | |
| 389 | + | |
| 390 | + b.HasKey("Id"); | |
| 391 | + | |
| 392 | + b.HasIndex("CreatedByUserId"); | |
| 393 | + | |
| 394 | + b.HasIndex("TripId"); | |
| 395 | + | |
| 396 | + b.ToTable("SettlementPlans"); | |
| 397 | + }); | |
| 398 | + | |
| 399 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 400 | + { | |
| 401 | + b.Property<Guid>("Id") | |
| 402 | + .ValueGeneratedOnAdd() | |
| 403 | + .HasColumnType("uuid"); | |
| 404 | + | |
| 405 | + b.Property<DateTime>("CreatedAt") | |
| 406 | + .HasColumnType("timestamp with time zone"); | |
| 407 | + | |
| 408 | + b.Property<Guid>("CreatedById") | |
| 409 | + .HasColumnType("uuid"); | |
| 410 | + | |
| 411 | + b.Property<string>("Name") | |
| 412 | + .IsRequired() | |
| 413 | + .HasMaxLength(200) | |
| 414 | + .HasColumnType("character varying(200)"); | |
| 415 | + | |
| 416 | + b.Property<int>("SplitMethod") | |
| 417 | + .HasColumnType("integer"); | |
| 418 | + | |
| 419 | + b.Property<Guid>("TripId") | |
| 420 | + .HasColumnType("uuid"); | |
| 421 | + | |
| 422 | + b.Property<DateTime>("UpdatedAt") | |
| 423 | + .HasColumnType("timestamp with time zone"); | |
| 424 | + | |
| 425 | + b.HasKey("Id"); | |
| 426 | + | |
| 427 | + b.HasIndex("CreatedById"); | |
| 428 | + | |
| 429 | + b.HasIndex("TripId"); | |
| 430 | + | |
| 431 | + b.ToTable("SplitPresets"); | |
| 432 | + }); | |
| 433 | + | |
| 434 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 435 | + { | |
| 436 | + b.Property<Guid>("Id") | |
| 437 | + .ValueGeneratedOnAdd() | |
| 438 | + .HasColumnType("uuid"); | |
| 439 | + | |
| 440 | + b.Property<DateTime>("CreatedAt") | |
| 441 | + .HasColumnType("timestamp with time zone"); | |
| 442 | + | |
| 443 | + b.Property<decimal?>("Percentage") | |
| 444 | + .HasColumnType("numeric"); | |
| 445 | + | |
| 446 | + b.Property<decimal?>("ShareWeight") | |
| 447 | + .HasColumnType("numeric"); | |
| 448 | + | |
| 449 | + b.Property<Guid>("SplitPresetId") | |
| 450 | + .HasColumnType("uuid"); | |
| 451 | + | |
| 452 | + b.Property<DateTime>("UpdatedAt") | |
| 453 | + .HasColumnType("timestamp with time zone"); | |
| 454 | + | |
| 455 | + b.Property<Guid>("UserId") | |
| 456 | + .HasColumnType("uuid"); | |
| 457 | + | |
| 458 | + b.HasKey("Id"); | |
| 459 | + | |
| 460 | + b.HasIndex("SplitPresetId"); | |
| 461 | + | |
| 462 | + b.HasIndex("UserId"); | |
| 463 | + | |
| 464 | + b.ToTable("SplitPresetMembers"); | |
| 465 | + }); | |
| 466 | + | |
| 467 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 468 | + { | |
| 469 | + b.Property<Guid>("Id") | |
| 470 | + .ValueGeneratedOnAdd() | |
| 471 | + .HasColumnType("uuid"); | |
| 472 | + | |
| 473 | + b.Property<DateTime>("CreatedAt") | |
| 474 | + .HasColumnType("timestamp with time zone"); | |
| 475 | + | |
| 476 | + b.Property<Guid>("CreatedById") | |
| 477 | + .HasColumnType("uuid"); | |
| 478 | + | |
| 479 | + b.Property<Guid>("DefaultCurrencyId") | |
| 480 | + .HasColumnType("uuid"); | |
| 481 | + | |
| 482 | + b.Property<string>("Description") | |
| 483 | + .HasColumnType("text"); | |
| 484 | + | |
| 485 | + b.Property<string>("Destination") | |
| 486 | + .HasMaxLength(200) | |
| 487 | + .HasColumnType("character varying(200)"); | |
| 488 | + | |
| 489 | + b.Property<DateTime?>("EndDate") | |
| 490 | + .HasColumnType("timestamp with time zone"); | |
| 491 | + | |
| 492 | + b.Property<string>("Name") | |
| 493 | + .IsRequired() | |
| 494 | + .HasMaxLength(200) | |
| 495 | + .HasColumnType("character varying(200)"); | |
| 496 | + | |
| 497 | + b.Property<DateTime?>("StartDate") | |
| 498 | + .HasColumnType("timestamp with time zone"); | |
| 499 | + | |
| 500 | + b.Property<int>("Status") | |
| 501 | + .HasColumnType("integer"); | |
| 502 | + | |
| 503 | + b.Property<DateTime>("UpdatedAt") | |
| 504 | + .HasColumnType("timestamp with time zone"); | |
| 505 | + | |
| 506 | + b.HasKey("Id"); | |
| 507 | + | |
| 508 | + b.HasIndex("CreatedById"); | |
| 509 | + | |
| 510 | + b.HasIndex("DefaultCurrencyId"); | |
| 511 | + | |
| 512 | + b.ToTable("Trips"); | |
| 513 | + }); | |
| 514 | + | |
| 515 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 516 | + { | |
| 517 | + b.Property<Guid>("Id") | |
| 518 | + .ValueGeneratedOnAdd() | |
| 519 | + .HasColumnType("uuid"); | |
| 520 | + | |
| 521 | + b.Property<DateTime>("CreatedAt") | |
| 522 | + .HasColumnType("timestamp with time zone"); | |
| 523 | + | |
| 524 | + b.Property<DateTime>("ExpiresAt") | |
| 525 | + .HasColumnType("timestamp with time zone"); | |
| 526 | + | |
| 527 | + b.Property<Guid>("InvitedByUserId") | |
| 528 | + .HasColumnType("uuid"); | |
| 529 | + | |
| 530 | + b.Property<DateTime?>("RespondedAt") | |
| 531 | + .HasColumnType("timestamp with time zone"); | |
| 532 | + | |
| 533 | + b.Property<int>("Status") | |
| 534 | + .HasColumnType("integer"); | |
| 535 | + | |
| 536 | + b.Property<string>("Token") | |
| 537 | + .IsRequired() | |
| 538 | + .HasMaxLength(256) | |
| 539 | + .HasColumnType("character varying(256)"); | |
| 540 | + | |
| 541 | + b.Property<Guid>("TripId") | |
| 542 | + .HasColumnType("uuid"); | |
| 543 | + | |
| 544 | + b.Property<DateTime>("UpdatedAt") | |
| 545 | + .HasColumnType("timestamp with time zone"); | |
| 546 | + | |
| 547 | + b.HasKey("Id"); | |
| 548 | + | |
| 549 | + b.HasIndex("InvitedByUserId"); | |
| 550 | + | |
| 551 | + b.HasIndex("Token") | |
| 552 | + .IsUnique(); | |
| 553 | + | |
| 554 | + b.HasIndex("TripId"); | |
| 555 | + | |
| 556 | + b.ToTable("TripInvitations"); | |
| 557 | + }); | |
| 558 | + | |
| 559 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 560 | + { | |
| 561 | + b.Property<Guid>("Id") | |
| 562 | + .ValueGeneratedOnAdd() | |
| 563 | + .HasColumnType("uuid"); | |
| 564 | + | |
| 565 | + b.Property<DateTime>("CreatedAt") | |
| 566 | + .HasColumnType("timestamp with time zone"); | |
| 567 | + | |
| 568 | + b.Property<bool>("IsActive") | |
| 569 | + .HasColumnType("boolean"); | |
| 570 | + | |
| 571 | + b.Property<DateTime>("JoinedAt") | |
| 572 | + .HasColumnType("timestamp with time zone"); | |
| 573 | + | |
| 574 | + b.Property<DateTime?>("LeftAt") | |
| 575 | + .HasColumnType("timestamp with time zone"); | |
| 576 | + | |
| 577 | + b.Property<string>("Nickname") | |
| 578 | + .HasMaxLength(100) | |
| 579 | + .HasColumnType("character varying(100)"); | |
| 580 | + | |
| 581 | + b.Property<int>("Role") | |
| 582 | + .HasColumnType("integer"); | |
| 583 | + | |
| 584 | + b.Property<Guid>("TripId") | |
| 585 | + .HasColumnType("uuid"); | |
| 586 | + | |
| 587 | + b.Property<DateTime>("UpdatedAt") | |
| 588 | + .HasColumnType("timestamp with time zone"); | |
| 589 | + | |
| 590 | + b.Property<Guid>("UserId") | |
| 591 | + .HasColumnType("uuid"); | |
| 592 | + | |
| 593 | + b.HasKey("Id"); | |
| 594 | + | |
| 595 | + b.HasIndex("UserId"); | |
| 596 | + | |
| 597 | + b.HasIndex("TripId", "UserId") | |
| 598 | + .IsUnique(); | |
| 599 | + | |
| 600 | + b.ToTable("TripParticipants"); | |
| 601 | + }); | |
| 602 | + | |
| 603 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 604 | + { | |
| 605 | + b.Property<Guid>("Id") | |
| 606 | + .ValueGeneratedOnAdd() | |
| 607 | + .HasColumnType("uuid"); | |
| 608 | + | |
| 609 | + b.Property<bool>("AllowMultipleVotes") | |
| 610 | + .HasColumnType("boolean"); | |
| 611 | + | |
| 612 | + b.Property<DateTime?>("ClosedAt") | |
| 613 | + .HasColumnType("timestamp with time zone"); | |
| 614 | + | |
| 615 | + b.Property<DateTime>("CreatedAt") | |
| 616 | + .HasColumnType("timestamp with time zone"); | |
| 617 | + | |
| 618 | + b.Property<Guid>("CreatedByUserId") | |
| 619 | + .HasColumnType("uuid"); | |
| 620 | + | |
| 621 | + b.Property<bool>("IsAnonymous") | |
| 622 | + .HasColumnType("boolean"); | |
| 623 | + | |
| 624 | + b.Property<string>("Question") | |
| 625 | + .IsRequired() | |
| 626 | + .HasMaxLength(500) | |
| 627 | + .HasColumnType("character varying(500)"); | |
| 628 | + | |
| 629 | + b.Property<Guid>("TripId") | |
| 630 | + .HasColumnType("uuid"); | |
| 631 | + | |
| 632 | + b.Property<DateTime>("UpdatedAt") | |
| 633 | + .HasColumnType("timestamp with time zone"); | |
| 634 | + | |
| 635 | + b.HasKey("Id"); | |
| 636 | + | |
| 637 | + b.HasIndex("CreatedByUserId"); | |
| 638 | + | |
| 639 | + b.HasIndex("TripId"); | |
| 640 | + | |
| 641 | + b.ToTable("TripPolls"); | |
| 642 | + }); | |
| 643 | + | |
| 644 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 645 | + { | |
| 646 | + b.Property<Guid>("Id") | |
| 647 | + .ValueGeneratedOnAdd() | |
| 648 | + .HasColumnType("uuid"); | |
| 649 | + | |
| 650 | + b.Property<DateTime>("CreatedAt") | |
| 651 | + .HasColumnType("timestamp with time zone"); | |
| 652 | + | |
| 653 | + b.Property<int>("DisplayOrder") | |
| 654 | + .HasColumnType("integer"); | |
| 655 | + | |
| 656 | + b.Property<Guid>("PollId") | |
| 657 | + .HasColumnType("uuid"); | |
| 658 | + | |
| 659 | + b.Property<string>("Text") | |
| 660 | + .IsRequired() | |
| 661 | + .HasMaxLength(300) | |
| 662 | + .HasColumnType("character varying(300)"); | |
| 663 | + | |
| 664 | + b.Property<DateTime>("UpdatedAt") | |
| 665 | + .HasColumnType("timestamp with time zone"); | |
| 666 | + | |
| 667 | + b.HasKey("Id"); | |
| 668 | + | |
| 669 | + b.HasIndex("PollId"); | |
| 670 | + | |
| 671 | + b.ToTable("TripPollOptions"); | |
| 672 | + }); | |
| 673 | + | |
| 674 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 675 | + { | |
| 676 | + b.Property<Guid>("Id") | |
| 677 | + .ValueGeneratedOnAdd() | |
| 678 | + .HasColumnType("uuid"); | |
| 679 | + | |
| 680 | + b.Property<DateTime>("CreatedAt") | |
| 681 | + .HasColumnType("timestamp with time zone"); | |
| 682 | + | |
| 683 | + b.Property<Guid>("PollOptionId") | |
| 684 | + .HasColumnType("uuid"); | |
| 685 | + | |
| 686 | + b.Property<DateTime>("UpdatedAt") | |
| 687 | + .HasColumnType("timestamp with time zone"); | |
| 688 | + | |
| 689 | + b.Property<Guid>("UserId") | |
| 690 | + .HasColumnType("uuid"); | |
| 691 | + | |
| 692 | + b.HasKey("Id"); | |
| 693 | + | |
| 694 | + b.HasIndex("UserId"); | |
| 695 | + | |
| 696 | + b.HasIndex("PollOptionId", "UserId") | |
| 697 | + .IsUnique(); | |
| 698 | + | |
| 699 | + b.ToTable("TripPollVotes"); | |
| 700 | + }); | |
| 701 | + | |
| 702 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 703 | + { | |
| 704 | + b.Property<Guid>("Id") | |
| 705 | + .ValueGeneratedOnAdd() | |
| 706 | + .HasColumnType("uuid"); | |
| 707 | + | |
| 708 | + b.Property<Guid>("AddedByUserId") | |
| 709 | + .HasColumnType("uuid"); | |
| 710 | + | |
| 711 | + b.Property<int>("Category") | |
| 712 | + .HasColumnType("integer"); | |
| 713 | + | |
| 714 | + b.Property<DateTime?>("CompletedAt") | |
| 715 | + .HasColumnType("timestamp with time zone"); | |
| 716 | + | |
| 717 | + b.Property<DateTime>("CreatedAt") | |
| 718 | + .HasColumnType("timestamp with time zone"); | |
| 719 | + | |
| 720 | + b.Property<string>("Description") | |
| 721 | + .HasColumnType("text"); | |
| 722 | + | |
| 723 | + b.Property<int>("DisplayOrder") | |
| 724 | + .HasColumnType("integer"); | |
| 725 | + | |
| 726 | + b.Property<decimal?>("EstimatedCost") | |
| 727 | + .HasColumnType("numeric"); | |
| 728 | + | |
| 729 | + b.Property<bool>("IsCompleted") | |
| 730 | + .HasColumnType("boolean"); | |
| 731 | + | |
| 732 | + b.Property<string>("Location") | |
| 733 | + .HasMaxLength(300) | |
| 734 | + .HasColumnType("character varying(300)"); | |
| 735 | + | |
| 736 | + b.Property<int>("Priority") | |
| 737 | + .HasColumnType("integer"); | |
| 738 | + | |
| 739 | + b.Property<string>("Title") | |
| 740 | + .IsRequired() | |
| 741 | + .HasMaxLength(200) | |
| 742 | + .HasColumnType("character varying(200)"); | |
| 743 | + | |
| 744 | + b.Property<Guid>("TripId") | |
| 745 | + .HasColumnType("uuid"); | |
| 746 | + | |
| 747 | + b.Property<DateTime>("UpdatedAt") | |
| 748 | + .HasColumnType("timestamp with time zone"); | |
| 749 | + | |
| 750 | + b.Property<string>("Url") | |
| 751 | + .HasMaxLength(500) | |
| 752 | + .HasColumnType("character varying(500)"); | |
| 753 | + | |
| 754 | + b.HasKey("Id"); | |
| 755 | + | |
| 756 | + b.HasIndex("AddedByUserId"); | |
| 757 | + | |
| 758 | + b.HasIndex("TripId"); | |
| 759 | + | |
| 760 | + b.ToTable("TripWishlistItems"); | |
| 761 | + }); | |
| 762 | + | |
| 763 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 764 | + { | |
| 765 | + b.Property<Guid>("Id") | |
| 766 | + .ValueGeneratedOnAdd() | |
| 767 | + .HasColumnType("uuid"); | |
| 768 | + | |
| 769 | + b.Property<DateTime>("CreatedAt") | |
| 770 | + .HasColumnType("timestamp with time zone"); | |
| 771 | + | |
| 772 | + b.Property<bool>("IsInterested") | |
| 773 | + .HasColumnType("boolean"); | |
| 774 | + | |
| 775 | + b.Property<DateTime>("UpdatedAt") | |
| 776 | + .HasColumnType("timestamp with time zone"); | |
| 777 | + | |
| 778 | + b.Property<Guid>("UserId") | |
| 779 | + .HasColumnType("uuid"); | |
| 780 | + | |
| 781 | + b.Property<Guid>("WishlistItemId") | |
| 782 | + .HasColumnType("uuid"); | |
| 783 | + | |
| 784 | + b.HasKey("Id"); | |
| 785 | + | |
| 786 | + b.HasIndex("UserId"); | |
| 787 | + | |
| 788 | + b.HasIndex("WishlistItemId", "UserId") | |
| 789 | + .IsUnique(); | |
| 790 | + | |
| 791 | + b.ToTable("TripWishlistVotes"); | |
| 792 | + }); | |
| 793 | + | |
| 794 | + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => | |
| 795 | + { | |
| 796 | + b.Property<int>("Id") | |
| 797 | + .ValueGeneratedOnAdd() | |
| 798 | + .HasColumnType("integer"); | |
| 799 | + | |
| 800 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 801 | + | |
| 802 | + b.Property<string>("FriendlyName") | |
| 803 | + .HasColumnType("text"); | |
| 804 | + | |
| 805 | + b.Property<string>("Xml") | |
| 806 | + .HasColumnType("text"); | |
| 807 | + | |
| 808 | + b.HasKey("Id"); | |
| 809 | + | |
| 810 | + b.ToTable("DataProtectionKeys"); | |
| 811 | + }); | |
| 812 | + | |
| 813 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 814 | + { | |
| 815 | + b.Property<int>("Id") | |
| 816 | + .ValueGeneratedOnAdd() | |
| 817 | + .HasColumnType("integer"); | |
| 818 | + | |
| 819 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 820 | + | |
| 821 | + b.Property<string>("ClaimType") | |
| 822 | + .HasColumnType("text"); | |
| 823 | + | |
| 824 | + b.Property<string>("ClaimValue") | |
| 825 | + .HasColumnType("text"); | |
| 826 | + | |
| 827 | + b.Property<Guid>("RoleId") | |
| 828 | + .HasColumnType("uuid"); | |
| 829 | + | |
| 830 | + b.HasKey("Id"); | |
| 831 | + | |
| 832 | + b.HasIndex("RoleId"); | |
| 833 | + | |
| 834 | + b.ToTable("AspNetRoleClaims", (string)null); | |
| 835 | + }); | |
| 836 | + | |
| 837 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 838 | + { | |
| 839 | + b.Property<int>("Id") | |
| 840 | + .ValueGeneratedOnAdd() | |
| 841 | + .HasColumnType("integer"); | |
| 842 | + | |
| 843 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 844 | + | |
| 845 | + b.Property<string>("ClaimType") | |
| 846 | + .HasColumnType("text"); | |
| 847 | + | |
| 848 | + b.Property<string>("ClaimValue") | |
| 849 | + .HasColumnType("text"); | |
| 850 | + | |
| 851 | + b.Property<Guid>("UserId") | |
| 852 | + .HasColumnType("uuid"); | |
| 853 | + | |
| 854 | + b.HasKey("Id"); | |
| 855 | + | |
| 856 | + b.HasIndex("UserId"); | |
| 857 | + | |
| 858 | + b.ToTable("AspNetUserClaims", (string)null); | |
| 859 | + }); | |
| 860 | + | |
| 861 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 862 | + { | |
| 863 | + b.Property<string>("LoginProvider") | |
| 864 | + .HasColumnType("text"); | |
| 865 | + | |
| 866 | + b.Property<string>("ProviderKey") | |
| 867 | + .HasColumnType("text"); | |
| 868 | + | |
| 869 | + b.Property<string>("ProviderDisplayName") | |
| 870 | + .HasColumnType("text"); | |
| 871 | + | |
| 872 | + b.Property<Guid>("UserId") | |
| 873 | + .HasColumnType("uuid"); | |
| 874 | + | |
| 875 | + b.HasKey("LoginProvider", "ProviderKey"); | |
| 876 | + | |
| 877 | + b.HasIndex("UserId"); | |
| 878 | + | |
| 879 | + b.ToTable("AspNetUserLogins", (string)null); | |
| 880 | + }); | |
| 881 | + | |
| 882 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 883 | + { | |
| 884 | + b.Property<Guid>("UserId") | |
| 885 | + .HasColumnType("uuid"); | |
| 886 | + | |
| 887 | + b.Property<Guid>("RoleId") | |
| 888 | + .HasColumnType("uuid"); | |
| 889 | + | |
| 890 | + b.HasKey("UserId", "RoleId"); | |
| 891 | + | |
| 892 | + b.HasIndex("RoleId"); | |
| 893 | + | |
| 894 | + b.ToTable("AspNetUserRoles", (string)null); | |
| 895 | + }); | |
| 896 | + | |
| 897 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 898 | + { | |
| 899 | + b.Property<Guid>("UserId") | |
| 900 | + .HasColumnType("uuid"); | |
| 901 | + | |
| 902 | + b.Property<string>("LoginProvider") | |
| 903 | + .HasColumnType("text"); | |
| 904 | + | |
| 905 | + b.Property<string>("Name") | |
| 906 | + .HasColumnType("text"); | |
| 907 | + | |
| 908 | + b.Property<string>("Value") | |
| 909 | + .HasColumnType("text"); | |
| 910 | + | |
| 911 | + b.HasKey("UserId", "LoginProvider", "Name"); | |
| 912 | + | |
| 913 | + b.ToTable("AspNetUserTokens", (string)null); | |
| 914 | + }); | |
| 915 | + | |
| 916 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 917 | + { | |
| 918 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 919 | + .WithMany("BudgetCategories") | |
| 920 | + .HasForeignKey("TripId") | |
| 921 | + .OnDelete(DeleteBehavior.Restrict) | |
| 922 | + .IsRequired(); | |
| 923 | + | |
| 924 | + b.Navigation("Trip"); | |
| 925 | + }); | |
| 926 | + | |
| 927 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 928 | + { | |
| 929 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 930 | + .WithMany("Expenses") | |
| 931 | + .HasForeignKey("BudgetCategoryId") | |
| 932 | + .OnDelete(DeleteBehavior.Restrict); | |
| 933 | + | |
| 934 | + b.HasOne("App.Domain.Currency", "Currency") | |
| 935 | + .WithMany() | |
| 936 | + .HasForeignKey("CurrencyId") | |
| 937 | + .OnDelete(DeleteBehavior.Restrict); | |
| 938 | + | |
| 939 | + b.HasOne("App.Domain.Identity.AppUser", "PaidByUser") | |
| 940 | + .WithMany() | |
| 941 | + .HasForeignKey("PaidByUserId") | |
| 942 | + .OnDelete(DeleteBehavior.Restrict) | |
| 943 | + .IsRequired(); | |
| 944 | + | |
| 945 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 946 | + .WithMany("Expenses") | |
| 947 | + .HasForeignKey("TripId") | |
| 948 | + .OnDelete(DeleteBehavior.Restrict) | |
| 949 | + .IsRequired(); | |
| 950 | + | |
| 951 | + b.Navigation("BudgetCategory"); | |
| 952 | + | |
| 953 | + b.Navigation("Currency"); | |
| 954 | + | |
| 955 | + b.Navigation("PaidByUser"); | |
| 956 | + | |
| 957 | + b.Navigation("Trip"); | |
| 958 | + }); | |
| 959 | + | |
| 960 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 961 | + { | |
| 962 | + b.HasOne("App.Domain.Expense", "Expense") | |
| 963 | + .WithMany("Splits") | |
| 964 | + .HasForeignKey("ExpenseId") | |
| 965 | + .OnDelete(DeleteBehavior.Restrict) | |
| 966 | + .IsRequired(); | |
| 967 | + | |
| 968 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 969 | + .WithMany() | |
| 970 | + .HasForeignKey("UserId") | |
| 971 | + .OnDelete(DeleteBehavior.Restrict) | |
| 972 | + .IsRequired(); | |
| 973 | + | |
| 974 | + b.Navigation("Expense"); | |
| 975 | + | |
| 976 | + b.Navigation("User"); | |
| 977 | + }); | |
| 978 | + | |
| 979 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 980 | + { | |
| 981 | + b.HasOne("App.Domain.Identity.AppUser", "AppUser") | |
| 982 | + .WithMany("RefreshTokens") | |
| 983 | + .HasForeignKey("AppUserId") | |
| 984 | + .OnDelete(DeleteBehavior.Restrict) | |
| 985 | + .IsRequired(); | |
| 986 | + | |
| 987 | + b.Navigation("AppUser"); | |
| 988 | + }); | |
| 989 | + | |
| 990 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 991 | + { | |
| 992 | + b.HasOne("App.Domain.Identity.AppUser", "FromUser") | |
| 993 | + .WithMany() | |
| 994 | + .HasForeignKey("FromUserId") | |
| 995 | + .OnDelete(DeleteBehavior.Restrict) | |
| 996 | + .IsRequired(); | |
| 997 | + | |
| 998 | + b.HasOne("App.Domain.SettlementPlan", "SettlementPlan") | |
| 999 | + .WithMany("Payments") | |
| 1000 | + .HasForeignKey("SettlementPlanId") | |
| 1001 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1002 | + .IsRequired(); | |
| 1003 | + | |
| 1004 | + b.HasOne("App.Domain.Identity.AppUser", "ToUser") | |
| 1005 | + .WithMany() | |
| 1006 | + .HasForeignKey("ToUserId") | |
| 1007 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1008 | + .IsRequired(); | |
| 1009 | + | |
| 1010 | + b.Navigation("FromUser"); | |
| 1011 | + | |
| 1012 | + b.Navigation("SettlementPlan"); | |
| 1013 | + | |
| 1014 | + b.Navigation("ToUser"); | |
| 1015 | + }); | |
| 1016 | + | |
| 1017 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1018 | + { | |
| 1019 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1020 | + .WithMany() | |
| 1021 | + .HasForeignKey("CreatedByUserId") | |
| 1022 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1023 | + .IsRequired(); | |
| 1024 | + | |
| 1025 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1026 | + .WithMany("SettlementPlans") | |
| 1027 | + .HasForeignKey("TripId") | |
| 1028 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1029 | + .IsRequired(); | |
| 1030 | + | |
| 1031 | + b.Navigation("CreatedByUser"); | |
| 1032 | + | |
| 1033 | + b.Navigation("Trip"); | |
| 1034 | + }); | |
| 1035 | + | |
| 1036 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1037 | + { | |
| 1038 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1039 | + .WithMany() | |
| 1040 | + .HasForeignKey("CreatedById") | |
| 1041 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1042 | + .IsRequired(); | |
| 1043 | + | |
| 1044 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1045 | + .WithMany() | |
| 1046 | + .HasForeignKey("TripId") | |
| 1047 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1048 | + .IsRequired(); | |
| 1049 | + | |
| 1050 | + b.Navigation("CreatedBy"); | |
| 1051 | + | |
| 1052 | + b.Navigation("Trip"); | |
| 1053 | + }); | |
| 1054 | + | |
| 1055 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 1056 | + { | |
| 1057 | + b.HasOne("App.Domain.SplitPreset", "SplitPreset") | |
| 1058 | + .WithMany("Members") | |
| 1059 | + .HasForeignKey("SplitPresetId") | |
| 1060 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1061 | + .IsRequired(); | |
| 1062 | + | |
| 1063 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1064 | + .WithMany() | |
| 1065 | + .HasForeignKey("UserId") | |
| 1066 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1067 | + .IsRequired(); | |
| 1068 | + | |
| 1069 | + b.Navigation("SplitPreset"); | |
| 1070 | + | |
| 1071 | + b.Navigation("User"); | |
| 1072 | + }); | |
| 1073 | + | |
| 1074 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1075 | + { | |
| 1076 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1077 | + .WithMany() | |
| 1078 | + .HasForeignKey("CreatedById") | |
| 1079 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1080 | + .IsRequired(); | |
| 1081 | + | |
| 1082 | + b.HasOne("App.Domain.Currency", "DefaultCurrency") | |
| 1083 | + .WithMany() | |
| 1084 | + .HasForeignKey("DefaultCurrencyId") | |
| 1085 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1086 | + .IsRequired(); | |
| 1087 | + | |
| 1088 | + b.Navigation("CreatedBy"); | |
| 1089 | + | |
| 1090 | + b.Navigation("DefaultCurrency"); | |
| 1091 | + }); | |
| 1092 | + | |
| 1093 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 1094 | + { | |
| 1095 | + b.HasOne("App.Domain.Identity.AppUser", "InvitedByUser") | |
| 1096 | + .WithMany() | |
| 1097 | + .HasForeignKey("InvitedByUserId") | |
| 1098 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1099 | + .IsRequired(); | |
| 1100 | + | |
| 1101 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1102 | + .WithMany("Invitations") | |
| 1103 | + .HasForeignKey("TripId") | |
| 1104 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1105 | + .IsRequired(); | |
| 1106 | + | |
| 1107 | + b.Navigation("InvitedByUser"); | |
| 1108 | + | |
| 1109 | + b.Navigation("Trip"); | |
| 1110 | + }); | |
| 1111 | + | |
| 1112 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 1113 | + { | |
| 1114 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1115 | + .WithMany("Participants") | |
| 1116 | + .HasForeignKey("TripId") | |
| 1117 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1118 | + .IsRequired(); | |
| 1119 | + | |
| 1120 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1121 | + .WithMany("TripParticipants") | |
| 1122 | + .HasForeignKey("UserId") | |
| 1123 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1124 | + .IsRequired(); | |
| 1125 | + | |
| 1126 | + b.Navigation("Trip"); | |
| 1127 | + | |
| 1128 | + b.Navigation("User"); | |
| 1129 | + }); | |
| 1130 | + | |
| 1131 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1132 | + { | |
| 1133 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1134 | + .WithMany() | |
| 1135 | + .HasForeignKey("CreatedByUserId") | |
| 1136 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1137 | + .IsRequired(); | |
| 1138 | + | |
| 1139 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1140 | + .WithMany("Polls") | |
| 1141 | + .HasForeignKey("TripId") | |
| 1142 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1143 | + .IsRequired(); | |
| 1144 | + | |
| 1145 | + b.Navigation("CreatedByUser"); | |
| 1146 | + | |
| 1147 | + b.Navigation("Trip"); | |
| 1148 | + }); | |
| 1149 | + | |
| 1150 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1151 | + { | |
| 1152 | + b.HasOne("App.Domain.TripPoll", "Poll") | |
| 1153 | + .WithMany("Options") | |
| 1154 | + .HasForeignKey("PollId") | |
| 1155 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1156 | + .IsRequired(); | |
| 1157 | + | |
| 1158 | + b.Navigation("Poll"); | |
| 1159 | + }); | |
| 1160 | + | |
| 1161 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 1162 | + { | |
| 1163 | + b.HasOne("App.Domain.TripPollOption", "PollOption") | |
| 1164 | + .WithMany("Votes") | |
| 1165 | + .HasForeignKey("PollOptionId") | |
| 1166 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1167 | + .IsRequired(); | |
| 1168 | + | |
| 1169 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1170 | + .WithMany() | |
| 1171 | + .HasForeignKey("UserId") | |
| 1172 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1173 | + .IsRequired(); | |
| 1174 | + | |
| 1175 | + b.Navigation("PollOption"); | |
| 1176 | + | |
| 1177 | + b.Navigation("User"); | |
| 1178 | + }); | |
| 1179 | + | |
| 1180 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1181 | + { | |
| 1182 | + b.HasOne("App.Domain.Identity.AppUser", "AddedByUser") | |
| 1183 | + .WithMany() | |
| 1184 | + .HasForeignKey("AddedByUserId") | |
| 1185 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1186 | + .IsRequired(); | |
| 1187 | + | |
| 1188 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1189 | + .WithMany("WishlistItems") | |
| 1190 | + .HasForeignKey("TripId") | |
| 1191 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1192 | + .IsRequired(); | |
| 1193 | + | |
| 1194 | + b.Navigation("AddedByUser"); | |
| 1195 | + | |
| 1196 | + b.Navigation("Trip"); | |
| 1197 | + }); | |
| 1198 | + | |
| 1199 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 1200 | + { | |
| 1201 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1202 | + .WithMany() | |
| 1203 | + .HasForeignKey("UserId") | |
| 1204 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1205 | + .IsRequired(); | |
| 1206 | + | |
| 1207 | + b.HasOne("App.Domain.TripWishlistItem", "WishlistItem") | |
| 1208 | + .WithMany("Votes") | |
| 1209 | + .HasForeignKey("WishlistItemId") | |
| 1210 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1211 | + .IsRequired(); | |
| 1212 | + | |
| 1213 | + b.Navigation("User"); | |
| 1214 | + | |
| 1215 | + b.Navigation("WishlistItem"); | |
| 1216 | + }); | |
| 1217 | + | |
| 1218 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 1219 | + { | |
| 1220 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1221 | + .WithMany() | |
| 1222 | + .HasForeignKey("RoleId") | |
| 1223 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1224 | + .IsRequired(); | |
| 1225 | + }); | |
| 1226 | + | |
| 1227 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 1228 | + { | |
| 1229 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1230 | + .WithMany() | |
| 1231 | + .HasForeignKey("UserId") | |
| 1232 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1233 | + .IsRequired(); | |
| 1234 | + }); | |
| 1235 | + | |
| 1236 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 1237 | + { | |
| 1238 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1239 | + .WithMany() | |
| 1240 | + .HasForeignKey("UserId") | |
| 1241 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1242 | + .IsRequired(); | |
| 1243 | + }); | |
| 1244 | + | |
| 1245 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 1246 | + { | |
| 1247 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1248 | + .WithMany() | |
| 1249 | + .HasForeignKey("RoleId") | |
| 1250 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1251 | + .IsRequired(); | |
| 1252 | + | |
| 1253 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1254 | + .WithMany() | |
| 1255 | + .HasForeignKey("UserId") | |
| 1256 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1257 | + .IsRequired(); | |
| 1258 | + }); | |
| 1259 | + | |
| 1260 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 1261 | + { | |
| 1262 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1263 | + .WithMany() | |
| 1264 | + .HasForeignKey("UserId") | |
| 1265 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1266 | + .IsRequired(); | |
| 1267 | + }); | |
| 1268 | + | |
| 1269 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 1270 | + { | |
| 1271 | + b.Navigation("Expenses"); | |
| 1272 | + }); | |
| 1273 | + | |
| 1274 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 1275 | + { | |
| 1276 | + b.Navigation("Splits"); | |
| 1277 | + }); | |
| 1278 | + | |
| 1279 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 1280 | + { | |
| 1281 | + b.Navigation("RefreshTokens"); | |
| 1282 | + | |
| 1283 | + b.Navigation("TripParticipants"); | |
| 1284 | + }); | |
| 1285 | + | |
| 1286 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1287 | + { | |
| 1288 | + b.Navigation("Payments"); | |
| 1289 | + }); | |
| 1290 | + | |
| 1291 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1292 | + { | |
| 1293 | + b.Navigation("Members"); | |
| 1294 | + }); | |
| 1295 | + | |
| 1296 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1297 | + { | |
| 1298 | + b.Navigation("BudgetCategories"); | |
| 1299 | + | |
| 1300 | + b.Navigation("Expenses"); | |
| 1301 | + | |
| 1302 | + b.Navigation("Invitations"); | |
| 1303 | + | |
| 1304 | + b.Navigation("Participants"); | |
| 1305 | + | |
| 1306 | + b.Navigation("Polls"); | |
| 1307 | + | |
| 1308 | + b.Navigation("SettlementPlans"); | |
| 1309 | + | |
| 1310 | + b.Navigation("WishlistItems"); | |
| 1311 | + }); | |
| 1312 | + | |
| 1313 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1314 | + { | |
| 1315 | + b.Navigation("Options"); | |
| 1316 | + }); | |
| 1317 | + | |
| 1318 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1319 | + { | |
| 1320 | + b.Navigation("Votes"); | |
| 1321 | + }); | |
| 1322 | + | |
| 1323 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1324 | + { | |
| 1325 | + b.Navigation("Votes"); | |
| 1326 | + }); | |
| 1327 | +#pragma warning restore 612, 618 | |
| 1328 | + } | |
| 1329 | + } | |
| 1330 | +} |
added SplitApp/App.DAL.EF/Migrations/20260402104505_RemoveUnusedBudgetCategoryTranslations.cs +49 −0
| @@ -0,0 +1,49 @@ | ||
| 1 | +using System; | |
| 2 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 3 | + | |
| 4 | +#nullable disable | |
| 5 | + | |
| 6 | +namespace App.DAL.EF.Migrations | |
| 7 | +{ | |
| 8 | + /// <inheritdoc /> | |
| 9 | + public partial class RemoveUnusedBudgetCategoryTranslations : Migration | |
| 10 | + { | |
| 11 | + /// <inheritdoc /> | |
| 12 | + protected override void Up(MigrationBuilder migrationBuilder) | |
| 13 | + { | |
| 14 | + migrationBuilder.DropTable( | |
| 15 | + name: "BudgetCategoryTranslations"); | |
| 16 | + } | |
| 17 | + | |
| 18 | + /// <inheritdoc /> | |
| 19 | + protected override void Down(MigrationBuilder migrationBuilder) | |
| 20 | + { | |
| 21 | + migrationBuilder.CreateTable( | |
| 22 | + name: "BudgetCategoryTranslations", | |
| 23 | + columns: table => new | |
| 24 | + { | |
| 25 | + Id = table.Column<Guid>(type: "uuid", nullable: false), | |
| 26 | + BudgetCategoryId = table.Column<Guid>(type: "uuid", nullable: false), | |
| 27 | + CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), | |
| 28 | + Culture = table.Column<string>(type: "character varying(5)", maxLength: 5, nullable: false), | |
| 29 | + Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false), | |
| 30 | + UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) | |
| 31 | + }, | |
| 32 | + constraints: table => | |
| 33 | + { | |
| 34 | + table.PrimaryKey("PK_BudgetCategoryTranslations", x => x.Id); | |
| 35 | + table.ForeignKey( | |
| 36 | + name: "FK_BudgetCategoryTranslations_BudgetCategories_BudgetCategoryId", | |
| 37 | + column: x => x.BudgetCategoryId, | |
| 38 | + principalTable: "BudgetCategories", | |
| 39 | + principalColumn: "Id", | |
| 40 | + onDelete: ReferentialAction.Restrict); | |
| 41 | + }); | |
| 42 | + | |
| 43 | + migrationBuilder.CreateIndex( | |
| 44 | + name: "IX_BudgetCategoryTranslations_BudgetCategoryId", | |
| 45 | + table: "BudgetCategoryTranslations", | |
| 46 | + column: "BudgetCategoryId"); | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} |
added SplitApp/App.DAL.EF/Migrations/20260410202112_BudgetCategoryNameToLangStr.Designer.cs +1330 −0
| @@ -0,0 +1,1330 @@ | ||
| 1 | +// <auto-generated /> | |
| 2 | +using System; | |
| 3 | +using App.DAL.EF; | |
| 4 | +using Microsoft.EntityFrameworkCore; | |
| 5 | +using Microsoft.EntityFrameworkCore.Infrastructure; | |
| 6 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 7 | +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | |
| 8 | +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | |
| 9 | + | |
| 10 | +#nullable disable | |
| 11 | + | |
| 12 | +namespace App.DAL.EF.Migrations | |
| 13 | +{ | |
| 14 | + [DbContext(typeof(AppDbContext))] | |
| 15 | + [Migration("20260410202112_BudgetCategoryNameToLangStr")] | |
| 16 | + partial class BudgetCategoryNameToLangStr | |
| 17 | + { | |
| 18 | + /// <inheritdoc /> | |
| 19 | + protected override void BuildTargetModel(ModelBuilder modelBuilder) | |
| 20 | + { | |
| 21 | +#pragma warning disable 612, 618 | |
| 22 | + modelBuilder | |
| 23 | + .HasAnnotation("ProductVersion", "10.0.5") | |
| 24 | + .HasAnnotation("Relational:MaxIdentifierLength", 63); | |
| 25 | + | |
| 26 | + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | |
| 27 | + | |
| 28 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 29 | + { | |
| 30 | + b.Property<Guid>("Id") | |
| 31 | + .ValueGeneratedOnAdd() | |
| 32 | + .HasColumnType("uuid"); | |
| 33 | + | |
| 34 | + b.Property<DateTime>("CreatedAt") | |
| 35 | + .HasColumnType("timestamp with time zone"); | |
| 36 | + | |
| 37 | + b.Property<int>("DisplayOrder") | |
| 38 | + .HasColumnType("integer"); | |
| 39 | + | |
| 40 | + b.Property<string>("IconName") | |
| 41 | + .HasMaxLength(100) | |
| 42 | + .HasColumnType("character varying(100)"); | |
| 43 | + | |
| 44 | + b.Property<string>("Name") | |
| 45 | + .IsRequired() | |
| 46 | + .HasMaxLength(1024) | |
| 47 | + .HasColumnType("character varying(1024)"); | |
| 48 | + | |
| 49 | + b.Property<decimal?>("PlannedAmount") | |
| 50 | + .HasColumnType("numeric"); | |
| 51 | + | |
| 52 | + b.Property<Guid>("TripId") | |
| 53 | + .HasColumnType("uuid"); | |
| 54 | + | |
| 55 | + b.Property<DateTime>("UpdatedAt") | |
| 56 | + .HasColumnType("timestamp with time zone"); | |
| 57 | + | |
| 58 | + b.HasKey("Id"); | |
| 59 | + | |
| 60 | + b.HasIndex("TripId"); | |
| 61 | + | |
| 62 | + b.ToTable("BudgetCategories"); | |
| 63 | + }); | |
| 64 | + | |
| 65 | + modelBuilder.Entity("App.Domain.Currency", b => | |
| 66 | + { | |
| 67 | + b.Property<Guid>("Id") | |
| 68 | + .ValueGeneratedOnAdd() | |
| 69 | + .HasColumnType("uuid"); | |
| 70 | + | |
| 71 | + b.Property<string>("Code") | |
| 72 | + .IsRequired() | |
| 73 | + .HasMaxLength(3) | |
| 74 | + .HasColumnType("character varying(3)"); | |
| 75 | + | |
| 76 | + b.Property<DateTime>("CreatedAt") | |
| 77 | + .HasColumnType("timestamp with time zone"); | |
| 78 | + | |
| 79 | + b.Property<string>("Name") | |
| 80 | + .IsRequired() | |
| 81 | + .HasMaxLength(1024) | |
| 82 | + .HasColumnType("character varying(1024)"); | |
| 83 | + | |
| 84 | + b.Property<string>("Symbol") | |
| 85 | + .IsRequired() | |
| 86 | + .HasMaxLength(10) | |
| 87 | + .HasColumnType("character varying(10)"); | |
| 88 | + | |
| 89 | + b.Property<DateTime>("UpdatedAt") | |
| 90 | + .HasColumnType("timestamp with time zone"); | |
| 91 | + | |
| 92 | + b.HasKey("Id"); | |
| 93 | + | |
| 94 | + b.ToTable("Currencies"); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 98 | + { | |
| 99 | + b.Property<Guid>("Id") | |
| 100 | + .ValueGeneratedOnAdd() | |
| 101 | + .HasColumnType("uuid"); | |
| 102 | + | |
| 103 | + b.Property<decimal>("Amount") | |
| 104 | + .HasColumnType("numeric"); | |
| 105 | + | |
| 106 | + b.Property<Guid?>("BudgetCategoryId") | |
| 107 | + .HasColumnType("uuid"); | |
| 108 | + | |
| 109 | + b.Property<DateTime>("CreatedAt") | |
| 110 | + .HasColumnType("timestamp with time zone"); | |
| 111 | + | |
| 112 | + b.Property<Guid?>("CurrencyId") | |
| 113 | + .HasColumnType("uuid"); | |
| 114 | + | |
| 115 | + b.Property<string>("Description") | |
| 116 | + .HasMaxLength(500) | |
| 117 | + .HasColumnType("character varying(500)"); | |
| 118 | + | |
| 119 | + b.Property<DateTime>("ExpenseDate") | |
| 120 | + .HasColumnType("timestamp with time zone"); | |
| 121 | + | |
| 122 | + b.Property<Guid>("PaidByUserId") | |
| 123 | + .HasColumnType("uuid"); | |
| 124 | + | |
| 125 | + b.Property<int>("SplitMethod") | |
| 126 | + .HasColumnType("integer"); | |
| 127 | + | |
| 128 | + b.Property<Guid>("TripId") | |
| 129 | + .HasColumnType("uuid"); | |
| 130 | + | |
| 131 | + b.Property<DateTime>("UpdatedAt") | |
| 132 | + .HasColumnType("timestamp with time zone"); | |
| 133 | + | |
| 134 | + b.HasKey("Id"); | |
| 135 | + | |
| 136 | + b.HasIndex("BudgetCategoryId"); | |
| 137 | + | |
| 138 | + b.HasIndex("CurrencyId"); | |
| 139 | + | |
| 140 | + b.HasIndex("PaidByUserId"); | |
| 141 | + | |
| 142 | + b.HasIndex("TripId"); | |
| 143 | + | |
| 144 | + b.ToTable("Expenses"); | |
| 145 | + }); | |
| 146 | + | |
| 147 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 148 | + { | |
| 149 | + b.Property<Guid>("Id") | |
| 150 | + .ValueGeneratedOnAdd() | |
| 151 | + .HasColumnType("uuid"); | |
| 152 | + | |
| 153 | + b.Property<decimal>("Amount") | |
| 154 | + .HasColumnType("numeric"); | |
| 155 | + | |
| 156 | + b.Property<DateTime>("CreatedAt") | |
| 157 | + .HasColumnType("timestamp with time zone"); | |
| 158 | + | |
| 159 | + b.Property<Guid>("ExpenseId") | |
| 160 | + .HasColumnType("uuid"); | |
| 161 | + | |
| 162 | + b.Property<decimal?>("Percentage") | |
| 163 | + .HasColumnType("numeric"); | |
| 164 | + | |
| 165 | + b.Property<DateTime>("UpdatedAt") | |
| 166 | + .HasColumnType("timestamp with time zone"); | |
| 167 | + | |
| 168 | + b.Property<Guid>("UserId") | |
| 169 | + .HasColumnType("uuid"); | |
| 170 | + | |
| 171 | + b.HasKey("Id"); | |
| 172 | + | |
| 173 | + b.HasIndex("ExpenseId"); | |
| 174 | + | |
| 175 | + b.HasIndex("UserId"); | |
| 176 | + | |
| 177 | + b.ToTable("ExpenseSplits"); | |
| 178 | + }); | |
| 179 | + | |
| 180 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 181 | + { | |
| 182 | + b.Property<Guid>("Id") | |
| 183 | + .ValueGeneratedOnAdd() | |
| 184 | + .HasColumnType("uuid"); | |
| 185 | + | |
| 186 | + b.Property<Guid>("AppUserId") | |
| 187 | + .HasColumnType("uuid"); | |
| 188 | + | |
| 189 | + b.Property<DateTime>("CreatedAt") | |
| 190 | + .HasColumnType("timestamp with time zone"); | |
| 191 | + | |
| 192 | + b.Property<DateTime>("ExpirationDT") | |
| 193 | + .HasColumnType("timestamp with time zone"); | |
| 194 | + | |
| 195 | + b.Property<DateTime>("PreviousExpirationDT") | |
| 196 | + .HasColumnType("timestamp with time zone"); | |
| 197 | + | |
| 198 | + b.Property<string>("PreviousRefreshToken") | |
| 199 | + .HasMaxLength(64) | |
| 200 | + .HasColumnType("character varying(64)"); | |
| 201 | + | |
| 202 | + b.Property<string>("RefreshToken") | |
| 203 | + .IsRequired() | |
| 204 | + .HasMaxLength(64) | |
| 205 | + .HasColumnType("character varying(64)"); | |
| 206 | + | |
| 207 | + b.Property<DateTime>("UpdatedAt") | |
| 208 | + .HasColumnType("timestamp with time zone"); | |
| 209 | + | |
| 210 | + b.HasKey("Id"); | |
| 211 | + | |
| 212 | + b.HasIndex("AppUserId"); | |
| 213 | + | |
| 214 | + b.ToTable("RefreshTokens"); | |
| 215 | + }); | |
| 216 | + | |
| 217 | + modelBuilder.Entity("App.Domain.Identity.AppRole", b => | |
| 218 | + { | |
| 219 | + b.Property<Guid>("Id") | |
| 220 | + .ValueGeneratedOnAdd() | |
| 221 | + .HasColumnType("uuid"); | |
| 222 | + | |
| 223 | + b.Property<string>("ConcurrencyStamp") | |
| 224 | + .IsConcurrencyToken() | |
| 225 | + .HasColumnType("text"); | |
| 226 | + | |
| 227 | + b.Property<string>("Name") | |
| 228 | + .HasMaxLength(256) | |
| 229 | + .HasColumnType("character varying(256)"); | |
| 230 | + | |
| 231 | + b.Property<string>("NormalizedName") | |
| 232 | + .HasMaxLength(256) | |
| 233 | + .HasColumnType("character varying(256)"); | |
| 234 | + | |
| 235 | + b.HasKey("Id"); | |
| 236 | + | |
| 237 | + b.HasIndex("NormalizedName") | |
| 238 | + .IsUnique() | |
| 239 | + .HasDatabaseName("RoleNameIndex"); | |
| 240 | + | |
| 241 | + b.ToTable("AspNetRoles", (string)null); | |
| 242 | + }); | |
| 243 | + | |
| 244 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 245 | + { | |
| 246 | + b.Property<Guid>("Id") | |
| 247 | + .ValueGeneratedOnAdd() | |
| 248 | + .HasColumnType("uuid"); | |
| 249 | + | |
| 250 | + b.Property<int>("AccessFailedCount") | |
| 251 | + .HasColumnType("integer"); | |
| 252 | + | |
| 253 | + b.Property<string>("ConcurrencyStamp") | |
| 254 | + .IsConcurrencyToken() | |
| 255 | + .HasColumnType("text"); | |
| 256 | + | |
| 257 | + b.Property<string>("Email") | |
| 258 | + .HasMaxLength(256) | |
| 259 | + .HasColumnType("character varying(256)"); | |
| 260 | + | |
| 261 | + b.Property<bool>("EmailConfirmed") | |
| 262 | + .HasColumnType("boolean"); | |
| 263 | + | |
| 264 | + b.Property<string>("FirstName") | |
| 265 | + .IsRequired() | |
| 266 | + .HasMaxLength(128) | |
| 267 | + .HasColumnType("character varying(128)"); | |
| 268 | + | |
| 269 | + b.Property<string>("LastName") | |
| 270 | + .IsRequired() | |
| 271 | + .HasMaxLength(128) | |
| 272 | + .HasColumnType("character varying(128)"); | |
| 273 | + | |
| 274 | + b.Property<bool>("LockoutEnabled") | |
| 275 | + .HasColumnType("boolean"); | |
| 276 | + | |
| 277 | + b.Property<DateTimeOffset?>("LockoutEnd") | |
| 278 | + .HasColumnType("timestamp with time zone"); | |
| 279 | + | |
| 280 | + b.Property<string>("NormalizedEmail") | |
| 281 | + .HasMaxLength(256) | |
| 282 | + .HasColumnType("character varying(256)"); | |
| 283 | + | |
| 284 | + b.Property<string>("NormalizedUserName") | |
| 285 | + .HasMaxLength(256) | |
| 286 | + .HasColumnType("character varying(256)"); | |
| 287 | + | |
| 288 | + b.Property<string>("PasswordHash") | |
| 289 | + .HasColumnType("text"); | |
| 290 | + | |
| 291 | + b.Property<string>("PhoneNumber") | |
| 292 | + .HasColumnType("text"); | |
| 293 | + | |
| 294 | + b.Property<bool>("PhoneNumberConfirmed") | |
| 295 | + .HasColumnType("boolean"); | |
| 296 | + | |
| 297 | + b.Property<string>("SecurityStamp") | |
| 298 | + .HasColumnType("text"); | |
| 299 | + | |
| 300 | + b.Property<bool>("TwoFactorEnabled") | |
| 301 | + .HasColumnType("boolean"); | |
| 302 | + | |
| 303 | + b.Property<string>("UserName") | |
| 304 | + .HasMaxLength(256) | |
| 305 | + .HasColumnType("character varying(256)"); | |
| 306 | + | |
| 307 | + b.HasKey("Id"); | |
| 308 | + | |
| 309 | + b.HasIndex("NormalizedEmail") | |
| 310 | + .HasDatabaseName("EmailIndex"); | |
| 311 | + | |
| 312 | + b.HasIndex("NormalizedUserName") | |
| 313 | + .IsUnique() | |
| 314 | + .HasDatabaseName("UserNameIndex"); | |
| 315 | + | |
| 316 | + b.ToTable("AspNetUsers", (string)null); | |
| 317 | + }); | |
| 318 | + | |
| 319 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 320 | + { | |
| 321 | + b.Property<Guid>("Id") | |
| 322 | + .ValueGeneratedOnAdd() | |
| 323 | + .HasColumnType("uuid"); | |
| 324 | + | |
| 325 | + b.Property<decimal>("Amount") | |
| 326 | + .HasColumnType("numeric"); | |
| 327 | + | |
| 328 | + b.Property<DateTime?>("ConfirmedAt") | |
| 329 | + .HasColumnType("timestamp with time zone"); | |
| 330 | + | |
| 331 | + b.Property<DateTime>("CreatedAt") | |
| 332 | + .HasColumnType("timestamp with time zone"); | |
| 333 | + | |
| 334 | + b.Property<Guid>("FromUserId") | |
| 335 | + .HasColumnType("uuid"); | |
| 336 | + | |
| 337 | + b.Property<DateTime?>("MarkedPaidAt") | |
| 338 | + .HasColumnType("timestamp with time zone"); | |
| 339 | + | |
| 340 | + b.Property<Guid>("SettlementPlanId") | |
| 341 | + .HasColumnType("uuid"); | |
| 342 | + | |
| 343 | + b.Property<int>("Status") | |
| 344 | + .HasColumnType("integer"); | |
| 345 | + | |
| 346 | + b.Property<Guid>("ToUserId") | |
| 347 | + .HasColumnType("uuid"); | |
| 348 | + | |
| 349 | + b.Property<DateTime>("UpdatedAt") | |
| 350 | + .HasColumnType("timestamp with time zone"); | |
| 351 | + | |
| 352 | + b.HasKey("Id"); | |
| 353 | + | |
| 354 | + b.HasIndex("FromUserId"); | |
| 355 | + | |
| 356 | + b.HasIndex("SettlementPlanId"); | |
| 357 | + | |
| 358 | + b.HasIndex("ToUserId"); | |
| 359 | + | |
| 360 | + b.ToTable("SettlementPayments"); | |
| 361 | + }); | |
| 362 | + | |
| 363 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 364 | + { | |
| 365 | + b.Property<Guid>("Id") | |
| 366 | + .ValueGeneratedOnAdd() | |
| 367 | + .HasColumnType("uuid"); | |
| 368 | + | |
| 369 | + b.Property<DateTime?>("CompletedAt") | |
| 370 | + .HasColumnType("timestamp with time zone"); | |
| 371 | + | |
| 372 | + b.Property<DateTime>("CreatedAt") | |
| 373 | + .HasColumnType("timestamp with time zone"); | |
| 374 | + | |
| 375 | + b.Property<Guid>("CreatedByUserId") | |
| 376 | + .HasColumnType("uuid"); | |
| 377 | + | |
| 378 | + b.Property<int>("Status") | |
| 379 | + .HasColumnType("integer"); | |
| 380 | + | |
| 381 | + b.Property<decimal>("TotalAmount") | |
| 382 | + .HasColumnType("numeric"); | |
| 383 | + | |
| 384 | + b.Property<Guid>("TripId") | |
| 385 | + .HasColumnType("uuid"); | |
| 386 | + | |
| 387 | + b.Property<DateTime>("UpdatedAt") | |
| 388 | + .HasColumnType("timestamp with time zone"); | |
| 389 | + | |
| 390 | + b.HasKey("Id"); | |
| 391 | + | |
| 392 | + b.HasIndex("CreatedByUserId"); | |
| 393 | + | |
| 394 | + b.HasIndex("TripId"); | |
| 395 | + | |
| 396 | + b.ToTable("SettlementPlans"); | |
| 397 | + }); | |
| 398 | + | |
| 399 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 400 | + { | |
| 401 | + b.Property<Guid>("Id") | |
| 402 | + .ValueGeneratedOnAdd() | |
| 403 | + .HasColumnType("uuid"); | |
| 404 | + | |
| 405 | + b.Property<DateTime>("CreatedAt") | |
| 406 | + .HasColumnType("timestamp with time zone"); | |
| 407 | + | |
| 408 | + b.Property<Guid>("CreatedById") | |
| 409 | + .HasColumnType("uuid"); | |
| 410 | + | |
| 411 | + b.Property<string>("Name") | |
| 412 | + .IsRequired() | |
| 413 | + .HasMaxLength(200) | |
| 414 | + .HasColumnType("character varying(200)"); | |
| 415 | + | |
| 416 | + b.Property<int>("SplitMethod") | |
| 417 | + .HasColumnType("integer"); | |
| 418 | + | |
| 419 | + b.Property<Guid>("TripId") | |
| 420 | + .HasColumnType("uuid"); | |
| 421 | + | |
| 422 | + b.Property<DateTime>("UpdatedAt") | |
| 423 | + .HasColumnType("timestamp with time zone"); | |
| 424 | + | |
| 425 | + b.HasKey("Id"); | |
| 426 | + | |
| 427 | + b.HasIndex("CreatedById"); | |
| 428 | + | |
| 429 | + b.HasIndex("TripId"); | |
| 430 | + | |
| 431 | + b.ToTable("SplitPresets"); | |
| 432 | + }); | |
| 433 | + | |
| 434 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 435 | + { | |
| 436 | + b.Property<Guid>("Id") | |
| 437 | + .ValueGeneratedOnAdd() | |
| 438 | + .HasColumnType("uuid"); | |
| 439 | + | |
| 440 | + b.Property<DateTime>("CreatedAt") | |
| 441 | + .HasColumnType("timestamp with time zone"); | |
| 442 | + | |
| 443 | + b.Property<decimal?>("Percentage") | |
| 444 | + .HasColumnType("numeric"); | |
| 445 | + | |
| 446 | + b.Property<decimal?>("ShareWeight") | |
| 447 | + .HasColumnType("numeric"); | |
| 448 | + | |
| 449 | + b.Property<Guid>("SplitPresetId") | |
| 450 | + .HasColumnType("uuid"); | |
| 451 | + | |
| 452 | + b.Property<DateTime>("UpdatedAt") | |
| 453 | + .HasColumnType("timestamp with time zone"); | |
| 454 | + | |
| 455 | + b.Property<Guid>("UserId") | |
| 456 | + .HasColumnType("uuid"); | |
| 457 | + | |
| 458 | + b.HasKey("Id"); | |
| 459 | + | |
| 460 | + b.HasIndex("SplitPresetId"); | |
| 461 | + | |
| 462 | + b.HasIndex("UserId"); | |
| 463 | + | |
| 464 | + b.ToTable("SplitPresetMembers"); | |
| 465 | + }); | |
| 466 | + | |
| 467 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 468 | + { | |
| 469 | + b.Property<Guid>("Id") | |
| 470 | + .ValueGeneratedOnAdd() | |
| 471 | + .HasColumnType("uuid"); | |
| 472 | + | |
| 473 | + b.Property<DateTime>("CreatedAt") | |
| 474 | + .HasColumnType("timestamp with time zone"); | |
| 475 | + | |
| 476 | + b.Property<Guid>("CreatedById") | |
| 477 | + .HasColumnType("uuid"); | |
| 478 | + | |
| 479 | + b.Property<Guid>("DefaultCurrencyId") | |
| 480 | + .HasColumnType("uuid"); | |
| 481 | + | |
| 482 | + b.Property<string>("Description") | |
| 483 | + .HasColumnType("text"); | |
| 484 | + | |
| 485 | + b.Property<string>("Destination") | |
| 486 | + .HasMaxLength(200) | |
| 487 | + .HasColumnType("character varying(200)"); | |
| 488 | + | |
| 489 | + b.Property<DateTime?>("EndDate") | |
| 490 | + .HasColumnType("timestamp with time zone"); | |
| 491 | + | |
| 492 | + b.Property<string>("Name") | |
| 493 | + .IsRequired() | |
| 494 | + .HasMaxLength(200) | |
| 495 | + .HasColumnType("character varying(200)"); | |
| 496 | + | |
| 497 | + b.Property<DateTime?>("StartDate") | |
| 498 | + .HasColumnType("timestamp with time zone"); | |
| 499 | + | |
| 500 | + b.Property<int>("Status") | |
| 501 | + .HasColumnType("integer"); | |
| 502 | + | |
| 503 | + b.Property<DateTime>("UpdatedAt") | |
| 504 | + .HasColumnType("timestamp with time zone"); | |
| 505 | + | |
| 506 | + b.HasKey("Id"); | |
| 507 | + | |
| 508 | + b.HasIndex("CreatedById"); | |
| 509 | + | |
| 510 | + b.HasIndex("DefaultCurrencyId"); | |
| 511 | + | |
| 512 | + b.ToTable("Trips"); | |
| 513 | + }); | |
| 514 | + | |
| 515 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 516 | + { | |
| 517 | + b.Property<Guid>("Id") | |
| 518 | + .ValueGeneratedOnAdd() | |
| 519 | + .HasColumnType("uuid"); | |
| 520 | + | |
| 521 | + b.Property<DateTime>("CreatedAt") | |
| 522 | + .HasColumnType("timestamp with time zone"); | |
| 523 | + | |
| 524 | + b.Property<DateTime>("ExpiresAt") | |
| 525 | + .HasColumnType("timestamp with time zone"); | |
| 526 | + | |
| 527 | + b.Property<Guid>("InvitedByUserId") | |
| 528 | + .HasColumnType("uuid"); | |
| 529 | + | |
| 530 | + b.Property<DateTime?>("RespondedAt") | |
| 531 | + .HasColumnType("timestamp with time zone"); | |
| 532 | + | |
| 533 | + b.Property<int>("Status") | |
| 534 | + .HasColumnType("integer"); | |
| 535 | + | |
| 536 | + b.Property<string>("Token") | |
| 537 | + .IsRequired() | |
| 538 | + .HasMaxLength(256) | |
| 539 | + .HasColumnType("character varying(256)"); | |
| 540 | + | |
| 541 | + b.Property<Guid>("TripId") | |
| 542 | + .HasColumnType("uuid"); | |
| 543 | + | |
| 544 | + b.Property<DateTime>("UpdatedAt") | |
| 545 | + .HasColumnType("timestamp with time zone"); | |
| 546 | + | |
| 547 | + b.HasKey("Id"); | |
| 548 | + | |
| 549 | + b.HasIndex("InvitedByUserId"); | |
| 550 | + | |
| 551 | + b.HasIndex("Token") | |
| 552 | + .IsUnique(); | |
| 553 | + | |
| 554 | + b.HasIndex("TripId"); | |
| 555 | + | |
| 556 | + b.ToTable("TripInvitations"); | |
| 557 | + }); | |
| 558 | + | |
| 559 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 560 | + { | |
| 561 | + b.Property<Guid>("Id") | |
| 562 | + .ValueGeneratedOnAdd() | |
| 563 | + .HasColumnType("uuid"); | |
| 564 | + | |
| 565 | + b.Property<DateTime>("CreatedAt") | |
| 566 | + .HasColumnType("timestamp with time zone"); | |
| 567 | + | |
| 568 | + b.Property<bool>("IsActive") | |
| 569 | + .HasColumnType("boolean"); | |
| 570 | + | |
| 571 | + b.Property<DateTime>("JoinedAt") | |
| 572 | + .HasColumnType("timestamp with time zone"); | |
| 573 | + | |
| 574 | + b.Property<DateTime?>("LeftAt") | |
| 575 | + .HasColumnType("timestamp with time zone"); | |
| 576 | + | |
| 577 | + b.Property<string>("Nickname") | |
| 578 | + .HasMaxLength(100) | |
| 579 | + .HasColumnType("character varying(100)"); | |
| 580 | + | |
| 581 | + b.Property<int>("Role") | |
| 582 | + .HasColumnType("integer"); | |
| 583 | + | |
| 584 | + b.Property<Guid>("TripId") | |
| 585 | + .HasColumnType("uuid"); | |
| 586 | + | |
| 587 | + b.Property<DateTime>("UpdatedAt") | |
| 588 | + .HasColumnType("timestamp with time zone"); | |
| 589 | + | |
| 590 | + b.Property<Guid>("UserId") | |
| 591 | + .HasColumnType("uuid"); | |
| 592 | + | |
| 593 | + b.HasKey("Id"); | |
| 594 | + | |
| 595 | + b.HasIndex("UserId"); | |
| 596 | + | |
| 597 | + b.HasIndex("TripId", "UserId") | |
| 598 | + .IsUnique(); | |
| 599 | + | |
| 600 | + b.ToTable("TripParticipants"); | |
| 601 | + }); | |
| 602 | + | |
| 603 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 604 | + { | |
| 605 | + b.Property<Guid>("Id") | |
| 606 | + .ValueGeneratedOnAdd() | |
| 607 | + .HasColumnType("uuid"); | |
| 608 | + | |
| 609 | + b.Property<bool>("AllowMultipleVotes") | |
| 610 | + .HasColumnType("boolean"); | |
| 611 | + | |
| 612 | + b.Property<DateTime?>("ClosedAt") | |
| 613 | + .HasColumnType("timestamp with time zone"); | |
| 614 | + | |
| 615 | + b.Property<DateTime>("CreatedAt") | |
| 616 | + .HasColumnType("timestamp with time zone"); | |
| 617 | + | |
| 618 | + b.Property<Guid>("CreatedByUserId") | |
| 619 | + .HasColumnType("uuid"); | |
| 620 | + | |
| 621 | + b.Property<bool>("IsAnonymous") | |
| 622 | + .HasColumnType("boolean"); | |
| 623 | + | |
| 624 | + b.Property<string>("Question") | |
| 625 | + .IsRequired() | |
| 626 | + .HasMaxLength(500) | |
| 627 | + .HasColumnType("character varying(500)"); | |
| 628 | + | |
| 629 | + b.Property<Guid>("TripId") | |
| 630 | + .HasColumnType("uuid"); | |
| 631 | + | |
| 632 | + b.Property<DateTime>("UpdatedAt") | |
| 633 | + .HasColumnType("timestamp with time zone"); | |
| 634 | + | |
| 635 | + b.HasKey("Id"); | |
| 636 | + | |
| 637 | + b.HasIndex("CreatedByUserId"); | |
| 638 | + | |
| 639 | + b.HasIndex("TripId"); | |
| 640 | + | |
| 641 | + b.ToTable("TripPolls"); | |
| 642 | + }); | |
| 643 | + | |
| 644 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 645 | + { | |
| 646 | + b.Property<Guid>("Id") | |
| 647 | + .ValueGeneratedOnAdd() | |
| 648 | + .HasColumnType("uuid"); | |
| 649 | + | |
| 650 | + b.Property<DateTime>("CreatedAt") | |
| 651 | + .HasColumnType("timestamp with time zone"); | |
| 652 | + | |
| 653 | + b.Property<int>("DisplayOrder") | |
| 654 | + .HasColumnType("integer"); | |
| 655 | + | |
| 656 | + b.Property<Guid>("PollId") | |
| 657 | + .HasColumnType("uuid"); | |
| 658 | + | |
| 659 | + b.Property<string>("Text") | |
| 660 | + .IsRequired() | |
| 661 | + .HasMaxLength(300) | |
| 662 | + .HasColumnType("character varying(300)"); | |
| 663 | + | |
| 664 | + b.Property<DateTime>("UpdatedAt") | |
| 665 | + .HasColumnType("timestamp with time zone"); | |
| 666 | + | |
| 667 | + b.HasKey("Id"); | |
| 668 | + | |
| 669 | + b.HasIndex("PollId"); | |
| 670 | + | |
| 671 | + b.ToTable("TripPollOptions"); | |
| 672 | + }); | |
| 673 | + | |
| 674 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 675 | + { | |
| 676 | + b.Property<Guid>("Id") | |
| 677 | + .ValueGeneratedOnAdd() | |
| 678 | + .HasColumnType("uuid"); | |
| 679 | + | |
| 680 | + b.Property<DateTime>("CreatedAt") | |
| 681 | + .HasColumnType("timestamp with time zone"); | |
| 682 | + | |
| 683 | + b.Property<Guid>("PollOptionId") | |
| 684 | + .HasColumnType("uuid"); | |
| 685 | + | |
| 686 | + b.Property<DateTime>("UpdatedAt") | |
| 687 | + .HasColumnType("timestamp with time zone"); | |
| 688 | + | |
| 689 | + b.Property<Guid>("UserId") | |
| 690 | + .HasColumnType("uuid"); | |
| 691 | + | |
| 692 | + b.HasKey("Id"); | |
| 693 | + | |
| 694 | + b.HasIndex("UserId"); | |
| 695 | + | |
| 696 | + b.HasIndex("PollOptionId", "UserId") | |
| 697 | + .IsUnique(); | |
| 698 | + | |
| 699 | + b.ToTable("TripPollVotes"); | |
| 700 | + }); | |
| 701 | + | |
| 702 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 703 | + { | |
| 704 | + b.Property<Guid>("Id") | |
| 705 | + .ValueGeneratedOnAdd() | |
| 706 | + .HasColumnType("uuid"); | |
| 707 | + | |
| 708 | + b.Property<Guid>("AddedByUserId") | |
| 709 | + .HasColumnType("uuid"); | |
| 710 | + | |
| 711 | + b.Property<int>("Category") | |
| 712 | + .HasColumnType("integer"); | |
| 713 | + | |
| 714 | + b.Property<DateTime?>("CompletedAt") | |
| 715 | + .HasColumnType("timestamp with time zone"); | |
| 716 | + | |
| 717 | + b.Property<DateTime>("CreatedAt") | |
| 718 | + .HasColumnType("timestamp with time zone"); | |
| 719 | + | |
| 720 | + b.Property<string>("Description") | |
| 721 | + .HasColumnType("text"); | |
| 722 | + | |
| 723 | + b.Property<int>("DisplayOrder") | |
| 724 | + .HasColumnType("integer"); | |
| 725 | + | |
| 726 | + b.Property<decimal?>("EstimatedCost") | |
| 727 | + .HasColumnType("numeric"); | |
| 728 | + | |
| 729 | + b.Property<bool>("IsCompleted") | |
| 730 | + .HasColumnType("boolean"); | |
| 731 | + | |
| 732 | + b.Property<string>("Location") | |
| 733 | + .HasMaxLength(300) | |
| 734 | + .HasColumnType("character varying(300)"); | |
| 735 | + | |
| 736 | + b.Property<int>("Priority") | |
| 737 | + .HasColumnType("integer"); | |
| 738 | + | |
| 739 | + b.Property<string>("Title") | |
| 740 | + .IsRequired() | |
| 741 | + .HasMaxLength(200) | |
| 742 | + .HasColumnType("character varying(200)"); | |
| 743 | + | |
| 744 | + b.Property<Guid>("TripId") | |
| 745 | + .HasColumnType("uuid"); | |
| 746 | + | |
| 747 | + b.Property<DateTime>("UpdatedAt") | |
| 748 | + .HasColumnType("timestamp with time zone"); | |
| 749 | + | |
| 750 | + b.Property<string>("Url") | |
| 751 | + .HasMaxLength(500) | |
| 752 | + .HasColumnType("character varying(500)"); | |
| 753 | + | |
| 754 | + b.HasKey("Id"); | |
| 755 | + | |
| 756 | + b.HasIndex("AddedByUserId"); | |
| 757 | + | |
| 758 | + b.HasIndex("TripId"); | |
| 759 | + | |
| 760 | + b.ToTable("TripWishlistItems"); | |
| 761 | + }); | |
| 762 | + | |
| 763 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 764 | + { | |
| 765 | + b.Property<Guid>("Id") | |
| 766 | + .ValueGeneratedOnAdd() | |
| 767 | + .HasColumnType("uuid"); | |
| 768 | + | |
| 769 | + b.Property<DateTime>("CreatedAt") | |
| 770 | + .HasColumnType("timestamp with time zone"); | |
| 771 | + | |
| 772 | + b.Property<bool>("IsInterested") | |
| 773 | + .HasColumnType("boolean"); | |
| 774 | + | |
| 775 | + b.Property<DateTime>("UpdatedAt") | |
| 776 | + .HasColumnType("timestamp with time zone"); | |
| 777 | + | |
| 778 | + b.Property<Guid>("UserId") | |
| 779 | + .HasColumnType("uuid"); | |
| 780 | + | |
| 781 | + b.Property<Guid>("WishlistItemId") | |
| 782 | + .HasColumnType("uuid"); | |
| 783 | + | |
| 784 | + b.HasKey("Id"); | |
| 785 | + | |
| 786 | + b.HasIndex("UserId"); | |
| 787 | + | |
| 788 | + b.HasIndex("WishlistItemId", "UserId") | |
| 789 | + .IsUnique(); | |
| 790 | + | |
| 791 | + b.ToTable("TripWishlistVotes"); | |
| 792 | + }); | |
| 793 | + | |
| 794 | + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => | |
| 795 | + { | |
| 796 | + b.Property<int>("Id") | |
| 797 | + .ValueGeneratedOnAdd() | |
| 798 | + .HasColumnType("integer"); | |
| 799 | + | |
| 800 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 801 | + | |
| 802 | + b.Property<string>("FriendlyName") | |
| 803 | + .HasColumnType("text"); | |
| 804 | + | |
| 805 | + b.Property<string>("Xml") | |
| 806 | + .HasColumnType("text"); | |
| 807 | + | |
| 808 | + b.HasKey("Id"); | |
| 809 | + | |
| 810 | + b.ToTable("DataProtectionKeys"); | |
| 811 | + }); | |
| 812 | + | |
| 813 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 814 | + { | |
| 815 | + b.Property<int>("Id") | |
| 816 | + .ValueGeneratedOnAdd() | |
| 817 | + .HasColumnType("integer"); | |
| 818 | + | |
| 819 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 820 | + | |
| 821 | + b.Property<string>("ClaimType") | |
| 822 | + .HasColumnType("text"); | |
| 823 | + | |
| 824 | + b.Property<string>("ClaimValue") | |
| 825 | + .HasColumnType("text"); | |
| 826 | + | |
| 827 | + b.Property<Guid>("RoleId") | |
| 828 | + .HasColumnType("uuid"); | |
| 829 | + | |
| 830 | + b.HasKey("Id"); | |
| 831 | + | |
| 832 | + b.HasIndex("RoleId"); | |
| 833 | + | |
| 834 | + b.ToTable("AspNetRoleClaims", (string)null); | |
| 835 | + }); | |
| 836 | + | |
| 837 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 838 | + { | |
| 839 | + b.Property<int>("Id") | |
| 840 | + .ValueGeneratedOnAdd() | |
| 841 | + .HasColumnType("integer"); | |
| 842 | + | |
| 843 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 844 | + | |
| 845 | + b.Property<string>("ClaimType") | |
| 846 | + .HasColumnType("text"); | |
| 847 | + | |
| 848 | + b.Property<string>("ClaimValue") | |
| 849 | + .HasColumnType("text"); | |
| 850 | + | |
| 851 | + b.Property<Guid>("UserId") | |
| 852 | + .HasColumnType("uuid"); | |
| 853 | + | |
| 854 | + b.HasKey("Id"); | |
| 855 | + | |
| 856 | + b.HasIndex("UserId"); | |
| 857 | + | |
| 858 | + b.ToTable("AspNetUserClaims", (string)null); | |
| 859 | + }); | |
| 860 | + | |
| 861 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 862 | + { | |
| 863 | + b.Property<string>("LoginProvider") | |
| 864 | + .HasColumnType("text"); | |
| 865 | + | |
| 866 | + b.Property<string>("ProviderKey") | |
| 867 | + .HasColumnType("text"); | |
| 868 | + | |
| 869 | + b.Property<string>("ProviderDisplayName") | |
| 870 | + .HasColumnType("text"); | |
| 871 | + | |
| 872 | + b.Property<Guid>("UserId") | |
| 873 | + .HasColumnType("uuid"); | |
| 874 | + | |
| 875 | + b.HasKey("LoginProvider", "ProviderKey"); | |
| 876 | + | |
| 877 | + b.HasIndex("UserId"); | |
| 878 | + | |
| 879 | + b.ToTable("AspNetUserLogins", (string)null); | |
| 880 | + }); | |
| 881 | + | |
| 882 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 883 | + { | |
| 884 | + b.Property<Guid>("UserId") | |
| 885 | + .HasColumnType("uuid"); | |
| 886 | + | |
| 887 | + b.Property<Guid>("RoleId") | |
| 888 | + .HasColumnType("uuid"); | |
| 889 | + | |
| 890 | + b.HasKey("UserId", "RoleId"); | |
| 891 | + | |
| 892 | + b.HasIndex("RoleId"); | |
| 893 | + | |
| 894 | + b.ToTable("AspNetUserRoles", (string)null); | |
| 895 | + }); | |
| 896 | + | |
| 897 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 898 | + { | |
| 899 | + b.Property<Guid>("UserId") | |
| 900 | + .HasColumnType("uuid"); | |
| 901 | + | |
| 902 | + b.Property<string>("LoginProvider") | |
| 903 | + .HasColumnType("text"); | |
| 904 | + | |
| 905 | + b.Property<string>("Name") | |
| 906 | + .HasColumnType("text"); | |
| 907 | + | |
| 908 | + b.Property<string>("Value") | |
| 909 | + .HasColumnType("text"); | |
| 910 | + | |
| 911 | + b.HasKey("UserId", "LoginProvider", "Name"); | |
| 912 | + | |
| 913 | + b.ToTable("AspNetUserTokens", (string)null); | |
| 914 | + }); | |
| 915 | + | |
| 916 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 917 | + { | |
| 918 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 919 | + .WithMany("BudgetCategories") | |
| 920 | + .HasForeignKey("TripId") | |
| 921 | + .OnDelete(DeleteBehavior.Restrict) | |
| 922 | + .IsRequired(); | |
| 923 | + | |
| 924 | + b.Navigation("Trip"); | |
| 925 | + }); | |
| 926 | + | |
| 927 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 928 | + { | |
| 929 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 930 | + .WithMany("Expenses") | |
| 931 | + .HasForeignKey("BudgetCategoryId") | |
| 932 | + .OnDelete(DeleteBehavior.Restrict); | |
| 933 | + | |
| 934 | + b.HasOne("App.Domain.Currency", "Currency") | |
| 935 | + .WithMany() | |
| 936 | + .HasForeignKey("CurrencyId") | |
| 937 | + .OnDelete(DeleteBehavior.Restrict); | |
| 938 | + | |
| 939 | + b.HasOne("App.Domain.Identity.AppUser", "PaidByUser") | |
| 940 | + .WithMany() | |
| 941 | + .HasForeignKey("PaidByUserId") | |
| 942 | + .OnDelete(DeleteBehavior.Restrict) | |
| 943 | + .IsRequired(); | |
| 944 | + | |
| 945 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 946 | + .WithMany("Expenses") | |
| 947 | + .HasForeignKey("TripId") | |
| 948 | + .OnDelete(DeleteBehavior.Restrict) | |
| 949 | + .IsRequired(); | |
| 950 | + | |
| 951 | + b.Navigation("BudgetCategory"); | |
| 952 | + | |
| 953 | + b.Navigation("Currency"); | |
| 954 | + | |
| 955 | + b.Navigation("PaidByUser"); | |
| 956 | + | |
| 957 | + b.Navigation("Trip"); | |
| 958 | + }); | |
| 959 | + | |
| 960 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 961 | + { | |
| 962 | + b.HasOne("App.Domain.Expense", "Expense") | |
| 963 | + .WithMany("Splits") | |
| 964 | + .HasForeignKey("ExpenseId") | |
| 965 | + .OnDelete(DeleteBehavior.Restrict) | |
| 966 | + .IsRequired(); | |
| 967 | + | |
| 968 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 969 | + .WithMany() | |
| 970 | + .HasForeignKey("UserId") | |
| 971 | + .OnDelete(DeleteBehavior.Restrict) | |
| 972 | + .IsRequired(); | |
| 973 | + | |
| 974 | + b.Navigation("Expense"); | |
| 975 | + | |
| 976 | + b.Navigation("User"); | |
| 977 | + }); | |
| 978 | + | |
| 979 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 980 | + { | |
| 981 | + b.HasOne("App.Domain.Identity.AppUser", "AppUser") | |
| 982 | + .WithMany("RefreshTokens") | |
| 983 | + .HasForeignKey("AppUserId") | |
| 984 | + .OnDelete(DeleteBehavior.Restrict) | |
| 985 | + .IsRequired(); | |
| 986 | + | |
| 987 | + b.Navigation("AppUser"); | |
| 988 | + }); | |
| 989 | + | |
| 990 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 991 | + { | |
| 992 | + b.HasOne("App.Domain.Identity.AppUser", "FromUser") | |
| 993 | + .WithMany() | |
| 994 | + .HasForeignKey("FromUserId") | |
| 995 | + .OnDelete(DeleteBehavior.Restrict) | |
| 996 | + .IsRequired(); | |
| 997 | + | |
| 998 | + b.HasOne("App.Domain.SettlementPlan", "SettlementPlan") | |
| 999 | + .WithMany("Payments") | |
| 1000 | + .HasForeignKey("SettlementPlanId") | |
| 1001 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1002 | + .IsRequired(); | |
| 1003 | + | |
| 1004 | + b.HasOne("App.Domain.Identity.AppUser", "ToUser") | |
| 1005 | + .WithMany() | |
| 1006 | + .HasForeignKey("ToUserId") | |
| 1007 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1008 | + .IsRequired(); | |
| 1009 | + | |
| 1010 | + b.Navigation("FromUser"); | |
| 1011 | + | |
| 1012 | + b.Navigation("SettlementPlan"); | |
| 1013 | + | |
| 1014 | + b.Navigation("ToUser"); | |
| 1015 | + }); | |
| 1016 | + | |
| 1017 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1018 | + { | |
| 1019 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1020 | + .WithMany() | |
| 1021 | + .HasForeignKey("CreatedByUserId") | |
| 1022 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1023 | + .IsRequired(); | |
| 1024 | + | |
| 1025 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1026 | + .WithMany("SettlementPlans") | |
| 1027 | + .HasForeignKey("TripId") | |
| 1028 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1029 | + .IsRequired(); | |
| 1030 | + | |
| 1031 | + b.Navigation("CreatedByUser"); | |
| 1032 | + | |
| 1033 | + b.Navigation("Trip"); | |
| 1034 | + }); | |
| 1035 | + | |
| 1036 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1037 | + { | |
| 1038 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1039 | + .WithMany() | |
| 1040 | + .HasForeignKey("CreatedById") | |
| 1041 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1042 | + .IsRequired(); | |
| 1043 | + | |
| 1044 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1045 | + .WithMany() | |
| 1046 | + .HasForeignKey("TripId") | |
| 1047 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1048 | + .IsRequired(); | |
| 1049 | + | |
| 1050 | + b.Navigation("CreatedBy"); | |
| 1051 | + | |
| 1052 | + b.Navigation("Trip"); | |
| 1053 | + }); | |
| 1054 | + | |
| 1055 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 1056 | + { | |
| 1057 | + b.HasOne("App.Domain.SplitPreset", "SplitPreset") | |
| 1058 | + .WithMany("Members") | |
| 1059 | + .HasForeignKey("SplitPresetId") | |
| 1060 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1061 | + .IsRequired(); | |
| 1062 | + | |
| 1063 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1064 | + .WithMany() | |
| 1065 | + .HasForeignKey("UserId") | |
| 1066 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1067 | + .IsRequired(); | |
| 1068 | + | |
| 1069 | + b.Navigation("SplitPreset"); | |
| 1070 | + | |
| 1071 | + b.Navigation("User"); | |
| 1072 | + }); | |
| 1073 | + | |
| 1074 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1075 | + { | |
| 1076 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1077 | + .WithMany() | |
| 1078 | + .HasForeignKey("CreatedById") | |
| 1079 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1080 | + .IsRequired(); | |
| 1081 | + | |
| 1082 | + b.HasOne("App.Domain.Currency", "DefaultCurrency") | |
| 1083 | + .WithMany() | |
| 1084 | + .HasForeignKey("DefaultCurrencyId") | |
| 1085 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1086 | + .IsRequired(); | |
| 1087 | + | |
| 1088 | + b.Navigation("CreatedBy"); | |
| 1089 | + | |
| 1090 | + b.Navigation("DefaultCurrency"); | |
| 1091 | + }); | |
| 1092 | + | |
| 1093 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 1094 | + { | |
| 1095 | + b.HasOne("App.Domain.Identity.AppUser", "InvitedByUser") | |
| 1096 | + .WithMany() | |
| 1097 | + .HasForeignKey("InvitedByUserId") | |
| 1098 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1099 | + .IsRequired(); | |
| 1100 | + | |
| 1101 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1102 | + .WithMany("Invitations") | |
| 1103 | + .HasForeignKey("TripId") | |
| 1104 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1105 | + .IsRequired(); | |
| 1106 | + | |
| 1107 | + b.Navigation("InvitedByUser"); | |
| 1108 | + | |
| 1109 | + b.Navigation("Trip"); | |
| 1110 | + }); | |
| 1111 | + | |
| 1112 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 1113 | + { | |
| 1114 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1115 | + .WithMany("Participants") | |
| 1116 | + .HasForeignKey("TripId") | |
| 1117 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1118 | + .IsRequired(); | |
| 1119 | + | |
| 1120 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1121 | + .WithMany("TripParticipants") | |
| 1122 | + .HasForeignKey("UserId") | |
| 1123 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1124 | + .IsRequired(); | |
| 1125 | + | |
| 1126 | + b.Navigation("Trip"); | |
| 1127 | + | |
| 1128 | + b.Navigation("User"); | |
| 1129 | + }); | |
| 1130 | + | |
| 1131 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1132 | + { | |
| 1133 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1134 | + .WithMany() | |
| 1135 | + .HasForeignKey("CreatedByUserId") | |
| 1136 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1137 | + .IsRequired(); | |
| 1138 | + | |
| 1139 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1140 | + .WithMany("Polls") | |
| 1141 | + .HasForeignKey("TripId") | |
| 1142 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1143 | + .IsRequired(); | |
| 1144 | + | |
| 1145 | + b.Navigation("CreatedByUser"); | |
| 1146 | + | |
| 1147 | + b.Navigation("Trip"); | |
| 1148 | + }); | |
| 1149 | + | |
| 1150 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1151 | + { | |
| 1152 | + b.HasOne("App.Domain.TripPoll", "Poll") | |
| 1153 | + .WithMany("Options") | |
| 1154 | + .HasForeignKey("PollId") | |
| 1155 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1156 | + .IsRequired(); | |
| 1157 | + | |
| 1158 | + b.Navigation("Poll"); | |
| 1159 | + }); | |
| 1160 | + | |
| 1161 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 1162 | + { | |
| 1163 | + b.HasOne("App.Domain.TripPollOption", "PollOption") | |
| 1164 | + .WithMany("Votes") | |
| 1165 | + .HasForeignKey("PollOptionId") | |
| 1166 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1167 | + .IsRequired(); | |
| 1168 | + | |
| 1169 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1170 | + .WithMany() | |
| 1171 | + .HasForeignKey("UserId") | |
| 1172 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1173 | + .IsRequired(); | |
| 1174 | + | |
| 1175 | + b.Navigation("PollOption"); | |
| 1176 | + | |
| 1177 | + b.Navigation("User"); | |
| 1178 | + }); | |
| 1179 | + | |
| 1180 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1181 | + { | |
| 1182 | + b.HasOne("App.Domain.Identity.AppUser", "AddedByUser") | |
| 1183 | + .WithMany() | |
| 1184 | + .HasForeignKey("AddedByUserId") | |
| 1185 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1186 | + .IsRequired(); | |
| 1187 | + | |
| 1188 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1189 | + .WithMany("WishlistItems") | |
| 1190 | + .HasForeignKey("TripId") | |
| 1191 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1192 | + .IsRequired(); | |
| 1193 | + | |
| 1194 | + b.Navigation("AddedByUser"); | |
| 1195 | + | |
| 1196 | + b.Navigation("Trip"); | |
| 1197 | + }); | |
| 1198 | + | |
| 1199 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 1200 | + { | |
| 1201 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1202 | + .WithMany() | |
| 1203 | + .HasForeignKey("UserId") | |
| 1204 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1205 | + .IsRequired(); | |
| 1206 | + | |
| 1207 | + b.HasOne("App.Domain.TripWishlistItem", "WishlistItem") | |
| 1208 | + .WithMany("Votes") | |
| 1209 | + .HasForeignKey("WishlistItemId") | |
| 1210 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1211 | + .IsRequired(); | |
| 1212 | + | |
| 1213 | + b.Navigation("User"); | |
| 1214 | + | |
| 1215 | + b.Navigation("WishlistItem"); | |
| 1216 | + }); | |
| 1217 | + | |
| 1218 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 1219 | + { | |
| 1220 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1221 | + .WithMany() | |
| 1222 | + .HasForeignKey("RoleId") | |
| 1223 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1224 | + .IsRequired(); | |
| 1225 | + }); | |
| 1226 | + | |
| 1227 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 1228 | + { | |
| 1229 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1230 | + .WithMany() | |
| 1231 | + .HasForeignKey("UserId") | |
| 1232 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1233 | + .IsRequired(); | |
| 1234 | + }); | |
| 1235 | + | |
| 1236 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 1237 | + { | |
| 1238 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1239 | + .WithMany() | |
| 1240 | + .HasForeignKey("UserId") | |
| 1241 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1242 | + .IsRequired(); | |
| 1243 | + }); | |
| 1244 | + | |
| 1245 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 1246 | + { | |
| 1247 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1248 | + .WithMany() | |
| 1249 | + .HasForeignKey("RoleId") | |
| 1250 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1251 | + .IsRequired(); | |
| 1252 | + | |
| 1253 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1254 | + .WithMany() | |
| 1255 | + .HasForeignKey("UserId") | |
| 1256 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1257 | + .IsRequired(); | |
| 1258 | + }); | |
| 1259 | + | |
| 1260 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 1261 | + { | |
| 1262 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1263 | + .WithMany() | |
| 1264 | + .HasForeignKey("UserId") | |
| 1265 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1266 | + .IsRequired(); | |
| 1267 | + }); | |
| 1268 | + | |
| 1269 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 1270 | + { | |
| 1271 | + b.Navigation("Expenses"); | |
| 1272 | + }); | |
| 1273 | + | |
| 1274 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 1275 | + { | |
| 1276 | + b.Navigation("Splits"); | |
| 1277 | + }); | |
| 1278 | + | |
| 1279 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 1280 | + { | |
| 1281 | + b.Navigation("RefreshTokens"); | |
| 1282 | + | |
| 1283 | + b.Navigation("TripParticipants"); | |
| 1284 | + }); | |
| 1285 | + | |
| 1286 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1287 | + { | |
| 1288 | + b.Navigation("Payments"); | |
| 1289 | + }); | |
| 1290 | + | |
| 1291 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1292 | + { | |
| 1293 | + b.Navigation("Members"); | |
| 1294 | + }); | |
| 1295 | + | |
| 1296 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1297 | + { | |
| 1298 | + b.Navigation("BudgetCategories"); | |
| 1299 | + | |
| 1300 | + b.Navigation("Expenses"); | |
| 1301 | + | |
| 1302 | + b.Navigation("Invitations"); | |
| 1303 | + | |
| 1304 | + b.Navigation("Participants"); | |
| 1305 | + | |
| 1306 | + b.Navigation("Polls"); | |
| 1307 | + | |
| 1308 | + b.Navigation("SettlementPlans"); | |
| 1309 | + | |
| 1310 | + b.Navigation("WishlistItems"); | |
| 1311 | + }); | |
| 1312 | + | |
| 1313 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1314 | + { | |
| 1315 | + b.Navigation("Options"); | |
| 1316 | + }); | |
| 1317 | + | |
| 1318 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1319 | + { | |
| 1320 | + b.Navigation("Votes"); | |
| 1321 | + }); | |
| 1322 | + | |
| 1323 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1324 | + { | |
| 1325 | + b.Navigation("Votes"); | |
| 1326 | + }); | |
| 1327 | +#pragma warning restore 612, 618 | |
| 1328 | + } | |
| 1329 | + } | |
| 1330 | +} |
added SplitApp/App.DAL.EF/Migrations/20260410202112_BudgetCategoryNameToLangStr.cs +38 −0
| @@ -0,0 +1,38 @@ | ||
| 1 | +using Microsoft.EntityFrameworkCore.Migrations; | |
| 2 | + | |
| 3 | +#nullable disable | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Migrations | |
| 6 | +{ | |
| 7 | + /// <inheritdoc /> | |
| 8 | + public partial class BudgetCategoryNameToLangStr : Migration | |
| 9 | + { | |
| 10 | + /// <inheritdoc /> | |
| 11 | + protected override void Up(MigrationBuilder migrationBuilder) | |
| 12 | + { | |
| 13 | + migrationBuilder.AlterColumn<string>( | |
| 14 | + name: "Name", | |
| 15 | + table: "BudgetCategories", | |
| 16 | + type: "character varying(1024)", | |
| 17 | + maxLength: 1024, | |
| 18 | + nullable: false, | |
| 19 | + oldClrType: typeof(string), | |
| 20 | + oldType: "character varying(100)", | |
| 21 | + oldMaxLength: 100); | |
| 22 | + } | |
| 23 | + | |
| 24 | + /// <inheritdoc /> | |
| 25 | + protected override void Down(MigrationBuilder migrationBuilder) | |
| 26 | + { | |
| 27 | + migrationBuilder.AlterColumn<string>( | |
| 28 | + name: "Name", | |
| 29 | + table: "BudgetCategories", | |
| 30 | + type: "character varying(100)", | |
| 31 | + maxLength: 100, | |
| 32 | + nullable: false, | |
| 33 | + oldClrType: typeof(string), | |
| 34 | + oldType: "character varying(1024)", | |
| 35 | + oldMaxLength: 1024); | |
| 36 | + } | |
| 37 | + } | |
| 38 | +} |
added SplitApp/App.DAL.EF/Migrations/AppDbContextModelSnapshot.cs +1327 −0
| @@ -0,0 +1,1327 @@ | ||
| 1 | +// <auto-generated /> | |
| 2 | +using System; | |
| 3 | +using App.DAL.EF; | |
| 4 | +using Microsoft.EntityFrameworkCore; | |
| 5 | +using Microsoft.EntityFrameworkCore.Infrastructure; | |
| 6 | +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | |
| 7 | +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; | |
| 8 | + | |
| 9 | +#nullable disable | |
| 10 | + | |
| 11 | +namespace App.DAL.EF.Migrations | |
| 12 | +{ | |
| 13 | + [DbContext(typeof(AppDbContext))] | |
| 14 | + partial class AppDbContextModelSnapshot : ModelSnapshot | |
| 15 | + { | |
| 16 | + protected override void BuildModel(ModelBuilder modelBuilder) | |
| 17 | + { | |
| 18 | +#pragma warning disable 612, 618 | |
| 19 | + modelBuilder | |
| 20 | + .HasAnnotation("ProductVersion", "10.0.5") | |
| 21 | + .HasAnnotation("Relational:MaxIdentifierLength", 63); | |
| 22 | + | |
| 23 | + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); | |
| 24 | + | |
| 25 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 26 | + { | |
| 27 | + b.Property<Guid>("Id") | |
| 28 | + .ValueGeneratedOnAdd() | |
| 29 | + .HasColumnType("uuid"); | |
| 30 | + | |
| 31 | + b.Property<DateTime>("CreatedAt") | |
| 32 | + .HasColumnType("timestamp with time zone"); | |
| 33 | + | |
| 34 | + b.Property<int>("DisplayOrder") | |
| 35 | + .HasColumnType("integer"); | |
| 36 | + | |
| 37 | + b.Property<string>("IconName") | |
| 38 | + .HasMaxLength(100) | |
| 39 | + .HasColumnType("character varying(100)"); | |
| 40 | + | |
| 41 | + b.Property<string>("Name") | |
| 42 | + .IsRequired() | |
| 43 | + .HasMaxLength(1024) | |
| 44 | + .HasColumnType("character varying(1024)"); | |
| 45 | + | |
| 46 | + b.Property<decimal?>("PlannedAmount") | |
| 47 | + .HasColumnType("numeric"); | |
| 48 | + | |
| 49 | + b.Property<Guid>("TripId") | |
| 50 | + .HasColumnType("uuid"); | |
| 51 | + | |
| 52 | + b.Property<DateTime>("UpdatedAt") | |
| 53 | + .HasColumnType("timestamp with time zone"); | |
| 54 | + | |
| 55 | + b.HasKey("Id"); | |
| 56 | + | |
| 57 | + b.HasIndex("TripId"); | |
| 58 | + | |
| 59 | + b.ToTable("BudgetCategories"); | |
| 60 | + }); | |
| 61 | + | |
| 62 | + modelBuilder.Entity("App.Domain.Currency", b => | |
| 63 | + { | |
| 64 | + b.Property<Guid>("Id") | |
| 65 | + .ValueGeneratedOnAdd() | |
| 66 | + .HasColumnType("uuid"); | |
| 67 | + | |
| 68 | + b.Property<string>("Code") | |
| 69 | + .IsRequired() | |
| 70 | + .HasMaxLength(3) | |
| 71 | + .HasColumnType("character varying(3)"); | |
| 72 | + | |
| 73 | + b.Property<DateTime>("CreatedAt") | |
| 74 | + .HasColumnType("timestamp with time zone"); | |
| 75 | + | |
| 76 | + b.Property<string>("Name") | |
| 77 | + .IsRequired() | |
| 78 | + .HasMaxLength(1024) | |
| 79 | + .HasColumnType("character varying(1024)"); | |
| 80 | + | |
| 81 | + b.Property<string>("Symbol") | |
| 82 | + .IsRequired() | |
| 83 | + .HasMaxLength(10) | |
| 84 | + .HasColumnType("character varying(10)"); | |
| 85 | + | |
| 86 | + b.Property<DateTime>("UpdatedAt") | |
| 87 | + .HasColumnType("timestamp with time zone"); | |
| 88 | + | |
| 89 | + b.HasKey("Id"); | |
| 90 | + | |
| 91 | + b.ToTable("Currencies"); | |
| 92 | + }); | |
| 93 | + | |
| 94 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 95 | + { | |
| 96 | + b.Property<Guid>("Id") | |
| 97 | + .ValueGeneratedOnAdd() | |
| 98 | + .HasColumnType("uuid"); | |
| 99 | + | |
| 100 | + b.Property<decimal>("Amount") | |
| 101 | + .HasColumnType("numeric"); | |
| 102 | + | |
| 103 | + b.Property<Guid?>("BudgetCategoryId") | |
| 104 | + .HasColumnType("uuid"); | |
| 105 | + | |
| 106 | + b.Property<DateTime>("CreatedAt") | |
| 107 | + .HasColumnType("timestamp with time zone"); | |
| 108 | + | |
| 109 | + b.Property<Guid?>("CurrencyId") | |
| 110 | + .HasColumnType("uuid"); | |
| 111 | + | |
| 112 | + b.Property<string>("Description") | |
| 113 | + .HasMaxLength(500) | |
| 114 | + .HasColumnType("character varying(500)"); | |
| 115 | + | |
| 116 | + b.Property<DateTime>("ExpenseDate") | |
| 117 | + .HasColumnType("timestamp with time zone"); | |
| 118 | + | |
| 119 | + b.Property<Guid>("PaidByUserId") | |
| 120 | + .HasColumnType("uuid"); | |
| 121 | + | |
| 122 | + b.Property<int>("SplitMethod") | |
| 123 | + .HasColumnType("integer"); | |
| 124 | + | |
| 125 | + b.Property<Guid>("TripId") | |
| 126 | + .HasColumnType("uuid"); | |
| 127 | + | |
| 128 | + b.Property<DateTime>("UpdatedAt") | |
| 129 | + .HasColumnType("timestamp with time zone"); | |
| 130 | + | |
| 131 | + b.HasKey("Id"); | |
| 132 | + | |
| 133 | + b.HasIndex("BudgetCategoryId"); | |
| 134 | + | |
| 135 | + b.HasIndex("CurrencyId"); | |
| 136 | + | |
| 137 | + b.HasIndex("PaidByUserId"); | |
| 138 | + | |
| 139 | + b.HasIndex("TripId"); | |
| 140 | + | |
| 141 | + b.ToTable("Expenses"); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 145 | + { | |
| 146 | + b.Property<Guid>("Id") | |
| 147 | + .ValueGeneratedOnAdd() | |
| 148 | + .HasColumnType("uuid"); | |
| 149 | + | |
| 150 | + b.Property<decimal>("Amount") | |
| 151 | + .HasColumnType("numeric"); | |
| 152 | + | |
| 153 | + b.Property<DateTime>("CreatedAt") | |
| 154 | + .HasColumnType("timestamp with time zone"); | |
| 155 | + | |
| 156 | + b.Property<Guid>("ExpenseId") | |
| 157 | + .HasColumnType("uuid"); | |
| 158 | + | |
| 159 | + b.Property<decimal?>("Percentage") | |
| 160 | + .HasColumnType("numeric"); | |
| 161 | + | |
| 162 | + b.Property<DateTime>("UpdatedAt") | |
| 163 | + .HasColumnType("timestamp with time zone"); | |
| 164 | + | |
| 165 | + b.Property<Guid>("UserId") | |
| 166 | + .HasColumnType("uuid"); | |
| 167 | + | |
| 168 | + b.HasKey("Id"); | |
| 169 | + | |
| 170 | + b.HasIndex("ExpenseId"); | |
| 171 | + | |
| 172 | + b.HasIndex("UserId"); | |
| 173 | + | |
| 174 | + b.ToTable("ExpenseSplits"); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 178 | + { | |
| 179 | + b.Property<Guid>("Id") | |
| 180 | + .ValueGeneratedOnAdd() | |
| 181 | + .HasColumnType("uuid"); | |
| 182 | + | |
| 183 | + b.Property<Guid>("AppUserId") | |
| 184 | + .HasColumnType("uuid"); | |
| 185 | + | |
| 186 | + b.Property<DateTime>("CreatedAt") | |
| 187 | + .HasColumnType("timestamp with time zone"); | |
| 188 | + | |
| 189 | + b.Property<DateTime>("ExpirationDT") | |
| 190 | + .HasColumnType("timestamp with time zone"); | |
| 191 | + | |
| 192 | + b.Property<DateTime>("PreviousExpirationDT") | |
| 193 | + .HasColumnType("timestamp with time zone"); | |
| 194 | + | |
| 195 | + b.Property<string>("PreviousRefreshToken") | |
| 196 | + .HasMaxLength(64) | |
| 197 | + .HasColumnType("character varying(64)"); | |
| 198 | + | |
| 199 | + b.Property<string>("RefreshToken") | |
| 200 | + .IsRequired() | |
| 201 | + .HasMaxLength(64) | |
| 202 | + .HasColumnType("character varying(64)"); | |
| 203 | + | |
| 204 | + b.Property<DateTime>("UpdatedAt") | |
| 205 | + .HasColumnType("timestamp with time zone"); | |
| 206 | + | |
| 207 | + b.HasKey("Id"); | |
| 208 | + | |
| 209 | + b.HasIndex("AppUserId"); | |
| 210 | + | |
| 211 | + b.ToTable("RefreshTokens"); | |
| 212 | + }); | |
| 213 | + | |
| 214 | + modelBuilder.Entity("App.Domain.Identity.AppRole", b => | |
| 215 | + { | |
| 216 | + b.Property<Guid>("Id") | |
| 217 | + .ValueGeneratedOnAdd() | |
| 218 | + .HasColumnType("uuid"); | |
| 219 | + | |
| 220 | + b.Property<string>("ConcurrencyStamp") | |
| 221 | + .IsConcurrencyToken() | |
| 222 | + .HasColumnType("text"); | |
| 223 | + | |
| 224 | + b.Property<string>("Name") | |
| 225 | + .HasMaxLength(256) | |
| 226 | + .HasColumnType("character varying(256)"); | |
| 227 | + | |
| 228 | + b.Property<string>("NormalizedName") | |
| 229 | + .HasMaxLength(256) | |
| 230 | + .HasColumnType("character varying(256)"); | |
| 231 | + | |
| 232 | + b.HasKey("Id"); | |
| 233 | + | |
| 234 | + b.HasIndex("NormalizedName") | |
| 235 | + .IsUnique() | |
| 236 | + .HasDatabaseName("RoleNameIndex"); | |
| 237 | + | |
| 238 | + b.ToTable("AspNetRoles", (string)null); | |
| 239 | + }); | |
| 240 | + | |
| 241 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 242 | + { | |
| 243 | + b.Property<Guid>("Id") | |
| 244 | + .ValueGeneratedOnAdd() | |
| 245 | + .HasColumnType("uuid"); | |
| 246 | + | |
| 247 | + b.Property<int>("AccessFailedCount") | |
| 248 | + .HasColumnType("integer"); | |
| 249 | + | |
| 250 | + b.Property<string>("ConcurrencyStamp") | |
| 251 | + .IsConcurrencyToken() | |
| 252 | + .HasColumnType("text"); | |
| 253 | + | |
| 254 | + b.Property<string>("Email") | |
| 255 | + .HasMaxLength(256) | |
| 256 | + .HasColumnType("character varying(256)"); | |
| 257 | + | |
| 258 | + b.Property<bool>("EmailConfirmed") | |
| 259 | + .HasColumnType("boolean"); | |
| 260 | + | |
| 261 | + b.Property<string>("FirstName") | |
| 262 | + .IsRequired() | |
| 263 | + .HasMaxLength(128) | |
| 264 | + .HasColumnType("character varying(128)"); | |
| 265 | + | |
| 266 | + b.Property<string>("LastName") | |
| 267 | + .IsRequired() | |
| 268 | + .HasMaxLength(128) | |
| 269 | + .HasColumnType("character varying(128)"); | |
| 270 | + | |
| 271 | + b.Property<bool>("LockoutEnabled") | |
| 272 | + .HasColumnType("boolean"); | |
| 273 | + | |
| 274 | + b.Property<DateTimeOffset?>("LockoutEnd") | |
| 275 | + .HasColumnType("timestamp with time zone"); | |
| 276 | + | |
| 277 | + b.Property<string>("NormalizedEmail") | |
| 278 | + .HasMaxLength(256) | |
| 279 | + .HasColumnType("character varying(256)"); | |
| 280 | + | |
| 281 | + b.Property<string>("NormalizedUserName") | |
| 282 | + .HasMaxLength(256) | |
| 283 | + .HasColumnType("character varying(256)"); | |
| 284 | + | |
| 285 | + b.Property<string>("PasswordHash") | |
| 286 | + .HasColumnType("text"); | |
| 287 | + | |
| 288 | + b.Property<string>("PhoneNumber") | |
| 289 | + .HasColumnType("text"); | |
| 290 | + | |
| 291 | + b.Property<bool>("PhoneNumberConfirmed") | |
| 292 | + .HasColumnType("boolean"); | |
| 293 | + | |
| 294 | + b.Property<string>("SecurityStamp") | |
| 295 | + .HasColumnType("text"); | |
| 296 | + | |
| 297 | + b.Property<bool>("TwoFactorEnabled") | |
| 298 | + .HasColumnType("boolean"); | |
| 299 | + | |
| 300 | + b.Property<string>("UserName") | |
| 301 | + .HasMaxLength(256) | |
| 302 | + .HasColumnType("character varying(256)"); | |
| 303 | + | |
| 304 | + b.HasKey("Id"); | |
| 305 | + | |
| 306 | + b.HasIndex("NormalizedEmail") | |
| 307 | + .HasDatabaseName("EmailIndex"); | |
| 308 | + | |
| 309 | + b.HasIndex("NormalizedUserName") | |
| 310 | + .IsUnique() | |
| 311 | + .HasDatabaseName("UserNameIndex"); | |
| 312 | + | |
| 313 | + b.ToTable("AspNetUsers", (string)null); | |
| 314 | + }); | |
| 315 | + | |
| 316 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 317 | + { | |
| 318 | + b.Property<Guid>("Id") | |
| 319 | + .ValueGeneratedOnAdd() | |
| 320 | + .HasColumnType("uuid"); | |
| 321 | + | |
| 322 | + b.Property<decimal>("Amount") | |
| 323 | + .HasColumnType("numeric"); | |
| 324 | + | |
| 325 | + b.Property<DateTime?>("ConfirmedAt") | |
| 326 | + .HasColumnType("timestamp with time zone"); | |
| 327 | + | |
| 328 | + b.Property<DateTime>("CreatedAt") | |
| 329 | + .HasColumnType("timestamp with time zone"); | |
| 330 | + | |
| 331 | + b.Property<Guid>("FromUserId") | |
| 332 | + .HasColumnType("uuid"); | |
| 333 | + | |
| 334 | + b.Property<DateTime?>("MarkedPaidAt") | |
| 335 | + .HasColumnType("timestamp with time zone"); | |
| 336 | + | |
| 337 | + b.Property<Guid>("SettlementPlanId") | |
| 338 | + .HasColumnType("uuid"); | |
| 339 | + | |
| 340 | + b.Property<int>("Status") | |
| 341 | + .HasColumnType("integer"); | |
| 342 | + | |
| 343 | + b.Property<Guid>("ToUserId") | |
| 344 | + .HasColumnType("uuid"); | |
| 345 | + | |
| 346 | + b.Property<DateTime>("UpdatedAt") | |
| 347 | + .HasColumnType("timestamp with time zone"); | |
| 348 | + | |
| 349 | + b.HasKey("Id"); | |
| 350 | + | |
| 351 | + b.HasIndex("FromUserId"); | |
| 352 | + | |
| 353 | + b.HasIndex("SettlementPlanId"); | |
| 354 | + | |
| 355 | + b.HasIndex("ToUserId"); | |
| 356 | + | |
| 357 | + b.ToTable("SettlementPayments"); | |
| 358 | + }); | |
| 359 | + | |
| 360 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 361 | + { | |
| 362 | + b.Property<Guid>("Id") | |
| 363 | + .ValueGeneratedOnAdd() | |
| 364 | + .HasColumnType("uuid"); | |
| 365 | + | |
| 366 | + b.Property<DateTime?>("CompletedAt") | |
| 367 | + .HasColumnType("timestamp with time zone"); | |
| 368 | + | |
| 369 | + b.Property<DateTime>("CreatedAt") | |
| 370 | + .HasColumnType("timestamp with time zone"); | |
| 371 | + | |
| 372 | + b.Property<Guid>("CreatedByUserId") | |
| 373 | + .HasColumnType("uuid"); | |
| 374 | + | |
| 375 | + b.Property<int>("Status") | |
| 376 | + .HasColumnType("integer"); | |
| 377 | + | |
| 378 | + b.Property<decimal>("TotalAmount") | |
| 379 | + .HasColumnType("numeric"); | |
| 380 | + | |
| 381 | + b.Property<Guid>("TripId") | |
| 382 | + .HasColumnType("uuid"); | |
| 383 | + | |
| 384 | + b.Property<DateTime>("UpdatedAt") | |
| 385 | + .HasColumnType("timestamp with time zone"); | |
| 386 | + | |
| 387 | + b.HasKey("Id"); | |
| 388 | + | |
| 389 | + b.HasIndex("CreatedByUserId"); | |
| 390 | + | |
| 391 | + b.HasIndex("TripId"); | |
| 392 | + | |
| 393 | + b.ToTable("SettlementPlans"); | |
| 394 | + }); | |
| 395 | + | |
| 396 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 397 | + { | |
| 398 | + b.Property<Guid>("Id") | |
| 399 | + .ValueGeneratedOnAdd() | |
| 400 | + .HasColumnType("uuid"); | |
| 401 | + | |
| 402 | + b.Property<DateTime>("CreatedAt") | |
| 403 | + .HasColumnType("timestamp with time zone"); | |
| 404 | + | |
| 405 | + b.Property<Guid>("CreatedById") | |
| 406 | + .HasColumnType("uuid"); | |
| 407 | + | |
| 408 | + b.Property<string>("Name") | |
| 409 | + .IsRequired() | |
| 410 | + .HasMaxLength(200) | |
| 411 | + .HasColumnType("character varying(200)"); | |
| 412 | + | |
| 413 | + b.Property<int>("SplitMethod") | |
| 414 | + .HasColumnType("integer"); | |
| 415 | + | |
| 416 | + b.Property<Guid>("TripId") | |
| 417 | + .HasColumnType("uuid"); | |
| 418 | + | |
| 419 | + b.Property<DateTime>("UpdatedAt") | |
| 420 | + .HasColumnType("timestamp with time zone"); | |
| 421 | + | |
| 422 | + b.HasKey("Id"); | |
| 423 | + | |
| 424 | + b.HasIndex("CreatedById"); | |
| 425 | + | |
| 426 | + b.HasIndex("TripId"); | |
| 427 | + | |
| 428 | + b.ToTable("SplitPresets"); | |
| 429 | + }); | |
| 430 | + | |
| 431 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 432 | + { | |
| 433 | + b.Property<Guid>("Id") | |
| 434 | + .ValueGeneratedOnAdd() | |
| 435 | + .HasColumnType("uuid"); | |
| 436 | + | |
| 437 | + b.Property<DateTime>("CreatedAt") | |
| 438 | + .HasColumnType("timestamp with time zone"); | |
| 439 | + | |
| 440 | + b.Property<decimal?>("Percentage") | |
| 441 | + .HasColumnType("numeric"); | |
| 442 | + | |
| 443 | + b.Property<decimal?>("ShareWeight") | |
| 444 | + .HasColumnType("numeric"); | |
| 445 | + | |
| 446 | + b.Property<Guid>("SplitPresetId") | |
| 447 | + .HasColumnType("uuid"); | |
| 448 | + | |
| 449 | + b.Property<DateTime>("UpdatedAt") | |
| 450 | + .HasColumnType("timestamp with time zone"); | |
| 451 | + | |
| 452 | + b.Property<Guid>("UserId") | |
| 453 | + .HasColumnType("uuid"); | |
| 454 | + | |
| 455 | + b.HasKey("Id"); | |
| 456 | + | |
| 457 | + b.HasIndex("SplitPresetId"); | |
| 458 | + | |
| 459 | + b.HasIndex("UserId"); | |
| 460 | + | |
| 461 | + b.ToTable("SplitPresetMembers"); | |
| 462 | + }); | |
| 463 | + | |
| 464 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 465 | + { | |
| 466 | + b.Property<Guid>("Id") | |
| 467 | + .ValueGeneratedOnAdd() | |
| 468 | + .HasColumnType("uuid"); | |
| 469 | + | |
| 470 | + b.Property<DateTime>("CreatedAt") | |
| 471 | + .HasColumnType("timestamp with time zone"); | |
| 472 | + | |
| 473 | + b.Property<Guid>("CreatedById") | |
| 474 | + .HasColumnType("uuid"); | |
| 475 | + | |
| 476 | + b.Property<Guid>("DefaultCurrencyId") | |
| 477 | + .HasColumnType("uuid"); | |
| 478 | + | |
| 479 | + b.Property<string>("Description") | |
| 480 | + .HasColumnType("text"); | |
| 481 | + | |
| 482 | + b.Property<string>("Destination") | |
| 483 | + .HasMaxLength(200) | |
| 484 | + .HasColumnType("character varying(200)"); | |
| 485 | + | |
| 486 | + b.Property<DateTime?>("EndDate") | |
| 487 | + .HasColumnType("timestamp with time zone"); | |
| 488 | + | |
| 489 | + b.Property<string>("Name") | |
| 490 | + .IsRequired() | |
| 491 | + .HasMaxLength(200) | |
| 492 | + .HasColumnType("character varying(200)"); | |
| 493 | + | |
| 494 | + b.Property<DateTime?>("StartDate") | |
| 495 | + .HasColumnType("timestamp with time zone"); | |
| 496 | + | |
| 497 | + b.Property<int>("Status") | |
| 498 | + .HasColumnType("integer"); | |
| 499 | + | |
| 500 | + b.Property<DateTime>("UpdatedAt") | |
| 501 | + .HasColumnType("timestamp with time zone"); | |
| 502 | + | |
| 503 | + b.HasKey("Id"); | |
| 504 | + | |
| 505 | + b.HasIndex("CreatedById"); | |
| 506 | + | |
| 507 | + b.HasIndex("DefaultCurrencyId"); | |
| 508 | + | |
| 509 | + b.ToTable("Trips"); | |
| 510 | + }); | |
| 511 | + | |
| 512 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 513 | + { | |
| 514 | + b.Property<Guid>("Id") | |
| 515 | + .ValueGeneratedOnAdd() | |
| 516 | + .HasColumnType("uuid"); | |
| 517 | + | |
| 518 | + b.Property<DateTime>("CreatedAt") | |
| 519 | + .HasColumnType("timestamp with time zone"); | |
| 520 | + | |
| 521 | + b.Property<DateTime>("ExpiresAt") | |
| 522 | + .HasColumnType("timestamp with time zone"); | |
| 523 | + | |
| 524 | + b.Property<Guid>("InvitedByUserId") | |
| 525 | + .HasColumnType("uuid"); | |
| 526 | + | |
| 527 | + b.Property<DateTime?>("RespondedAt") | |
| 528 | + .HasColumnType("timestamp with time zone"); | |
| 529 | + | |
| 530 | + b.Property<int>("Status") | |
| 531 | + .HasColumnType("integer"); | |
| 532 | + | |
| 533 | + b.Property<string>("Token") | |
| 534 | + .IsRequired() | |
| 535 | + .HasMaxLength(256) | |
| 536 | + .HasColumnType("character varying(256)"); | |
| 537 | + | |
| 538 | + b.Property<Guid>("TripId") | |
| 539 | + .HasColumnType("uuid"); | |
| 540 | + | |
| 541 | + b.Property<DateTime>("UpdatedAt") | |
| 542 | + .HasColumnType("timestamp with time zone"); | |
| 543 | + | |
| 544 | + b.HasKey("Id"); | |
| 545 | + | |
| 546 | + b.HasIndex("InvitedByUserId"); | |
| 547 | + | |
| 548 | + b.HasIndex("Token") | |
| 549 | + .IsUnique(); | |
| 550 | + | |
| 551 | + b.HasIndex("TripId"); | |
| 552 | + | |
| 553 | + b.ToTable("TripInvitations"); | |
| 554 | + }); | |
| 555 | + | |
| 556 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 557 | + { | |
| 558 | + b.Property<Guid>("Id") | |
| 559 | + .ValueGeneratedOnAdd() | |
| 560 | + .HasColumnType("uuid"); | |
| 561 | + | |
| 562 | + b.Property<DateTime>("CreatedAt") | |
| 563 | + .HasColumnType("timestamp with time zone"); | |
| 564 | + | |
| 565 | + b.Property<bool>("IsActive") | |
| 566 | + .HasColumnType("boolean"); | |
| 567 | + | |
| 568 | + b.Property<DateTime>("JoinedAt") | |
| 569 | + .HasColumnType("timestamp with time zone"); | |
| 570 | + | |
| 571 | + b.Property<DateTime?>("LeftAt") | |
| 572 | + .HasColumnType("timestamp with time zone"); | |
| 573 | + | |
| 574 | + b.Property<string>("Nickname") | |
| 575 | + .HasMaxLength(100) | |
| 576 | + .HasColumnType("character varying(100)"); | |
| 577 | + | |
| 578 | + b.Property<int>("Role") | |
| 579 | + .HasColumnType("integer"); | |
| 580 | + | |
| 581 | + b.Property<Guid>("TripId") | |
| 582 | + .HasColumnType("uuid"); | |
| 583 | + | |
| 584 | + b.Property<DateTime>("UpdatedAt") | |
| 585 | + .HasColumnType("timestamp with time zone"); | |
| 586 | + | |
| 587 | + b.Property<Guid>("UserId") | |
| 588 | + .HasColumnType("uuid"); | |
| 589 | + | |
| 590 | + b.HasKey("Id"); | |
| 591 | + | |
| 592 | + b.HasIndex("UserId"); | |
| 593 | + | |
| 594 | + b.HasIndex("TripId", "UserId") | |
| 595 | + .IsUnique(); | |
| 596 | + | |
| 597 | + b.ToTable("TripParticipants"); | |
| 598 | + }); | |
| 599 | + | |
| 600 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 601 | + { | |
| 602 | + b.Property<Guid>("Id") | |
| 603 | + .ValueGeneratedOnAdd() | |
| 604 | + .HasColumnType("uuid"); | |
| 605 | + | |
| 606 | + b.Property<bool>("AllowMultipleVotes") | |
| 607 | + .HasColumnType("boolean"); | |
| 608 | + | |
| 609 | + b.Property<DateTime?>("ClosedAt") | |
| 610 | + .HasColumnType("timestamp with time zone"); | |
| 611 | + | |
| 612 | + b.Property<DateTime>("CreatedAt") | |
| 613 | + .HasColumnType("timestamp with time zone"); | |
| 614 | + | |
| 615 | + b.Property<Guid>("CreatedByUserId") | |
| 616 | + .HasColumnType("uuid"); | |
| 617 | + | |
| 618 | + b.Property<bool>("IsAnonymous") | |
| 619 | + .HasColumnType("boolean"); | |
| 620 | + | |
| 621 | + b.Property<string>("Question") | |
| 622 | + .IsRequired() | |
| 623 | + .HasMaxLength(500) | |
| 624 | + .HasColumnType("character varying(500)"); | |
| 625 | + | |
| 626 | + b.Property<Guid>("TripId") | |
| 627 | + .HasColumnType("uuid"); | |
| 628 | + | |
| 629 | + b.Property<DateTime>("UpdatedAt") | |
| 630 | + .HasColumnType("timestamp with time zone"); | |
| 631 | + | |
| 632 | + b.HasKey("Id"); | |
| 633 | + | |
| 634 | + b.HasIndex("CreatedByUserId"); | |
| 635 | + | |
| 636 | + b.HasIndex("TripId"); | |
| 637 | + | |
| 638 | + b.ToTable("TripPolls"); | |
| 639 | + }); | |
| 640 | + | |
| 641 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 642 | + { | |
| 643 | + b.Property<Guid>("Id") | |
| 644 | + .ValueGeneratedOnAdd() | |
| 645 | + .HasColumnType("uuid"); | |
| 646 | + | |
| 647 | + b.Property<DateTime>("CreatedAt") | |
| 648 | + .HasColumnType("timestamp with time zone"); | |
| 649 | + | |
| 650 | + b.Property<int>("DisplayOrder") | |
| 651 | + .HasColumnType("integer"); | |
| 652 | + | |
| 653 | + b.Property<Guid>("PollId") | |
| 654 | + .HasColumnType("uuid"); | |
| 655 | + | |
| 656 | + b.Property<string>("Text") | |
| 657 | + .IsRequired() | |
| 658 | + .HasMaxLength(300) | |
| 659 | + .HasColumnType("character varying(300)"); | |
| 660 | + | |
| 661 | + b.Property<DateTime>("UpdatedAt") | |
| 662 | + .HasColumnType("timestamp with time zone"); | |
| 663 | + | |
| 664 | + b.HasKey("Id"); | |
| 665 | + | |
| 666 | + b.HasIndex("PollId"); | |
| 667 | + | |
| 668 | + b.ToTable("TripPollOptions"); | |
| 669 | + }); | |
| 670 | + | |
| 671 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 672 | + { | |
| 673 | + b.Property<Guid>("Id") | |
| 674 | + .ValueGeneratedOnAdd() | |
| 675 | + .HasColumnType("uuid"); | |
| 676 | + | |
| 677 | + b.Property<DateTime>("CreatedAt") | |
| 678 | + .HasColumnType("timestamp with time zone"); | |
| 679 | + | |
| 680 | + b.Property<Guid>("PollOptionId") | |
| 681 | + .HasColumnType("uuid"); | |
| 682 | + | |
| 683 | + b.Property<DateTime>("UpdatedAt") | |
| 684 | + .HasColumnType("timestamp with time zone"); | |
| 685 | + | |
| 686 | + b.Property<Guid>("UserId") | |
| 687 | + .HasColumnType("uuid"); | |
| 688 | + | |
| 689 | + b.HasKey("Id"); | |
| 690 | + | |
| 691 | + b.HasIndex("UserId"); | |
| 692 | + | |
| 693 | + b.HasIndex("PollOptionId", "UserId") | |
| 694 | + .IsUnique(); | |
| 695 | + | |
| 696 | + b.ToTable("TripPollVotes"); | |
| 697 | + }); | |
| 698 | + | |
| 699 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 700 | + { | |
| 701 | + b.Property<Guid>("Id") | |
| 702 | + .ValueGeneratedOnAdd() | |
| 703 | + .HasColumnType("uuid"); | |
| 704 | + | |
| 705 | + b.Property<Guid>("AddedByUserId") | |
| 706 | + .HasColumnType("uuid"); | |
| 707 | + | |
| 708 | + b.Property<int>("Category") | |
| 709 | + .HasColumnType("integer"); | |
| 710 | + | |
| 711 | + b.Property<DateTime?>("CompletedAt") | |
| 712 | + .HasColumnType("timestamp with time zone"); | |
| 713 | + | |
| 714 | + b.Property<DateTime>("CreatedAt") | |
| 715 | + .HasColumnType("timestamp with time zone"); | |
| 716 | + | |
| 717 | + b.Property<string>("Description") | |
| 718 | + .HasColumnType("text"); | |
| 719 | + | |
| 720 | + b.Property<int>("DisplayOrder") | |
| 721 | + .HasColumnType("integer"); | |
| 722 | + | |
| 723 | + b.Property<decimal?>("EstimatedCost") | |
| 724 | + .HasColumnType("numeric"); | |
| 725 | + | |
| 726 | + b.Property<bool>("IsCompleted") | |
| 727 | + .HasColumnType("boolean"); | |
| 728 | + | |
| 729 | + b.Property<string>("Location") | |
| 730 | + .HasMaxLength(300) | |
| 731 | + .HasColumnType("character varying(300)"); | |
| 732 | + | |
| 733 | + b.Property<int>("Priority") | |
| 734 | + .HasColumnType("integer"); | |
| 735 | + | |
| 736 | + b.Property<string>("Title") | |
| 737 | + .IsRequired() | |
| 738 | + .HasMaxLength(200) | |
| 739 | + .HasColumnType("character varying(200)"); | |
| 740 | + | |
| 741 | + b.Property<Guid>("TripId") | |
| 742 | + .HasColumnType("uuid"); | |
| 743 | + | |
| 744 | + b.Property<DateTime>("UpdatedAt") | |
| 745 | + .HasColumnType("timestamp with time zone"); | |
| 746 | + | |
| 747 | + b.Property<string>("Url") | |
| 748 | + .HasMaxLength(500) | |
| 749 | + .HasColumnType("character varying(500)"); | |
| 750 | + | |
| 751 | + b.HasKey("Id"); | |
| 752 | + | |
| 753 | + b.HasIndex("AddedByUserId"); | |
| 754 | + | |
| 755 | + b.HasIndex("TripId"); | |
| 756 | + | |
| 757 | + b.ToTable("TripWishlistItems"); | |
| 758 | + }); | |
| 759 | + | |
| 760 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 761 | + { | |
| 762 | + b.Property<Guid>("Id") | |
| 763 | + .ValueGeneratedOnAdd() | |
| 764 | + .HasColumnType("uuid"); | |
| 765 | + | |
| 766 | + b.Property<DateTime>("CreatedAt") | |
| 767 | + .HasColumnType("timestamp with time zone"); | |
| 768 | + | |
| 769 | + b.Property<bool>("IsInterested") | |
| 770 | + .HasColumnType("boolean"); | |
| 771 | + | |
| 772 | + b.Property<DateTime>("UpdatedAt") | |
| 773 | + .HasColumnType("timestamp with time zone"); | |
| 774 | + | |
| 775 | + b.Property<Guid>("UserId") | |
| 776 | + .HasColumnType("uuid"); | |
| 777 | + | |
| 778 | + b.Property<Guid>("WishlistItemId") | |
| 779 | + .HasColumnType("uuid"); | |
| 780 | + | |
| 781 | + b.HasKey("Id"); | |
| 782 | + | |
| 783 | + b.HasIndex("UserId"); | |
| 784 | + | |
| 785 | + b.HasIndex("WishlistItemId", "UserId") | |
| 786 | + .IsUnique(); | |
| 787 | + | |
| 788 | + b.ToTable("TripWishlistVotes"); | |
| 789 | + }); | |
| 790 | + | |
| 791 | + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => | |
| 792 | + { | |
| 793 | + b.Property<int>("Id") | |
| 794 | + .ValueGeneratedOnAdd() | |
| 795 | + .HasColumnType("integer"); | |
| 796 | + | |
| 797 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 798 | + | |
| 799 | + b.Property<string>("FriendlyName") | |
| 800 | + .HasColumnType("text"); | |
| 801 | + | |
| 802 | + b.Property<string>("Xml") | |
| 803 | + .HasColumnType("text"); | |
| 804 | + | |
| 805 | + b.HasKey("Id"); | |
| 806 | + | |
| 807 | + b.ToTable("DataProtectionKeys"); | |
| 808 | + }); | |
| 809 | + | |
| 810 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 811 | + { | |
| 812 | + b.Property<int>("Id") | |
| 813 | + .ValueGeneratedOnAdd() | |
| 814 | + .HasColumnType("integer"); | |
| 815 | + | |
| 816 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 817 | + | |
| 818 | + b.Property<string>("ClaimType") | |
| 819 | + .HasColumnType("text"); | |
| 820 | + | |
| 821 | + b.Property<string>("ClaimValue") | |
| 822 | + .HasColumnType("text"); | |
| 823 | + | |
| 824 | + b.Property<Guid>("RoleId") | |
| 825 | + .HasColumnType("uuid"); | |
| 826 | + | |
| 827 | + b.HasKey("Id"); | |
| 828 | + | |
| 829 | + b.HasIndex("RoleId"); | |
| 830 | + | |
| 831 | + b.ToTable("AspNetRoleClaims", (string)null); | |
| 832 | + }); | |
| 833 | + | |
| 834 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 835 | + { | |
| 836 | + b.Property<int>("Id") | |
| 837 | + .ValueGeneratedOnAdd() | |
| 838 | + .HasColumnType("integer"); | |
| 839 | + | |
| 840 | + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); | |
| 841 | + | |
| 842 | + b.Property<string>("ClaimType") | |
| 843 | + .HasColumnType("text"); | |
| 844 | + | |
| 845 | + b.Property<string>("ClaimValue") | |
| 846 | + .HasColumnType("text"); | |
| 847 | + | |
| 848 | + b.Property<Guid>("UserId") | |
| 849 | + .HasColumnType("uuid"); | |
| 850 | + | |
| 851 | + b.HasKey("Id"); | |
| 852 | + | |
| 853 | + b.HasIndex("UserId"); | |
| 854 | + | |
| 855 | + b.ToTable("AspNetUserClaims", (string)null); | |
| 856 | + }); | |
| 857 | + | |
| 858 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 859 | + { | |
| 860 | + b.Property<string>("LoginProvider") | |
| 861 | + .HasColumnType("text"); | |
| 862 | + | |
| 863 | + b.Property<string>("ProviderKey") | |
| 864 | + .HasColumnType("text"); | |
| 865 | + | |
| 866 | + b.Property<string>("ProviderDisplayName") | |
| 867 | + .HasColumnType("text"); | |
| 868 | + | |
| 869 | + b.Property<Guid>("UserId") | |
| 870 | + .HasColumnType("uuid"); | |
| 871 | + | |
| 872 | + b.HasKey("LoginProvider", "ProviderKey"); | |
| 873 | + | |
| 874 | + b.HasIndex("UserId"); | |
| 875 | + | |
| 876 | + b.ToTable("AspNetUserLogins", (string)null); | |
| 877 | + }); | |
| 878 | + | |
| 879 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 880 | + { | |
| 881 | + b.Property<Guid>("UserId") | |
| 882 | + .HasColumnType("uuid"); | |
| 883 | + | |
| 884 | + b.Property<Guid>("RoleId") | |
| 885 | + .HasColumnType("uuid"); | |
| 886 | + | |
| 887 | + b.HasKey("UserId", "RoleId"); | |
| 888 | + | |
| 889 | + b.HasIndex("RoleId"); | |
| 890 | + | |
| 891 | + b.ToTable("AspNetUserRoles", (string)null); | |
| 892 | + }); | |
| 893 | + | |
| 894 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 895 | + { | |
| 896 | + b.Property<Guid>("UserId") | |
| 897 | + .HasColumnType("uuid"); | |
| 898 | + | |
| 899 | + b.Property<string>("LoginProvider") | |
| 900 | + .HasColumnType("text"); | |
| 901 | + | |
| 902 | + b.Property<string>("Name") | |
| 903 | + .HasColumnType("text"); | |
| 904 | + | |
| 905 | + b.Property<string>("Value") | |
| 906 | + .HasColumnType("text"); | |
| 907 | + | |
| 908 | + b.HasKey("UserId", "LoginProvider", "Name"); | |
| 909 | + | |
| 910 | + b.ToTable("AspNetUserTokens", (string)null); | |
| 911 | + }); | |
| 912 | + | |
| 913 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 914 | + { | |
| 915 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 916 | + .WithMany("BudgetCategories") | |
| 917 | + .HasForeignKey("TripId") | |
| 918 | + .OnDelete(DeleteBehavior.Restrict) | |
| 919 | + .IsRequired(); | |
| 920 | + | |
| 921 | + b.Navigation("Trip"); | |
| 922 | + }); | |
| 923 | + | |
| 924 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 925 | + { | |
| 926 | + b.HasOne("App.Domain.BudgetCategory", "BudgetCategory") | |
| 927 | + .WithMany("Expenses") | |
| 928 | + .HasForeignKey("BudgetCategoryId") | |
| 929 | + .OnDelete(DeleteBehavior.Restrict); | |
| 930 | + | |
| 931 | + b.HasOne("App.Domain.Currency", "Currency") | |
| 932 | + .WithMany() | |
| 933 | + .HasForeignKey("CurrencyId") | |
| 934 | + .OnDelete(DeleteBehavior.Restrict); | |
| 935 | + | |
| 936 | + b.HasOne("App.Domain.Identity.AppUser", "PaidByUser") | |
| 937 | + .WithMany() | |
| 938 | + .HasForeignKey("PaidByUserId") | |
| 939 | + .OnDelete(DeleteBehavior.Restrict) | |
| 940 | + .IsRequired(); | |
| 941 | + | |
| 942 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 943 | + .WithMany("Expenses") | |
| 944 | + .HasForeignKey("TripId") | |
| 945 | + .OnDelete(DeleteBehavior.Restrict) | |
| 946 | + .IsRequired(); | |
| 947 | + | |
| 948 | + b.Navigation("BudgetCategory"); | |
| 949 | + | |
| 950 | + b.Navigation("Currency"); | |
| 951 | + | |
| 952 | + b.Navigation("PaidByUser"); | |
| 953 | + | |
| 954 | + b.Navigation("Trip"); | |
| 955 | + }); | |
| 956 | + | |
| 957 | + modelBuilder.Entity("App.Domain.ExpenseSplit", b => | |
| 958 | + { | |
| 959 | + b.HasOne("App.Domain.Expense", "Expense") | |
| 960 | + .WithMany("Splits") | |
| 961 | + .HasForeignKey("ExpenseId") | |
| 962 | + .OnDelete(DeleteBehavior.Restrict) | |
| 963 | + .IsRequired(); | |
| 964 | + | |
| 965 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 966 | + .WithMany() | |
| 967 | + .HasForeignKey("UserId") | |
| 968 | + .OnDelete(DeleteBehavior.Restrict) | |
| 969 | + .IsRequired(); | |
| 970 | + | |
| 971 | + b.Navigation("Expense"); | |
| 972 | + | |
| 973 | + b.Navigation("User"); | |
| 974 | + }); | |
| 975 | + | |
| 976 | + modelBuilder.Entity("App.Domain.Identity.AppRefreshToken", b => | |
| 977 | + { | |
| 978 | + b.HasOne("App.Domain.Identity.AppUser", "AppUser") | |
| 979 | + .WithMany("RefreshTokens") | |
| 980 | + .HasForeignKey("AppUserId") | |
| 981 | + .OnDelete(DeleteBehavior.Restrict) | |
| 982 | + .IsRequired(); | |
| 983 | + | |
| 984 | + b.Navigation("AppUser"); | |
| 985 | + }); | |
| 986 | + | |
| 987 | + modelBuilder.Entity("App.Domain.SettlementPayment", b => | |
| 988 | + { | |
| 989 | + b.HasOne("App.Domain.Identity.AppUser", "FromUser") | |
| 990 | + .WithMany() | |
| 991 | + .HasForeignKey("FromUserId") | |
| 992 | + .OnDelete(DeleteBehavior.Restrict) | |
| 993 | + .IsRequired(); | |
| 994 | + | |
| 995 | + b.HasOne("App.Domain.SettlementPlan", "SettlementPlan") | |
| 996 | + .WithMany("Payments") | |
| 997 | + .HasForeignKey("SettlementPlanId") | |
| 998 | + .OnDelete(DeleteBehavior.Restrict) | |
| 999 | + .IsRequired(); | |
| 1000 | + | |
| 1001 | + b.HasOne("App.Domain.Identity.AppUser", "ToUser") | |
| 1002 | + .WithMany() | |
| 1003 | + .HasForeignKey("ToUserId") | |
| 1004 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1005 | + .IsRequired(); | |
| 1006 | + | |
| 1007 | + b.Navigation("FromUser"); | |
| 1008 | + | |
| 1009 | + b.Navigation("SettlementPlan"); | |
| 1010 | + | |
| 1011 | + b.Navigation("ToUser"); | |
| 1012 | + }); | |
| 1013 | + | |
| 1014 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1015 | + { | |
| 1016 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1017 | + .WithMany() | |
| 1018 | + .HasForeignKey("CreatedByUserId") | |
| 1019 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1020 | + .IsRequired(); | |
| 1021 | + | |
| 1022 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1023 | + .WithMany("SettlementPlans") | |
| 1024 | + .HasForeignKey("TripId") | |
| 1025 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1026 | + .IsRequired(); | |
| 1027 | + | |
| 1028 | + b.Navigation("CreatedByUser"); | |
| 1029 | + | |
| 1030 | + b.Navigation("Trip"); | |
| 1031 | + }); | |
| 1032 | + | |
| 1033 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1034 | + { | |
| 1035 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1036 | + .WithMany() | |
| 1037 | + .HasForeignKey("CreatedById") | |
| 1038 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1039 | + .IsRequired(); | |
| 1040 | + | |
| 1041 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1042 | + .WithMany() | |
| 1043 | + .HasForeignKey("TripId") | |
| 1044 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1045 | + .IsRequired(); | |
| 1046 | + | |
| 1047 | + b.Navigation("CreatedBy"); | |
| 1048 | + | |
| 1049 | + b.Navigation("Trip"); | |
| 1050 | + }); | |
| 1051 | + | |
| 1052 | + modelBuilder.Entity("App.Domain.SplitPresetMember", b => | |
| 1053 | + { | |
| 1054 | + b.HasOne("App.Domain.SplitPreset", "SplitPreset") | |
| 1055 | + .WithMany("Members") | |
| 1056 | + .HasForeignKey("SplitPresetId") | |
| 1057 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1058 | + .IsRequired(); | |
| 1059 | + | |
| 1060 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1061 | + .WithMany() | |
| 1062 | + .HasForeignKey("UserId") | |
| 1063 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1064 | + .IsRequired(); | |
| 1065 | + | |
| 1066 | + b.Navigation("SplitPreset"); | |
| 1067 | + | |
| 1068 | + b.Navigation("User"); | |
| 1069 | + }); | |
| 1070 | + | |
| 1071 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1072 | + { | |
| 1073 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedBy") | |
| 1074 | + .WithMany() | |
| 1075 | + .HasForeignKey("CreatedById") | |
| 1076 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1077 | + .IsRequired(); | |
| 1078 | + | |
| 1079 | + b.HasOne("App.Domain.Currency", "DefaultCurrency") | |
| 1080 | + .WithMany() | |
| 1081 | + .HasForeignKey("DefaultCurrencyId") | |
| 1082 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1083 | + .IsRequired(); | |
| 1084 | + | |
| 1085 | + b.Navigation("CreatedBy"); | |
| 1086 | + | |
| 1087 | + b.Navigation("DefaultCurrency"); | |
| 1088 | + }); | |
| 1089 | + | |
| 1090 | + modelBuilder.Entity("App.Domain.TripInvitation", b => | |
| 1091 | + { | |
| 1092 | + b.HasOne("App.Domain.Identity.AppUser", "InvitedByUser") | |
| 1093 | + .WithMany() | |
| 1094 | + .HasForeignKey("InvitedByUserId") | |
| 1095 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1096 | + .IsRequired(); | |
| 1097 | + | |
| 1098 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1099 | + .WithMany("Invitations") | |
| 1100 | + .HasForeignKey("TripId") | |
| 1101 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1102 | + .IsRequired(); | |
| 1103 | + | |
| 1104 | + b.Navigation("InvitedByUser"); | |
| 1105 | + | |
| 1106 | + b.Navigation("Trip"); | |
| 1107 | + }); | |
| 1108 | + | |
| 1109 | + modelBuilder.Entity("App.Domain.TripParticipant", b => | |
| 1110 | + { | |
| 1111 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1112 | + .WithMany("Participants") | |
| 1113 | + .HasForeignKey("TripId") | |
| 1114 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1115 | + .IsRequired(); | |
| 1116 | + | |
| 1117 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1118 | + .WithMany("TripParticipants") | |
| 1119 | + .HasForeignKey("UserId") | |
| 1120 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1121 | + .IsRequired(); | |
| 1122 | + | |
| 1123 | + b.Navigation("Trip"); | |
| 1124 | + | |
| 1125 | + b.Navigation("User"); | |
| 1126 | + }); | |
| 1127 | + | |
| 1128 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1129 | + { | |
| 1130 | + b.HasOne("App.Domain.Identity.AppUser", "CreatedByUser") | |
| 1131 | + .WithMany() | |
| 1132 | + .HasForeignKey("CreatedByUserId") | |
| 1133 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1134 | + .IsRequired(); | |
| 1135 | + | |
| 1136 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1137 | + .WithMany("Polls") | |
| 1138 | + .HasForeignKey("TripId") | |
| 1139 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1140 | + .IsRequired(); | |
| 1141 | + | |
| 1142 | + b.Navigation("CreatedByUser"); | |
| 1143 | + | |
| 1144 | + b.Navigation("Trip"); | |
| 1145 | + }); | |
| 1146 | + | |
| 1147 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1148 | + { | |
| 1149 | + b.HasOne("App.Domain.TripPoll", "Poll") | |
| 1150 | + .WithMany("Options") | |
| 1151 | + .HasForeignKey("PollId") | |
| 1152 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1153 | + .IsRequired(); | |
| 1154 | + | |
| 1155 | + b.Navigation("Poll"); | |
| 1156 | + }); | |
| 1157 | + | |
| 1158 | + modelBuilder.Entity("App.Domain.TripPollVote", b => | |
| 1159 | + { | |
| 1160 | + b.HasOne("App.Domain.TripPollOption", "PollOption") | |
| 1161 | + .WithMany("Votes") | |
| 1162 | + .HasForeignKey("PollOptionId") | |
| 1163 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1164 | + .IsRequired(); | |
| 1165 | + | |
| 1166 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1167 | + .WithMany() | |
| 1168 | + .HasForeignKey("UserId") | |
| 1169 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1170 | + .IsRequired(); | |
| 1171 | + | |
| 1172 | + b.Navigation("PollOption"); | |
| 1173 | + | |
| 1174 | + b.Navigation("User"); | |
| 1175 | + }); | |
| 1176 | + | |
| 1177 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1178 | + { | |
| 1179 | + b.HasOne("App.Domain.Identity.AppUser", "AddedByUser") | |
| 1180 | + .WithMany() | |
| 1181 | + .HasForeignKey("AddedByUserId") | |
| 1182 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1183 | + .IsRequired(); | |
| 1184 | + | |
| 1185 | + b.HasOne("App.Domain.Trip", "Trip") | |
| 1186 | + .WithMany("WishlistItems") | |
| 1187 | + .HasForeignKey("TripId") | |
| 1188 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1189 | + .IsRequired(); | |
| 1190 | + | |
| 1191 | + b.Navigation("AddedByUser"); | |
| 1192 | + | |
| 1193 | + b.Navigation("Trip"); | |
| 1194 | + }); | |
| 1195 | + | |
| 1196 | + modelBuilder.Entity("App.Domain.TripWishlistVote", b => | |
| 1197 | + { | |
| 1198 | + b.HasOne("App.Domain.Identity.AppUser", "User") | |
| 1199 | + .WithMany() | |
| 1200 | + .HasForeignKey("UserId") | |
| 1201 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1202 | + .IsRequired(); | |
| 1203 | + | |
| 1204 | + b.HasOne("App.Domain.TripWishlistItem", "WishlistItem") | |
| 1205 | + .WithMany("Votes") | |
| 1206 | + .HasForeignKey("WishlistItemId") | |
| 1207 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1208 | + .IsRequired(); | |
| 1209 | + | |
| 1210 | + b.Navigation("User"); | |
| 1211 | + | |
| 1212 | + b.Navigation("WishlistItem"); | |
| 1213 | + }); | |
| 1214 | + | |
| 1215 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b => | |
| 1216 | + { | |
| 1217 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1218 | + .WithMany() | |
| 1219 | + .HasForeignKey("RoleId") | |
| 1220 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1221 | + .IsRequired(); | |
| 1222 | + }); | |
| 1223 | + | |
| 1224 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b => | |
| 1225 | + { | |
| 1226 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1227 | + .WithMany() | |
| 1228 | + .HasForeignKey("UserId") | |
| 1229 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1230 | + .IsRequired(); | |
| 1231 | + }); | |
| 1232 | + | |
| 1233 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b => | |
| 1234 | + { | |
| 1235 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1236 | + .WithMany() | |
| 1237 | + .HasForeignKey("UserId") | |
| 1238 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1239 | + .IsRequired(); | |
| 1240 | + }); | |
| 1241 | + | |
| 1242 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b => | |
| 1243 | + { | |
| 1244 | + b.HasOne("App.Domain.Identity.AppRole", null) | |
| 1245 | + .WithMany() | |
| 1246 | + .HasForeignKey("RoleId") | |
| 1247 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1248 | + .IsRequired(); | |
| 1249 | + | |
| 1250 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1251 | + .WithMany() | |
| 1252 | + .HasForeignKey("UserId") | |
| 1253 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1254 | + .IsRequired(); | |
| 1255 | + }); | |
| 1256 | + | |
| 1257 | + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b => | |
| 1258 | + { | |
| 1259 | + b.HasOne("App.Domain.Identity.AppUser", null) | |
| 1260 | + .WithMany() | |
| 1261 | + .HasForeignKey("UserId") | |
| 1262 | + .OnDelete(DeleteBehavior.Restrict) | |
| 1263 | + .IsRequired(); | |
| 1264 | + }); | |
| 1265 | + | |
| 1266 | + modelBuilder.Entity("App.Domain.BudgetCategory", b => | |
| 1267 | + { | |
| 1268 | + b.Navigation("Expenses"); | |
| 1269 | + }); | |
| 1270 | + | |
| 1271 | + modelBuilder.Entity("App.Domain.Expense", b => | |
| 1272 | + { | |
| 1273 | + b.Navigation("Splits"); | |
| 1274 | + }); | |
| 1275 | + | |
| 1276 | + modelBuilder.Entity("App.Domain.Identity.AppUser", b => | |
| 1277 | + { | |
| 1278 | + b.Navigation("RefreshTokens"); | |
| 1279 | + | |
| 1280 | + b.Navigation("TripParticipants"); | |
| 1281 | + }); | |
| 1282 | + | |
| 1283 | + modelBuilder.Entity("App.Domain.SettlementPlan", b => | |
| 1284 | + { | |
| 1285 | + b.Navigation("Payments"); | |
| 1286 | + }); | |
| 1287 | + | |
| 1288 | + modelBuilder.Entity("App.Domain.SplitPreset", b => | |
| 1289 | + { | |
| 1290 | + b.Navigation("Members"); | |
| 1291 | + }); | |
| 1292 | + | |
| 1293 | + modelBuilder.Entity("App.Domain.Trip", b => | |
| 1294 | + { | |
| 1295 | + b.Navigation("BudgetCategories"); | |
| 1296 | + | |
| 1297 | + b.Navigation("Expenses"); | |
| 1298 | + | |
| 1299 | + b.Navigation("Invitations"); | |
| 1300 | + | |
| 1301 | + b.Navigation("Participants"); | |
| 1302 | + | |
| 1303 | + b.Navigation("Polls"); | |
| 1304 | + | |
| 1305 | + b.Navigation("SettlementPlans"); | |
| 1306 | + | |
| 1307 | + b.Navigation("WishlistItems"); | |
| 1308 | + }); | |
| 1309 | + | |
| 1310 | + modelBuilder.Entity("App.Domain.TripPoll", b => | |
| 1311 | + { | |
| 1312 | + b.Navigation("Options"); | |
| 1313 | + }); | |
| 1314 | + | |
| 1315 | + modelBuilder.Entity("App.Domain.TripPollOption", b => | |
| 1316 | + { | |
| 1317 | + b.Navigation("Votes"); | |
| 1318 | + }); | |
| 1319 | + | |
| 1320 | + modelBuilder.Entity("App.Domain.TripWishlistItem", b => | |
| 1321 | + { | |
| 1322 | + b.Navigation("Votes"); | |
| 1323 | + }); | |
| 1324 | +#pragma warning restore 612, 618 | |
| 1325 | + } | |
| 1326 | + } | |
| 1327 | +} |
added SplitApp/App.DAL.EF/Repositories/BaseRepository.cs +28 −0
| @@ -0,0 +1,28 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | +using Microsoft.EntityFrameworkCore; | |
| 3 | + | |
| 4 | +namespace App.DAL.EF.Repositories; | |
| 5 | + | |
| 6 | +public class BaseRepository<TEntity> : IBaseRepository<TEntity> where TEntity : class, IBaseEntity | |
| 7 | +{ | |
| 8 | + protected readonly AppDbContext DbContext; | |
| 9 | + protected readonly DbSet<TEntity> DbSet; | |
| 10 | + | |
| 11 | + public BaseRepository(AppDbContext dbContext) | |
| 12 | + { | |
| 13 | + DbContext = dbContext; | |
| 14 | + DbSet = dbContext.Set<TEntity>(); | |
| 15 | + } | |
| 16 | + | |
| 17 | + public virtual async Task<IEnumerable<TEntity>> GetAllAsync() => await DbSet.ToListAsync(); | |
| 18 | + public virtual async Task<TEntity?> GetByIdAsync(Guid id) => await DbSet.FirstOrDefaultAsync(e => e.Id == id); | |
| 19 | + public virtual TEntity Add(TEntity entity) => DbSet.Add(entity).Entity; | |
| 20 | + public virtual TEntity Update(TEntity entity) => DbSet.Update(entity).Entity; | |
| 21 | + public virtual async Task<TEntity?> RemoveAsync(Guid id) | |
| 22 | + { | |
| 23 | + var entity = await GetByIdAsync(id); | |
| 24 | + if (entity == null) return null; | |
| 25 | + return DbSet.Remove(entity).Entity; | |
| 26 | + } | |
| 27 | + public virtual async Task<bool> ExistsAsync(Guid id) => await DbSet.AnyAsync(e => e.Id == id); | |
| 28 | +} |
added SplitApp/App.DAL.EF/Repositories/BudgetCategoryRepository.cs +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class BudgetCategoryRepository : BaseRepository<BudgetCategory>, IBudgetCategoryRepository | |
| 8 | +{ | |
| 9 | + public BudgetCategoryRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<BudgetCategory>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.BudgetCategories | |
| 16 | + .Include(bc => bc.Trip) | |
| 17 | + .OrderBy(bc => bc.DisplayOrder) | |
| 18 | + .ToListAsync(); | |
| 19 | + } | |
| 20 | + | |
| 21 | + public override async Task<BudgetCategory?> GetByIdAsync(Guid id) | |
| 22 | + { | |
| 23 | + return await DbContext.BudgetCategories | |
| 24 | + .Include(bc => bc.Trip) | |
| 25 | + .FirstOrDefaultAsync(bc => bc.Id == id); | |
| 26 | + } | |
| 27 | + | |
| 28 | + public async Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId) | |
| 29 | + { | |
| 30 | + return await DbContext.BudgetCategories | |
| 31 | + .Where(bc => bc.TripId == tripId) | |
| 32 | + .Include(bc => bc.Expenses) | |
| 33 | + .OrderBy(bc => bc.DisplayOrder) | |
| 34 | + .ToListAsync(); | |
| 35 | + } | |
| 36 | +} |
added SplitApp/App.DAL.EF/Repositories/ExpenseRepository.cs +55 −0
| @@ -0,0 +1,55 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class ExpenseRepository : BaseRepository<Expense>, IExpenseRepository | |
| 8 | +{ | |
| 9 | + public ExpenseRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<Expense>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.Expenses | |
| 16 | + .Include(e => e.Trip) | |
| 17 | + .Include(e => e.PaidByUser) | |
| 18 | + .Include(e => e.BudgetCategory) | |
| 19 | + .Include(e => e.Currency) | |
| 20 | + .OrderByDescending(e => e.ExpenseDate) | |
| 21 | + .ToListAsync(); | |
| 22 | + } | |
| 23 | + | |
| 24 | + public override async Task<Expense?> GetByIdAsync(Guid id) | |
| 25 | + { | |
| 26 | + return await DbContext.Expenses | |
| 27 | + .Include(e => e.Trip) | |
| 28 | + .Include(e => e.PaidByUser) | |
| 29 | + .Include(e => e.BudgetCategory) | |
| 30 | + .Include(e => e.Currency) | |
| 31 | + .FirstOrDefaultAsync(e => e.Id == id); | |
| 32 | + } | |
| 33 | + | |
| 34 | + public async Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId) | |
| 35 | + { | |
| 36 | + return await DbContext.Expenses | |
| 37 | + .Where(e => e.TripId == tripId) | |
| 38 | + .OrderByDescending(e => e.ExpenseDate) | |
| 39 | + .Include(e => e.PaidByUser) | |
| 40 | + .Include(e => e.BudgetCategory) | |
| 41 | + .Include(e => e.Currency) | |
| 42 | + .ToListAsync(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + public async Task<Expense?> GetByIdWithDetailsAsync(Guid id) | |
| 46 | + { | |
| 47 | + return await DbContext.Expenses | |
| 48 | + .Include(e => e.PaidByUser) | |
| 49 | + .Include(e => e.BudgetCategory) | |
| 50 | + .Include(e => e.Currency) | |
| 51 | + .Include(e => e.Splits!) | |
| 52 | + .ThenInclude(s => s.User) | |
| 53 | + .FirstOrDefaultAsync(e => e.Id == id); | |
| 54 | + } | |
| 55 | +} |
added SplitApp/App.DAL.EF/Repositories/RefreshTokenRepository.cs +42 −0
| @@ -0,0 +1,42 @@ | ||
| 1 | +using App.Domain.Contracts; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class RefreshTokenRepository : BaseRepository<AppRefreshToken>, IRefreshTokenRepository | |
| 8 | +{ | |
| 9 | + public RefreshTokenRepository(AppDbContext 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/App.DAL.EF/Repositories/SettlementPaymentRepository.cs +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class SettlementPaymentRepository : BaseRepository<SettlementPayment>, ISettlementPaymentRepository | |
| 8 | +{ | |
| 9 | + public SettlementPaymentRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<SettlementPayment>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.SettlementPayments | |
| 16 | + .Include(sp => sp.FromUser) | |
| 17 | + .Include(sp => sp.ToUser) | |
| 18 | + .Include(sp => sp.SettlementPlan) | |
| 19 | + .ToListAsync(); | |
| 20 | + } | |
| 21 | + | |
| 22 | + public override async Task<SettlementPayment?> GetByIdAsync(Guid id) | |
| 23 | + { | |
| 24 | + return await DbContext.SettlementPayments | |
| 25 | + .Include(sp => sp.FromUser) | |
| 26 | + .Include(sp => sp.ToUser) | |
| 27 | + .Include(sp => sp.SettlementPlan) | |
| 28 | + .FirstOrDefaultAsync(sp => sp.Id == id); | |
| 29 | + } | |
| 30 | +} |
added SplitApp/App.DAL.EF/Repositories/SettlementPlanRepository.cs +55 −0
| @@ -0,0 +1,55 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class SettlementPlanRepository : BaseRepository<SettlementPlan>, ISettlementPlanRepository | |
| 8 | +{ | |
| 9 | + public SettlementPlanRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<SettlementPlan>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.SettlementPlans | |
| 16 | + .Include(sp => sp.Trip) | |
| 17 | + .Include(sp => sp.CreatedByUser) | |
| 18 | + .Include(sp => sp.Payments) | |
| 19 | + .ToListAsync(); | |
| 20 | + } | |
| 21 | + | |
| 22 | + public override async Task<SettlementPlan?> GetByIdAsync(Guid id) | |
| 23 | + { | |
| 24 | + return await DbContext.SettlementPlans | |
| 25 | + .Include(sp => sp.Trip) | |
| 26 | + .Include(sp => sp.CreatedByUser) | |
| 27 | + .Include(sp => sp.Payments!) | |
| 28 | + .ThenInclude(p => p.FromUser) | |
| 29 | + .Include(sp => sp.Payments!) | |
| 30 | + .ThenInclude(p => p.ToUser) | |
| 31 | + .FirstOrDefaultAsync(sp => sp.Id == id); | |
| 32 | + } | |
| 33 | + | |
| 34 | + public async Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId) | |
| 35 | + { | |
| 36 | + return await DbContext.SettlementPlans | |
| 37 | + .Where(sp => sp.TripId == tripId) | |
| 38 | + .OrderByDescending(sp => sp.CreatedAt) | |
| 39 | + .Include(sp => sp.Payments!) | |
| 40 | + .ThenInclude(p => p.FromUser) | |
| 41 | + .Include(sp => sp.Payments!) | |
| 42 | + .ThenInclude(p => p.ToUser) | |
| 43 | + .FirstOrDefaultAsync(); | |
| 44 | + } | |
| 45 | + | |
| 46 | + public async Task DeletePlanWithPaymentsAsync(Guid planId) | |
| 47 | + { | |
| 48 | + await DbContext.Set<SettlementPayment>() | |
| 49 | + .Where(p => p.SettlementPlanId == planId) | |
| 50 | + .ExecuteDeleteAsync(); | |
| 51 | + await DbContext.SettlementPlans | |
| 52 | + .Where(sp => sp.Id == planId) | |
| 53 | + .ExecuteDeleteAsync(); | |
| 54 | + } | |
| 55 | +} |
added SplitApp/App.DAL.EF/Repositories/SplitPresetRepository.cs +43 −0
| @@ -0,0 +1,43 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class SplitPresetRepository : BaseRepository<SplitPreset>, ISplitPresetRepository | |
| 8 | +{ | |
| 9 | + public SplitPresetRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<SplitPreset>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.SplitPresets | |
| 16 | + .Include(sp => sp.Trip) | |
| 17 | + .Include(sp => sp.CreatedBy) | |
| 18 | + .Include(sp => sp.Members!) | |
| 19 | + .ThenInclude(m => m.User) | |
| 20 | + .ToListAsync(); | |
| 21 | + } | |
| 22 | + | |
| 23 | + public override async Task<SplitPreset?> GetByIdAsync(Guid id) | |
| 24 | + { | |
| 25 | + return await DbContext.SplitPresets | |
| 26 | + .Include(sp => sp.Trip) | |
| 27 | + .Include(sp => sp.CreatedBy) | |
| 28 | + .Include(sp => sp.Members!) | |
| 29 | + .ThenInclude(m => m.User) | |
| 30 | + .FirstOrDefaultAsync(sp => sp.Id == id); | |
| 31 | + } | |
| 32 | + | |
| 33 | + public async Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId) | |
| 34 | + { | |
| 35 | + return await DbContext.SplitPresets | |
| 36 | + .Where(sp => sp.TripId == tripId) | |
| 37 | + .Include(sp => sp.CreatedBy) | |
| 38 | + .Include(sp => sp.Members!) | |
| 39 | + .ThenInclude(m => m.User) | |
| 40 | + .OrderBy(sp => sp.Name) | |
| 41 | + .ToListAsync(); | |
| 42 | + } | |
| 43 | +} |
added SplitApp/App.DAL.EF/Repositories/TripInvitationRepository.cs +42 −0
| @@ -0,0 +1,42 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class TripInvitationRepository : BaseRepository<TripInvitation>, ITripInvitationRepository | |
| 8 | +{ | |
| 9 | + public TripInvitationRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<TripInvitation>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.TripInvitations | |
| 16 | + .Include(i => i.Trip) | |
| 17 | + .Include(i => i.InvitedByUser) | |
| 18 | + .ToListAsync(); | |
| 19 | + } | |
| 20 | + | |
| 21 | + public override async Task<TripInvitation?> GetByIdAsync(Guid id) | |
| 22 | + { | |
| 23 | + return await DbContext.TripInvitations | |
| 24 | + .Include(i => i.Trip) | |
| 25 | + .Include(i => i.InvitedByUser) | |
| 26 | + .FirstOrDefaultAsync(i => i.Id == id); | |
| 27 | + } | |
| 28 | + | |
| 29 | + public async Task<TripInvitation?> GetByTokenAsync(string token) | |
| 30 | + { | |
| 31 | + return await DbContext.TripInvitations | |
| 32 | + .FirstOrDefaultAsync(i => i.Token == token); | |
| 33 | + } | |
| 34 | + | |
| 35 | + public async Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId) | |
| 36 | + { | |
| 37 | + return await DbContext.TripInvitations | |
| 38 | + .Where(i => i.TripId == tripId && i.Status == EInvitationStatus.Pending) | |
| 39 | + .Include(i => i.InvitedByUser) | |
| 40 | + .ToListAsync(); | |
| 41 | + } | |
| 42 | +} |
added SplitApp/App.DAL.EF/Repositories/TripParticipantRepository.cs +50 −0
| @@ -0,0 +1,50 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class TripParticipantRepository : BaseRepository<TripParticipant>, ITripParticipantRepository | |
| 8 | +{ | |
| 9 | + public TripParticipantRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<TripParticipant>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.TripParticipants | |
| 16 | + .Include(tp => tp.Trip) | |
| 17 | + .Include(tp => tp.User) | |
| 18 | + .ToListAsync(); | |
| 19 | + } | |
| 20 | + | |
| 21 | + public override async Task<TripParticipant?> GetByIdAsync(Guid id) | |
| 22 | + { | |
| 23 | + return await DbContext.TripParticipants | |
| 24 | + .Include(tp => tp.Trip) | |
| 25 | + .Include(tp => tp.User) | |
| 26 | + .FirstOrDefaultAsync(tp => tp.Id == id); | |
| 27 | + } | |
| 28 | + | |
| 29 | + public async Task<bool> IsParticipantAsync(Guid tripId, Guid userId) | |
| 30 | + { | |
| 31 | + return await DbContext.TripParticipants | |
| 32 | + .AnyAsync(tp => tp.TripId == tripId && tp.UserId == userId && tp.IsActive); | |
| 33 | + } | |
| 34 | + | |
| 35 | + public async Task<bool> IsOrganizerAsync(Guid tripId, Guid userId) | |
| 36 | + { | |
| 37 | + return await DbContext.TripParticipants | |
| 38 | + .AnyAsync(tp => tp.TripId == tripId && tp.UserId == userId && tp.IsActive | |
| 39 | + && tp.Role == EParticipantRole.Organizer); | |
| 40 | + } | |
| 41 | + | |
| 42 | + public async Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId) | |
| 43 | + { | |
| 44 | + return await DbContext.TripParticipants | |
| 45 | + .Where(tp => tp.TripId == tripId && tp.IsActive) | |
| 46 | + .Include(tp => tp.User) | |
| 47 | + .OrderBy(tp => tp.User!.FirstName) | |
| 48 | + .ToListAsync(); | |
| 49 | + } | |
| 50 | +} |
added SplitApp/App.DAL.EF/Repositories/TripPollRepository.cs +52 −0
| @@ -0,0 +1,52 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class TripPollRepository : BaseRepository<TripPoll>, ITripPollRepository | |
| 8 | +{ | |
| 9 | + public TripPollRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<TripPoll>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.TripPolls | |
| 16 | + .Include(p => p.Trip) | |
| 17 | + .Include(p => p.CreatedByUser) | |
| 18 | + .Include(p => p.Options!) | |
| 19 | + .ThenInclude(o => o.Votes) | |
| 20 | + .ToListAsync(); | |
| 21 | + } | |
| 22 | + | |
| 23 | + public override async Task<TripPoll?> GetByIdAsync(Guid id) | |
| 24 | + { | |
| 25 | + return await DbContext.TripPolls | |
| 26 | + .Include(p => p.Trip) | |
| 27 | + .Include(p => p.CreatedByUser) | |
| 28 | + .Include(p => p.Options!) | |
| 29 | + .ThenInclude(o => o.Votes) | |
| 30 | + .FirstOrDefaultAsync(p => p.Id == id); | |
| 31 | + } | |
| 32 | + | |
| 33 | + public async Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId) | |
| 34 | + { | |
| 35 | + return await DbContext.TripPolls | |
| 36 | + .Where(p => p.TripId == tripId) | |
| 37 | + .Include(p => p.CreatedByUser) | |
| 38 | + .Include(p => p.Options!) | |
| 39 | + .ThenInclude(o => o.Votes) | |
| 40 | + .ToListAsync(); | |
| 41 | + } | |
| 42 | + | |
| 43 | + public async Task<TripPoll?> GetByIdWithDetailsAsync(Guid id) | |
| 44 | + { | |
| 45 | + return await DbContext.TripPolls | |
| 46 | + .Include(p => p.CreatedByUser) | |
| 47 | + .Include(p => p.Options!) | |
| 48 | + .ThenInclude(o => o.Votes!) | |
| 49 | + .ThenInclude(v => v.User) | |
| 50 | + .FirstOrDefaultAsync(p => p.Id == id); | |
| 51 | + } | |
| 52 | +} |
added SplitApp/App.DAL.EF/Repositories/TripRepository.cs +153 −0
| @@ -0,0 +1,153 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class TripRepository : BaseRepository<Trip>, ITripRepository | |
| 8 | +{ | |
| 9 | + public TripRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<Trip>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.Trips | |
| 16 | + .Include(t => t.DefaultCurrency) | |
| 17 | + .Include(t => t.CreatedBy) | |
| 18 | + .ToListAsync(); | |
| 19 | + } | |
| 20 | + | |
| 21 | + public override async Task<Trip?> GetByIdAsync(Guid id) | |
| 22 | + { | |
| 23 | + return await DbContext.Trips | |
| 24 | + .Include(t => t.DefaultCurrency) | |
| 25 | + .Include(t => t.CreatedBy) | |
| 26 | + .FirstOrDefaultAsync(t => t.Id == id); | |
| 27 | + } | |
| 28 | + | |
| 29 | + public async Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId) | |
| 30 | + { | |
| 31 | + return await DbContext.Trips | |
| 32 | + .Include(t => t.DefaultCurrency) | |
| 33 | + .Include(t => t.Participants!) | |
| 34 | + .ThenInclude(p => p.User) | |
| 35 | + .Where(t => t.Participants!.Any(p => p.UserId == userId && p.IsActive)) | |
| 36 | + .ToListAsync(); | |
| 37 | + } | |
| 38 | + | |
| 39 | + public async Task<Trip?> GetByIdWithDetailsAsync(Guid id) | |
| 40 | + { | |
| 41 | + return await DbContext.Trips | |
| 42 | + .Include(t => t.DefaultCurrency) | |
| 43 | + .Include(t => t.Participants!) | |
| 44 | + .ThenInclude(p => p.User) | |
| 45 | + .Include(t => t.Expenses) | |
| 46 | + .FirstOrDefaultAsync(t => t.Id == id); | |
| 47 | + } | |
| 48 | + | |
| 49 | + // Override because AppDbContext sets all FKs to DeleteBehavior.Restrict, | |
| 50 | + // which blocks Trip deletion as soon as any child row exists (and a Trip | |
| 51 | + // always has at least the organizer TripParticipant). | |
| 52 | + // | |
| 53 | + // Restrict also prevents EF's in-memory cascade: calling Remove(trip) | |
| 54 | + // alone makes the tracker try to null the children's FKs (which are | |
| 55 | + // non-nullable) and throws. We must therefore explicitly mark every | |
| 56 | + // descendant as Deleted before removing the Trip itself. Everything | |
| 57 | + // happens in a single SaveChanges, so it's still one DB transaction. | |
| 58 | + public override async Task<Trip?> RemoveAsync(Guid id) | |
| 59 | + { | |
| 60 | + var trip = await DbContext.Trips | |
| 61 | + .Include(t => t.Participants) | |
| 62 | + .Include(t => t.Invitations) | |
| 63 | + .Include(t => t.BudgetCategories) | |
| 64 | + .Include(t => t.WishlistItems!).ThenInclude(w => w.Votes) | |
| 65 | + .Include(t => t.Polls!).ThenInclude(p => p.Options!).ThenInclude(o => o.Votes) | |
| 66 | + .Include(t => t.Expenses!).ThenInclude(e => e.Splits) | |
| 67 | + .Include(t => t.SettlementPlans!).ThenInclude(sp => sp.Payments) | |
| 68 | + .FirstOrDefaultAsync(t => t.Id == id); | |
| 69 | + | |
| 70 | + if (trip == null) return null; | |
| 71 | + | |
| 72 | + // SplitPreset has TripId FK but no navigation collection on Trip, | |
| 73 | + // so load it separately. | |
| 74 | + var splitPresets = await DbContext.SplitPresets | |
| 75 | + .Include(sp => sp.Members) | |
| 76 | + .Where(sp => sp.TripId == id) | |
| 77 | + .ToListAsync(); | |
| 78 | + | |
| 79 | + // Delete grandchildren first, then children, then Trip. | |
| 80 | + // Order matters: a parent row cannot be deleted while its dependents | |
| 81 | + // still reference it (Restrict). | |
| 82 | + | |
| 83 | + // Expenses -> ExpenseSplits | |
| 84 | + if (trip.Expenses != null) | |
| 85 | + { | |
| 86 | + foreach (var expense in trip.Expenses) | |
| 87 | + { | |
| 88 | + if (expense.Splits != null && expense.Splits.Count > 0) | |
| 89 | + DbContext.ExpenseSplits.RemoveRange(expense.Splits); | |
| 90 | + } | |
| 91 | + DbContext.Expenses.RemoveRange(trip.Expenses); | |
| 92 | + } | |
| 93 | + | |
| 94 | + // SettlementPlans -> SettlementPayments | |
| 95 | + if (trip.SettlementPlans != null) | |
| 96 | + { | |
| 97 | + foreach (var plan in trip.SettlementPlans) | |
| 98 | + { | |
| 99 | + if (plan.Payments != null && plan.Payments.Count > 0) | |
| 100 | + DbContext.SettlementPayments.RemoveRange(plan.Payments); | |
| 101 | + } | |
| 102 | + DbContext.SettlementPlans.RemoveRange(trip.SettlementPlans); | |
| 103 | + } | |
| 104 | + | |
| 105 | + // Polls -> PollOptions -> PollVotes | |
| 106 | + if (trip.Polls != null) | |
| 107 | + { | |
| 108 | + foreach (var poll in trip.Polls) | |
| 109 | + { | |
| 110 | + if (poll.Options != null) | |
| 111 | + { | |
| 112 | + foreach (var option in poll.Options) | |
| 113 | + { | |
| 114 | + if (option.Votes != null && option.Votes.Count > 0) | |
| 115 | + DbContext.TripPollVotes.RemoveRange(option.Votes); | |
| 116 | + } | |
| 117 | + DbContext.TripPollOptions.RemoveRange(poll.Options); | |
| 118 | + } | |
| 119 | + } | |
| 120 | + DbContext.TripPolls.RemoveRange(trip.Polls); | |
| 121 | + } | |
| 122 | + | |
| 123 | + // WishlistItems -> WishlistVotes | |
| 124 | + if (trip.WishlistItems != null) | |
| 125 | + { | |
| 126 | + foreach (var item in trip.WishlistItems) | |
| 127 | + { | |
| 128 | + if (item.Votes != null && item.Votes.Count > 0) | |
| 129 | + DbContext.TripWishlistVotes.RemoveRange(item.Votes); | |
| 130 | + } | |
| 131 | + DbContext.TripWishlistItems.RemoveRange(trip.WishlistItems); | |
| 132 | + } | |
| 133 | + | |
| 134 | + // SplitPresets -> SplitPresetMembers | |
| 135 | + foreach (var preset in splitPresets) | |
| 136 | + { | |
| 137 | + if (preset.Members != null && preset.Members.Count > 0) | |
| 138 | + DbContext.SplitPresetMembers.RemoveRange(preset.Members); | |
| 139 | + } | |
| 140 | + DbContext.SplitPresets.RemoveRange(splitPresets); | |
| 141 | + | |
| 142 | + // Direct children of Trip with no further descendants referenced here | |
| 143 | + if (trip.BudgetCategories != null && trip.BudgetCategories.Count > 0) | |
| 144 | + DbContext.BudgetCategories.RemoveRange(trip.BudgetCategories); | |
| 145 | + if (trip.Invitations != null && trip.Invitations.Count > 0) | |
| 146 | + DbContext.TripInvitations.RemoveRange(trip.Invitations); | |
| 147 | + if (trip.Participants != null && trip.Participants.Count > 0) | |
| 148 | + DbContext.TripParticipants.RemoveRange(trip.Participants); | |
| 149 | + | |
| 150 | + DbContext.Trips.Remove(trip); | |
| 151 | + return trip; | |
| 152 | + } | |
| 153 | +} |
added SplitApp/App.DAL.EF/Repositories/TripWishlistItemRepository.cs +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Contracts; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class TripWishlistItemRepository : BaseRepository<TripWishlistItem>, ITripWishlistItemRepository | |
| 8 | +{ | |
| 9 | + public TripWishlistItemRepository(AppDbContext dbContext) : base(dbContext) | |
| 10 | + { | |
| 11 | + } | |
| 12 | + | |
| 13 | + public override async Task<IEnumerable<TripWishlistItem>> GetAllAsync() | |
| 14 | + { | |
| 15 | + return await DbContext.TripWishlistItems | |
| 16 | + .Include(w => w.Trip) | |
| 17 | + .Include(w => w.AddedByUser) | |
| 18 | + .Include(w => w.Votes) | |
| 19 | + .ToListAsync(); | |
| 20 | + } | |
| 21 | + | |
| 22 | + public override async Task<TripWishlistItem?> GetByIdAsync(Guid id) | |
| 23 | + { | |
| 24 | + return await DbContext.TripWishlistItems | |
| 25 | + .Include(w => w.Trip) | |
| 26 | + .Include(w => w.AddedByUser) | |
| 27 | + .Include(w => w.Votes) | |
| 28 | + .FirstOrDefaultAsync(w => w.Id == id); | |
| 29 | + } | |
| 30 | + | |
| 31 | + public async Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId) | |
| 32 | + { | |
| 33 | + return await DbContext.TripWishlistItems | |
| 34 | + .Where(w => w.TripId == tripId) | |
| 35 | + .Include(w => w.AddedByUser) | |
| 36 | + .Include(w => w.Votes) | |
| 37 | + .OrderBy(w => w.DisplayOrder) | |
| 38 | + .ToListAsync(); | |
| 39 | + } | |
| 40 | +} |
added SplitApp/App.DAL.EF/Repositories/UserRepository.cs +32 −0
| @@ -0,0 +1,32 @@ | ||
| 1 | +using App.Domain.Contracts; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Microsoft.EntityFrameworkCore; | |
| 4 | + | |
| 5 | +namespace App.DAL.EF.Repositories; | |
| 6 | + | |
| 7 | +public class UserRepository : BaseRepository<AppUser>, IUserRepository | |
| 8 | +{ | |
| 9 | + public UserRepository(AppDbContext 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 | +} |
added SplitApp/App.DAL.EF/Seeding/AppDataInit.cs +642 −0
| @@ -0,0 +1,642 @@ | ||
| 1 | +using App.Domain; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | +using Microsoft.AspNetCore.Identity; | |
| 5 | +using Microsoft.EntityFrameworkCore; | |
| 6 | + | |
| 7 | +namespace App.DAL.EF.Seeding; | |
| 8 | + | |
| 9 | +public static class AppDataInit | |
| 10 | +{ | |
| 11 | + public static void DeleteDatabase(AppDbContext context) | |
| 12 | + { | |
| 13 | + context.Database.EnsureDeleted(); | |
| 14 | + } | |
| 15 | + | |
| 16 | + public static void MigrateDatabase(AppDbContext context) | |
| 17 | + { | |
| 18 | + context.Database.Migrate(); | |
| 19 | + } | |
| 20 | + | |
| 21 | + public static void SeedIdentity(UserManager<AppUser> userManager, RoleManager<AppRole> roleManager) | |
| 22 | + { | |
| 23 | + foreach (var roleName in InitialData.Roles) | |
| 24 | + { | |
| 25 | + var role = roleManager.FindByNameAsync(roleName).Result; | |
| 26 | + if (role != null) continue; | |
| 27 | + | |
| 28 | + role = new AppRole { Name = roleName }; | |
| 29 | + var result = roleManager.CreateAsync(role).Result; | |
| 30 | + if (!result.Succeeded) | |
| 31 | + { | |
| 32 | + throw new ApplicationException($"Role creation failed: {roleName}"); | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + foreach (var userData in InitialData.SeedUsers()) | |
| 37 | + { | |
| 38 | + var user = userManager.FindByEmailAsync(userData.email).Result; | |
| 39 | + if (user != null) continue; | |
| 40 | + | |
| 41 | + user = new AppUser | |
| 42 | + { | |
| 43 | + Email = userData.email, | |
| 44 | + UserName = userData.email, | |
| 45 | + FirstName = userData.firstName, | |
| 46 | + LastName = userData.lastName, | |
| 47 | + EmailConfirmed = true, | |
| 48 | + }; | |
| 49 | + | |
| 50 | + // Allow overriding the seeded admin password via env (e.g. in production). | |
| 51 | + // Falls back to the built-in value so local development is unaffected. | |
| 52 | + var password = userData.password; | |
| 53 | + if (userData.roles.Contains("admin")) | |
| 54 | + { | |
| 55 | + var envPassword = Environment.GetEnvironmentVariable("SEED_ADMIN_PASSWORD"); | |
| 56 | + if (!string.IsNullOrWhiteSpace(envPassword)) | |
| 57 | + { | |
| 58 | + password = envPassword; | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + var result = userManager.CreateAsync(user, password).Result; | |
| 63 | + if (!result.Succeeded) | |
| 64 | + { | |
| 65 | + throw new ApplicationException($"User creation failed: {userData.email}"); | |
| 66 | + } | |
| 67 | + | |
| 68 | + foreach (var roleName in userData.roles) | |
| 69 | + { | |
| 70 | + var roleResult = userManager.AddToRoleAsync(user, roleName).Result; | |
| 71 | + if (!roleResult.Succeeded) | |
| 72 | + { | |
| 73 | + throw new ApplicationException($"Role assignment failed: {userData.email} -> {roleName}"); | |
| 74 | + } | |
| 75 | + } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + | |
| 79 | + public static void SeedAppData(AppDbContext context) | |
| 80 | + { | |
| 81 | + // Seed currencies | |
| 82 | + if (!context.Currencies.Any()) | |
| 83 | + { | |
| 84 | + foreach (var currencyData in InitialData.Currencies) | |
| 85 | + { | |
| 86 | + var name = new LangStr(currencyData.NameEn, "en"); | |
| 87 | + name.SetTranslation(currencyData.NameEt, "et"); | |
| 88 | + | |
| 89 | + context.Currencies.Add(new Currency | |
| 90 | + { | |
| 91 | + Code = currencyData.Code, | |
| 92 | + Name = name, | |
| 93 | + Symbol = currencyData.Symbol, | |
| 94 | + }); | |
| 95 | + } | |
| 96 | + | |
| 97 | + context.SaveChanges(); | |
| 98 | + } | |
| 99 | + | |
| 100 | + // Seed example trips, participants, expenses, polls, wishlist items | |
| 101 | + if (!context.Trips.Any()) | |
| 102 | + { | |
| 103 | + SeedExampleData(context); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + private static void SeedExampleData(AppDbContext context) | |
| 108 | + { | |
| 109 | + // Get users | |
| 110 | + var admin = context.Users.First(u => u.Email == "admin@taltech.ee"); | |
| 111 | + var testUser = context.Users.First(u => u.Email == "user@taltech.ee"); | |
| 112 | + var alice = context.Users.First(u => u.Email == "alice@taltech.ee"); | |
| 113 | + var bob = context.Users.First(u => u.Email == "bob@taltech.ee"); | |
| 114 | + var charlie = context.Users.First(u => u.Email == "charlie@taltech.ee"); | |
| 115 | + var diana = context.Users.First(u => u.Email == "diana@taltech.ee"); | |
| 116 | + | |
| 117 | + var eur = context.Currencies.First(c => c.Code == "EUR"); | |
| 118 | + var usd = context.Currencies.First(c => c.Code == "USD"); | |
| 119 | + var gbp = context.Currencies.First(c => c.Code == "GBP"); | |
| 120 | + | |
| 121 | + // ============================================================ | |
| 122 | + // TRIP 1: Barcelona Weekend (Active, 4 participants, lots of expenses) | |
| 123 | + // ============================================================ | |
| 124 | + var trip1 = new Trip | |
| 125 | + { | |
| 126 | + Name = "Barcelona Weekend", | |
| 127 | + Description = "A long weekend exploring Barcelona with friends", | |
| 128 | + Destination = "Barcelona, Spain", | |
| 129 | + StartDate = new DateTime(2026, 4, 10, 0, 0, 0, DateTimeKind.Utc), | |
| 130 | + EndDate = new DateTime(2026, 4, 13, 0, 0, 0, DateTimeKind.Utc), | |
| 131 | + Status = ETripStatus.Active, | |
| 132 | + DefaultCurrencyId = eur.Id, | |
| 133 | + CreatedById = admin.Id, | |
| 134 | + }; | |
| 135 | + context.Trips.Add(trip1); | |
| 136 | + | |
| 137 | + var tp1Admin = new TripParticipant | |
| 138 | + { | |
| 139 | + TripId = trip1.Id, UserId = admin.Id, | |
| 140 | + Role = EParticipantRole.Organizer, JoinedAt = DateTime.UtcNow.AddDays(-10), IsActive = true | |
| 141 | + }; | |
| 142 | + var tp1Alice = new TripParticipant | |
| 143 | + { | |
| 144 | + TripId = trip1.Id, UserId = alice.Id, | |
| 145 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-9), IsActive = true | |
| 146 | + }; | |
| 147 | + var tp1Bob = new TripParticipant | |
| 148 | + { | |
| 149 | + TripId = trip1.Id, UserId = bob.Id, | |
| 150 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-8), IsActive = true | |
| 151 | + }; | |
| 152 | + var tp1Charlie = new TripParticipant | |
| 153 | + { | |
| 154 | + TripId = trip1.Id, UserId = charlie.Id, | |
| 155 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-7), IsActive = true | |
| 156 | + }; | |
| 157 | + context.TripParticipants.AddRange(tp1Admin, tp1Alice, tp1Bob, tp1Charlie); | |
| 158 | + | |
| 159 | + // Budget categories for trip 1 (with planned spending limits per proposal) | |
| 160 | + var cat1Food = new BudgetCategory { TripId = trip1.Id, Name = new LangStr("Food & Drinks", "en") { ["et"] = "Toit ja joogid" }, IconName = "cup-hot", PlannedAmount = 400m, DisplayOrder = 0 }; | |
| 161 | + var cat1Transport = new BudgetCategory { TripId = trip1.Id, Name = new LangStr("Transport", "en") { ["et"] = "Transport" }, IconName = "bus-front", PlannedAmount = 150m, DisplayOrder = 1 }; | |
| 162 | + var cat1Activities = new BudgetCategory { TripId = trip1.Id, Name = new LangStr("Activities", "en") { ["et"] = "Tegevused" }, IconName = "binoculars", PlannedAmount = 200m, DisplayOrder = 2 }; | |
| 163 | + var cat1Accommodation = new BudgetCategory { TripId = trip1.Id, Name = new LangStr("Accommodation", "en") { ["et"] = "Majutus" }, IconName = "house", PlannedAmount = 500m, DisplayOrder = 3 }; | |
| 164 | + context.BudgetCategories.AddRange(cat1Food, cat1Transport, cat1Activities, cat1Accommodation); | |
| 165 | + | |
| 166 | + // Split presets for trip 1 | |
| 167 | + var preset1All = new SplitPreset | |
| 168 | + { | |
| 169 | + TripId = trip1.Id, Name = "Everyone equal", SplitMethod = ESplitMethod.EqualAll, | |
| 170 | + CreatedById = admin.Id | |
| 171 | + }; | |
| 172 | + var preset1Hotel = new SplitPreset | |
| 173 | + { | |
| 174 | + TripId = trip1.Id, Name = "Hotel group", SplitMethod = ESplitMethod.EqualSubset, | |
| 175 | + CreatedById = admin.Id | |
| 176 | + }; | |
| 177 | + context.SplitPresets.AddRange(preset1All, preset1Hotel); | |
| 178 | + | |
| 179 | + // Members for "Hotel group" preset (3 of 4 participants) | |
| 180 | + context.SplitPresetMembers.AddRange( | |
| 181 | + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = admin.Id }, | |
| 182 | + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = alice.Id }, | |
| 183 | + new SplitPresetMember { SplitPresetId = preset1Hotel.Id, UserId = bob.Id } | |
| 184 | + ); | |
| 185 | + | |
| 186 | + // Expenses for trip 1 | |
| 187 | + var expenses1 = new List<(string desc, decimal amount, Guid paidBy, Guid? catId, DateTime date, ESplitMethod split)> | |
| 188 | + { | |
| 189 | + ("Airbnb apartment (3 nights)", 480.00m, admin.Id, cat1Accommodation.Id, DateTime.UtcNow.AddDays(-5), ESplitMethod.EqualAll), | |
| 190 | + ("Airport taxi", 35.00m, alice.Id, cat1Transport.Id, DateTime.UtcNow.AddDays(-5), ESplitMethod.EqualAll), | |
| 191 | + ("Grocery shopping", 62.50m, bob.Id, cat1Food.Id, DateTime.UtcNow.AddDays(-4), ESplitMethod.EqualAll), | |
| 192 | + ("Dinner at La Boqueria", 128.00m, admin.Id, cat1Food.Id, DateTime.UtcNow.AddDays(-4), ESplitMethod.EqualAll), | |
| 193 | + ("Sagrada Familia tickets", 104.00m, charlie.Id, cat1Activities.Id, DateTime.UtcNow.AddDays(-3), ESplitMethod.EqualAll), | |
| 194 | + ("Metro passes (4x)", 44.00m, alice.Id, cat1Transport.Id, DateTime.UtcNow.AddDays(-3), ESplitMethod.EqualAll), | |
| 195 | + ("Tapas bar lunch", 76.00m, bob.Id, cat1Food.Id, DateTime.UtcNow.AddDays(-3), ESplitMethod.EqualAll), | |
| 196 | + ("Park Guell entry", 40.00m, admin.Id, cat1Activities.Id, DateTime.UtcNow.AddDays(-2), ESplitMethod.EqualAll), | |
| 197 | + ("Sangria and snacks", 48.50m, charlie.Id, cat1Food.Id, DateTime.UtcNow.AddDays(-2), ESplitMethod.EqualAll), | |
| 198 | + ("Souvenir shopping", 55.00m, alice.Id, cat1Food.Id, DateTime.UtcNow.AddDays(-1), ESplitMethod.EqualSubset), | |
| 199 | + ("Return taxi to airport", 38.00m, bob.Id, cat1Transport.Id, DateTime.UtcNow.AddDays(-1), ESplitMethod.EqualAll), | |
| 200 | + }; | |
| 201 | + | |
| 202 | + var trip1Participants = new[] { admin.Id, alice.Id, bob.Id, charlie.Id }; | |
| 203 | + | |
| 204 | + foreach (var (desc, amount, paidBy, catId, date, split) in expenses1) | |
| 205 | + { | |
| 206 | + var expense = new Expense | |
| 207 | + { | |
| 208 | + TripId = trip1.Id, | |
| 209 | + PaidByUserId = paidBy, | |
| 210 | + Amount = amount, | |
| 211 | + Description = desc, | |
| 212 | + ExpenseDate = date, | |
| 213 | + BudgetCategoryId = catId, | |
| 214 | + CurrencyId = eur.Id, | |
| 215 | + SplitMethod = split, | |
| 216 | + }; | |
| 217 | + context.Expenses.Add(expense); | |
| 218 | + | |
| 219 | + // Create equal splits among all 4 participants | |
| 220 | + var splitParticipants = split == ESplitMethod.EqualSubset | |
| 221 | + ? new[] { alice.Id, bob.Id, charlie.Id } // souvenir shopping - only 3 | |
| 222 | + : trip1Participants; | |
| 223 | + | |
| 224 | + var count = splitParticipants.Length; | |
| 225 | + var baseAmt = Math.Floor(amount / count * 100) / 100; | |
| 226 | + var remainder = amount - baseAmt * count; | |
| 227 | + | |
| 228 | + for (var i = 0; i < count; i++) | |
| 229 | + { | |
| 230 | + var splitAmt = baseAmt + (i < (int)(remainder * 100) ? 0.01m : 0); | |
| 231 | + context.ExpenseSplits.Add(new ExpenseSplit | |
| 232 | + { | |
| 233 | + ExpenseId = expense.Id, | |
| 234 | + UserId = splitParticipants[i], | |
| 235 | + Amount = splitAmt, | |
| 236 | + }); | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + // Poll for trip 1 | |
| 241 | + var poll1 = new TripPoll | |
| 242 | + { | |
| 243 | + TripId = trip1.Id, CreatedByUserId = admin.Id, | |
| 244 | + Question = "Where should we eat on the last night?", | |
| 245 | + AllowMultipleVotes = false, IsAnonymous = false, | |
| 246 | + }; | |
| 247 | + context.TripPolls.Add(poll1); | |
| 248 | + | |
| 249 | + var pollOpt1A = new TripPollOption { PollId = poll1.Id, Text = "Can Culleretes (oldest restaurant)", DisplayOrder = 0 }; | |
| 250 | + var pollOpt1B = new TripPollOption { PollId = poll1.Id, Text = "El Xampanyet (tapas)", DisplayOrder = 1 }; | |
| 251 | + var pollOpt1C = new TripPollOption { PollId = poll1.Id, Text = "Cerveceria Catalana", DisplayOrder = 2 }; | |
| 252 | + context.TripPollOptions.AddRange(pollOpt1A, pollOpt1B, pollOpt1C); | |
| 253 | + | |
| 254 | + context.TripPollVotes.Add(new TripPollVote { PollOptionId = pollOpt1B.Id, UserId = admin.Id }); | |
| 255 | + context.TripPollVotes.Add(new TripPollVote { PollOptionId = pollOpt1A.Id, UserId = alice.Id }); | |
| 256 | + context.TripPollVotes.Add(new TripPollVote { PollOptionId = pollOpt1B.Id, UserId = bob.Id }); | |
| 257 | + | |
| 258 | + // Wishlist for trip 1 | |
| 259 | + context.TripWishlistItems.AddRange( | |
| 260 | + new TripWishlistItem | |
| 261 | + { | |
| 262 | + TripId = trip1.Id, AddedByUserId = alice.Id, Title = "Casa Batllo", | |
| 263 | + Description = "Gaudi's famous building on Passeig de Gracia", | |
| 264 | + Category = EWishlistCategory.Place, Priority = EWishlistPriority.MustDo, | |
| 265 | + EstimatedCost = 35m, Location = "Passeig de Gracia 43", DisplayOrder = 0 | |
| 266 | + }, | |
| 267 | + new TripWishlistItem | |
| 268 | + { | |
| 269 | + TripId = trip1.Id, AddedByUserId = bob.Id, Title = "Beach volleyball", | |
| 270 | + Description = "Play at Barceloneta beach in the morning", | |
| 271 | + Category = EWishlistCategory.Activity, Priority = EWishlistPriority.NiceToHave, | |
| 272 | + Location = "Barceloneta Beach", DisplayOrder = 1 | |
| 273 | + }, | |
| 274 | + new TripWishlistItem | |
| 275 | + { | |
| 276 | + TripId = trip1.Id, AddedByUserId = charlie.Id, Title = "Flamenco show", | |
| 277 | + Description = "Evening flamenco performance", | |
| 278 | + Category = EWishlistCategory.Activity, Priority = EWishlistPriority.MustDo, | |
| 279 | + EstimatedCost = 45m, DisplayOrder = 2 | |
| 280 | + }, | |
| 281 | + new TripWishlistItem | |
| 282 | + { | |
| 283 | + TripId = trip1.Id, AddedByUserId = admin.Id, Title = "La Paradeta seafood", | |
| 284 | + Description = "Fresh seafood market-style restaurant", | |
| 285 | + Category = EWishlistCategory.Restaurant, Priority = EWishlistPriority.Optional, | |
| 286 | + Location = "Carrer Comercial 7", DisplayOrder = 3 | |
| 287 | + } | |
| 288 | + ); | |
| 289 | + | |
| 290 | + // ============================================================ | |
| 291 | + // TRIP 2: London Business Trip (Settled, 3 participants) | |
| 292 | + // ============================================================ | |
| 293 | + var trip2 = new Trip | |
| 294 | + { | |
| 295 | + Name = "London Business Trip", | |
| 296 | + Description = "Conference and team meetings in London", | |
| 297 | + Destination = "London, UK", | |
| 298 | + StartDate = new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc), | |
| 299 | + EndDate = new DateTime(2026, 3, 4, 0, 0, 0, DateTimeKind.Utc), | |
| 300 | + Status = ETripStatus.Settled, | |
| 301 | + DefaultCurrencyId = gbp.Id, | |
| 302 | + CreatedById = testUser.Id, | |
| 303 | + }; | |
| 304 | + context.Trips.Add(trip2); | |
| 305 | + | |
| 306 | + var tp2Test = new TripParticipant | |
| 307 | + { | |
| 308 | + TripId = trip2.Id, UserId = testUser.Id, | |
| 309 | + Role = EParticipantRole.Organizer, JoinedAt = DateTime.UtcNow.AddDays(-30), IsActive = true | |
| 310 | + }; | |
| 311 | + var tp2Admin = new TripParticipant | |
| 312 | + { | |
| 313 | + TripId = trip2.Id, UserId = admin.Id, | |
| 314 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-29), IsActive = true | |
| 315 | + }; | |
| 316 | + var tp2Diana = new TripParticipant | |
| 317 | + { | |
| 318 | + TripId = trip2.Id, UserId = diana.Id, | |
| 319 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-28), IsActive = true | |
| 320 | + }; | |
| 321 | + context.TripParticipants.AddRange(tp2Test, tp2Admin, tp2Diana); | |
| 322 | + | |
| 323 | + var trip2Participants = new[] { testUser.Id, admin.Id, diana.Id }; | |
| 324 | + | |
| 325 | + var expenses2 = new List<(string desc, decimal amount, Guid paidBy, DateTime date)> | |
| 326 | + { | |
| 327 | + ("Hotel (2 nights)", 340.00m, testUser.Id, DateTime.UtcNow.AddDays(-28)), | |
| 328 | + ("Heathrow Express", 75.00m, admin.Id, DateTime.UtcNow.AddDays(-28)), | |
| 329 | + ("Conference dinner", 185.00m, testUser.Id, DateTime.UtcNow.AddDays(-27)), | |
| 330 | + ("Uber rides", 48.00m, diana.Id, DateTime.UtcNow.AddDays(-27)), | |
| 331 | + ("Team lunch", 92.00m, admin.Id, DateTime.UtcNow.AddDays(-26)), | |
| 332 | + ("Coffee & snacks", 24.50m, diana.Id, DateTime.UtcNow.AddDays(-26)), | |
| 333 | + }; | |
| 334 | + | |
| 335 | + foreach (var (desc, amount, paidBy, date) in expenses2) | |
| 336 | + { | |
| 337 | + var expense = new Expense | |
| 338 | + { | |
| 339 | + TripId = trip2.Id, PaidByUserId = paidBy, Amount = amount, | |
| 340 | + Description = desc, ExpenseDate = date, CurrencyId = gbp.Id, | |
| 341 | + SplitMethod = ESplitMethod.EqualAll, | |
| 342 | + }; | |
| 343 | + context.Expenses.Add(expense); | |
| 344 | + | |
| 345 | + var count = trip2Participants.Length; | |
| 346 | + var baseAmt = Math.Floor(amount / count * 100) / 100; | |
| 347 | + var remainder = amount - baseAmt * count; | |
| 348 | + for (var i = 0; i < count; i++) | |
| 349 | + { | |
| 350 | + context.ExpenseSplits.Add(new ExpenseSplit | |
| 351 | + { | |
| 352 | + ExpenseId = expense.Id, | |
| 353 | + UserId = trip2Participants[i], | |
| 354 | + Amount = baseAmt + (i < (int)(remainder * 100) ? 0.01m : 0), | |
| 355 | + }); | |
| 356 | + } | |
| 357 | + } | |
| 358 | + | |
| 359 | + // Settlement plan for trip 2 (completed) | |
| 360 | + var plan2 = new SettlementPlan | |
| 361 | + { | |
| 362 | + TripId = trip2.Id, CreatedByUserId = testUser.Id, | |
| 363 | + TotalAmount = 255m, Status = ESettlementStatus.Completed, | |
| 364 | + CompletedAt = DateTime.UtcNow.AddDays(-20), | |
| 365 | + }; | |
| 366 | + context.SettlementPlans.Add(plan2); | |
| 367 | + | |
| 368 | + context.SettlementPayments.Add(new SettlementPayment | |
| 369 | + { | |
| 370 | + SettlementPlanId = plan2.Id, FromUserId = diana.Id, ToUserId = testUser.Id, | |
| 371 | + Amount = 130.50m, Status = EPaymentStatus.Confirmed, | |
| 372 | + MarkedPaidAt = DateTime.UtcNow.AddDays(-22), ConfirmedAt = DateTime.UtcNow.AddDays(-20), | |
| 373 | + }); | |
| 374 | + context.SettlementPayments.Add(new SettlementPayment | |
| 375 | + { | |
| 376 | + SettlementPlanId = plan2.Id, FromUserId = diana.Id, ToUserId = admin.Id, | |
| 377 | + Amount = 28.00m, Status = EPaymentStatus.Confirmed, | |
| 378 | + MarkedPaidAt = DateTime.UtcNow.AddDays(-21), ConfirmedAt = DateTime.UtcNow.AddDays(-20), | |
| 379 | + }); | |
| 380 | + | |
| 381 | + // ============================================================ | |
| 382 | + // TRIP 3: Summer Cabin (Active, 5 participants, with pending settlement) | |
| 383 | + // ============================================================ | |
| 384 | + var trip3 = new Trip | |
| 385 | + { | |
| 386 | + Name = "Summer Cabin Getaway", | |
| 387 | + Description = "Relaxing weekend at a cabin by the lake", | |
| 388 | + Destination = "Otepaa, Estonia", | |
| 389 | + StartDate = new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc), | |
| 390 | + EndDate = new DateTime(2026, 5, 18, 0, 0, 0, DateTimeKind.Utc), | |
| 391 | + Status = ETripStatus.Active, | |
| 392 | + DefaultCurrencyId = eur.Id, | |
| 393 | + CreatedById = alice.Id, | |
| 394 | + }; | |
| 395 | + context.Trips.Add(trip3); | |
| 396 | + | |
| 397 | + var tp3Alice = new TripParticipant | |
| 398 | + { | |
| 399 | + TripId = trip3.Id, UserId = alice.Id, | |
| 400 | + Role = EParticipantRole.Organizer, JoinedAt = DateTime.UtcNow.AddDays(-3), IsActive = true | |
| 401 | + }; | |
| 402 | + var tp3Bob = new TripParticipant | |
| 403 | + { | |
| 404 | + TripId = trip3.Id, UserId = bob.Id, | |
| 405 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-2), IsActive = true | |
| 406 | + }; | |
| 407 | + var tp3Charlie = new TripParticipant | |
| 408 | + { | |
| 409 | + TripId = trip3.Id, UserId = charlie.Id, | |
| 410 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-2), IsActive = true | |
| 411 | + }; | |
| 412 | + var tp3Diana = new TripParticipant | |
| 413 | + { | |
| 414 | + TripId = trip3.Id, UserId = diana.Id, | |
| 415 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-1), IsActive = true | |
| 416 | + }; | |
| 417 | + var tp3Admin = new TripParticipant | |
| 418 | + { | |
| 419 | + TripId = trip3.Id, UserId = admin.Id, | |
| 420 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-1), IsActive = true | |
| 421 | + }; | |
| 422 | + context.TripParticipants.AddRange(tp3Alice, tp3Bob, tp3Charlie, tp3Diana, tp3Admin); | |
| 423 | + | |
| 424 | + var trip3Participants = new[] { alice.Id, bob.Id, charlie.Id, diana.Id, admin.Id }; | |
| 425 | + | |
| 426 | + var expenses3 = new List<(string desc, decimal amount, Guid paidBy, DateTime date)> | |
| 427 | + { | |
| 428 | + ("Cabin rental (3 nights)", 600.00m, alice.Id, DateTime.UtcNow.AddDays(-2)), | |
| 429 | + ("BBQ supplies and meat", 95.00m, bob.Id, DateTime.UtcNow.AddDays(-1)), | |
| 430 | + ("Firewood and charcoal", 25.00m, charlie.Id, DateTime.UtcNow.AddDays(-1)), | |
| 431 | + ("Drinks and beverages", 78.00m, diana.Id, DateTime.UtcNow), | |
| 432 | + ("Fishing gear rental", 40.00m, admin.Id, DateTime.UtcNow), | |
| 433 | + ("Breakfast groceries", 42.00m, alice.Id, DateTime.UtcNow), | |
| 434 | + ("Canoe rental (half day)", 60.00m, bob.Id, DateTime.UtcNow), | |
| 435 | + }; | |
| 436 | + | |
| 437 | + foreach (var (desc, amount, paidBy, date) in expenses3) | |
| 438 | + { | |
| 439 | + var expense = new Expense | |
| 440 | + { | |
| 441 | + TripId = trip3.Id, PaidByUserId = paidBy, Amount = amount, | |
| 442 | + Description = desc, ExpenseDate = date, CurrencyId = eur.Id, | |
| 443 | + SplitMethod = ESplitMethod.EqualAll, | |
| 444 | + }; | |
| 445 | + context.Expenses.Add(expense); | |
| 446 | + | |
| 447 | + var count = trip3Participants.Length; | |
| 448 | + var baseAmt = Math.Floor(amount / count * 100) / 100; | |
| 449 | + var remainder = amount - baseAmt * count; | |
| 450 | + for (var i = 0; i < count; i++) | |
| 451 | + { | |
| 452 | + context.ExpenseSplits.Add(new ExpenseSplit | |
| 453 | + { | |
| 454 | + ExpenseId = expense.Id, | |
| 455 | + UserId = trip3Participants[i], | |
| 456 | + Amount = baseAmt + (i < (int)(remainder * 100) ? 0.01m : 0), | |
| 457 | + }); | |
| 458 | + } | |
| 459 | + } | |
| 460 | + | |
| 461 | + // Settlement plan for trip 3 (in progress - some paid, some pending) | |
| 462 | + var plan3 = new SettlementPlan | |
| 463 | + { | |
| 464 | + TripId = trip3.Id, CreatedByUserId = alice.Id, | |
| 465 | + TotalAmount = 350m, Status = ESettlementStatus.InProgress, | |
| 466 | + }; | |
| 467 | + context.SettlementPlans.Add(plan3); | |
| 468 | + | |
| 469 | + context.SettlementPayments.Add(new SettlementPayment | |
| 470 | + { | |
| 471 | + SettlementPlanId = plan3.Id, FromUserId = charlie.Id, ToUserId = alice.Id, | |
| 472 | + Amount = 145.00m, Status = EPaymentStatus.MarkedPaid, | |
| 473 | + MarkedPaidAt = DateTime.UtcNow.AddHours(-2), | |
| 474 | + }); | |
| 475 | + context.SettlementPayments.Add(new SettlementPayment | |
| 476 | + { | |
| 477 | + SettlementPlanId = plan3.Id, FromUserId = diana.Id, ToUserId = alice.Id, | |
| 478 | + Amount = 110.00m, Status = EPaymentStatus.Pending, | |
| 479 | + }); | |
| 480 | + context.SettlementPayments.Add(new SettlementPayment | |
| 481 | + { | |
| 482 | + SettlementPlanId = plan3.Id, FromUserId = admin.Id, ToUserId = bob.Id, | |
| 483 | + Amount = 95.00m, Status = EPaymentStatus.Pending, | |
| 484 | + }); | |
| 485 | + | |
| 486 | + // Poll for trip 3 | |
| 487 | + var poll3 = new TripPoll | |
| 488 | + { | |
| 489 | + TripId = trip3.Id, CreatedByUserId = alice.Id, | |
| 490 | + Question = "What activity for Saturday afternoon?", | |
| 491 | + AllowMultipleVotes = true, IsAnonymous = false, | |
| 492 | + }; | |
| 493 | + context.TripPolls.Add(poll3); | |
| 494 | + | |
| 495 | + var pollOpt3A = new TripPollOption { PollId = poll3.Id, Text = "Hiking to the viewpoint", DisplayOrder = 0 }; | |
| 496 | + var pollOpt3B = new TripPollOption { PollId = poll3.Id, Text = "Fishing at the lake", DisplayOrder = 1 }; | |
| 497 | + var pollOpt3C = new TripPollOption { PollId = poll3.Id, Text = "Board games at the cabin", DisplayOrder = 2 }; | |
| 498 | + var pollOpt3D = new TripPollOption { PollId = poll3.Id, Text = "Cycling around the area", DisplayOrder = 3 }; | |
| 499 | + context.TripPollOptions.AddRange(pollOpt3A, pollOpt3B, pollOpt3C, pollOpt3D); | |
| 500 | + | |
| 501 | + context.TripPollVotes.AddRange( | |
| 502 | + new TripPollVote { PollOptionId = pollOpt3A.Id, UserId = alice.Id }, | |
| 503 | + new TripPollVote { PollOptionId = pollOpt3B.Id, UserId = alice.Id }, | |
| 504 | + new TripPollVote { PollOptionId = pollOpt3A.Id, UserId = bob.Id }, | |
| 505 | + new TripPollVote { PollOptionId = pollOpt3C.Id, UserId = charlie.Id }, | |
| 506 | + new TripPollVote { PollOptionId = pollOpt3B.Id, UserId = diana.Id }, | |
| 507 | + new TripPollVote { PollOptionId = pollOpt3D.Id, UserId = admin.Id }, | |
| 508 | + new TripPollVote { PollOptionId = pollOpt3A.Id, UserId = admin.Id } | |
| 509 | + ); | |
| 510 | + | |
| 511 | + // Wishlist for trip 3 | |
| 512 | + context.TripWishlistItems.AddRange( | |
| 513 | + new TripWishlistItem | |
| 514 | + { | |
| 515 | + TripId = trip3.Id, AddedByUserId = bob.Id, Title = "Smoke sauna experience", | |
| 516 | + Description = "Traditional Estonian smoke sauna at the lakeside", | |
| 517 | + Category = EWishlistCategory.Activity, Priority = EWishlistPriority.MustDo, | |
| 518 | + EstimatedCost = 15m, DisplayOrder = 0, | |
| 519 | + }, | |
| 520 | + new TripWishlistItem | |
| 521 | + { | |
| 522 | + TripId = trip3.Id, AddedByUserId = diana.Id, Title = "Visit Otepaa Adventure Park", | |
| 523 | + Description = "Rope courses and zip lines in the forest", | |
| 524 | + Category = EWishlistCategory.Activity, Priority = EWishlistPriority.NiceToHave, | |
| 525 | + EstimatedCost = 25m, Location = "Otepaa Adventure Park", DisplayOrder = 1, | |
| 526 | + }, | |
| 527 | + new TripWishlistItem | |
| 528 | + { | |
| 529 | + TripId = trip3.Id, AddedByUserId = alice.Id, Title = "Puhajaarve beach", | |
| 530 | + Description = "Swimming and sunbathing at the sacred lake", | |
| 531 | + Category = EWishlistCategory.Place, Priority = EWishlistPriority.MustDo, | |
| 532 | + Location = "Puhajaarv", DisplayOrder = 2, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddHours(-5), | |
| 533 | + } | |
| 534 | + ); | |
| 535 | + | |
| 536 | + // Invitation for trip 3 | |
| 537 | + context.TripInvitations.Add(new TripInvitation | |
| 538 | + { | |
| 539 | + TripId = trip3.Id, InvitedByUserId = alice.Id, | |
| 540 | + Token = Guid.NewGuid().ToString("N"), | |
| 541 | + Status = EInvitationStatus.Pending, | |
| 542 | + ExpiresAt = DateTime.UtcNow.AddDays(7), | |
| 543 | + }); | |
| 544 | + | |
| 545 | + // ============================================================ | |
| 546 | + // TRIP 4: New York City (Archived, completed) | |
| 547 | + // ============================================================ | |
| 548 | + var trip4 = new Trip | |
| 549 | + { | |
| 550 | + Name = "NYC Adventure", | |
| 551 | + Description = "Week in New York City exploring Manhattan and Brooklyn", | |
| 552 | + Destination = "New York, USA", | |
| 553 | + StartDate = new DateTime(2025, 12, 20, 0, 0, 0, DateTimeKind.Utc), | |
| 554 | + EndDate = new DateTime(2025, 12, 27, 0, 0, 0, DateTimeKind.Utc), | |
| 555 | + Status = ETripStatus.Archived, | |
| 556 | + DefaultCurrencyId = usd.Id, | |
| 557 | + CreatedById = bob.Id, | |
| 558 | + }; | |
| 559 | + context.Trips.Add(trip4); | |
| 560 | + | |
| 561 | + context.TripParticipants.AddRange( | |
| 562 | + new TripParticipant | |
| 563 | + { | |
| 564 | + TripId = trip4.Id, UserId = bob.Id, | |
| 565 | + Role = EParticipantRole.Organizer, JoinedAt = DateTime.UtcNow.AddDays(-90), IsActive = true | |
| 566 | + }, | |
| 567 | + new TripParticipant | |
| 568 | + { | |
| 569 | + TripId = trip4.Id, UserId = alice.Id, | |
| 570 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-89), IsActive = true | |
| 571 | + }, | |
| 572 | + new TripParticipant | |
| 573 | + { | |
| 574 | + TripId = trip4.Id, UserId = diana.Id, | |
| 575 | + Role = EParticipantRole.Participant, JoinedAt = DateTime.UtcNow.AddDays(-88), IsActive = true | |
| 576 | + } | |
| 577 | + ); | |
| 578 | + | |
| 579 | + var trip4Participants = new[] { bob.Id, alice.Id, diana.Id }; | |
| 580 | + | |
| 581 | + var expenses4 = new List<(string desc, decimal amount, Guid paidBy, DateTime date)> | |
| 582 | + { | |
| 583 | + ("Hotel in Midtown (6 nights)", 1800.00m, bob.Id, new DateTime(2025, 12, 20, 12, 0, 0, DateTimeKind.Utc)), | |
| 584 | + ("Broadway show tickets", 450.00m, alice.Id, new DateTime(2025, 12, 21, 20, 0, 0, DateTimeKind.Utc)), | |
| 585 | + ("Statue of Liberty ferry", 63.00m, diana.Id, new DateTime(2025, 12, 22, 10, 0, 0, DateTimeKind.Utc)), | |
| 586 | + ("Central Park bike rental", 75.00m, bob.Id, new DateTime(2025, 12, 23, 14, 0, 0, DateTimeKind.Utc)), | |
| 587 | + ("Dinner in Little Italy", 195.00m, alice.Id, new DateTime(2025, 12, 23, 20, 0, 0, DateTimeKind.Utc)), | |
| 588 | + ("Brooklyn Bridge walk snacks", 28.00m, diana.Id, new DateTime(2025, 12, 24, 11, 0, 0, DateTimeKind.Utc)), | |
| 589 | + ("MoMA tickets", 75.00m, bob.Id, new DateTime(2025, 12, 25, 10, 0, 0, DateTimeKind.Utc)), | |
| 590 | + ("Times Square shopping", 220.00m, alice.Id, new DateTime(2025, 12, 26, 15, 0, 0, DateTimeKind.Utc)), | |
| 591 | + ("JFK taxi", 65.00m, diana.Id, new DateTime(2025, 12, 27, 8, 0, 0, DateTimeKind.Utc)), | |
| 592 | + }; | |
| 593 | + | |
| 594 | + foreach (var (desc, amount, paidBy, date) in expenses4) | |
| 595 | + { | |
| 596 | + var expense = new Expense | |
| 597 | + { | |
| 598 | + TripId = trip4.Id, PaidByUserId = paidBy, Amount = amount, | |
| 599 | + Description = desc, ExpenseDate = date, CurrencyId = usd.Id, | |
| 600 | + SplitMethod = ESplitMethod.EqualAll, | |
| 601 | + }; | |
| 602 | + context.Expenses.Add(expense); | |
| 603 | + | |
| 604 | + var count = trip4Participants.Length; | |
| 605 | + var baseAmt = Math.Floor(amount / count * 100) / 100; | |
| 606 | + var remainder = amount - baseAmt * count; | |
| 607 | + for (var i = 0; i < count; i++) | |
| 608 | + { | |
| 609 | + context.ExpenseSplits.Add(new ExpenseSplit | |
| 610 | + { | |
| 611 | + ExpenseId = expense.Id, | |
| 612 | + UserId = trip4Participants[i], | |
| 613 | + Amount = baseAmt + (i < (int)(remainder * 100) ? 0.01m : 0), | |
| 614 | + }); | |
| 615 | + } | |
| 616 | + } | |
| 617 | + | |
| 618 | + // Closed poll for trip 4 | |
| 619 | + var poll4 = new TripPoll | |
| 620 | + { | |
| 621 | + TripId = trip4.Id, CreatedByUserId = bob.Id, | |
| 622 | + Question = "Best day of the trip?", | |
| 623 | + AllowMultipleVotes = false, IsAnonymous = false, | |
| 624 | + ClosedAt = new DateTime(2025, 12, 27, 12, 0, 0, DateTimeKind.Utc), | |
| 625 | + }; | |
| 626 | + context.TripPolls.Add(poll4); | |
| 627 | + | |
| 628 | + var pollOpt4A = new TripPollOption { PollId = poll4.Id, Text = "Broadway night", DisplayOrder = 0 }; | |
| 629 | + var pollOpt4B = new TripPollOption { PollId = poll4.Id, Text = "Central Park day", DisplayOrder = 1 }; | |
| 630 | + var pollOpt4C = new TripPollOption { PollId = poll4.Id, Text = "Brooklyn Bridge walk", DisplayOrder = 2 }; | |
| 631 | + context.TripPollOptions.AddRange(pollOpt4A, pollOpt4B, pollOpt4C); | |
| 632 | + | |
| 633 | + context.TripPollVotes.AddRange( | |
| 634 | + new TripPollVote { PollOptionId = pollOpt4A.Id, UserId = alice.Id }, | |
| 635 | + new TripPollVote { PollOptionId = pollOpt4A.Id, UserId = diana.Id }, | |
| 636 | + new TripPollVote { PollOptionId = pollOpt4B.Id, UserId = bob.Id } | |
| 637 | + ); | |
| 638 | + | |
| 639 | + context.SaveChanges(); | |
| 640 | + } | |
| 641 | + | |
| 642 | +} |
added SplitApp/App.DAL.EF/Seeding/InitialData.cs +65 −0
| @@ -0,0 +1,65 @@ | ||
| 1 | +namespace App.DAL.EF.Seeding; | |
| 2 | + | |
| 3 | +public static class InitialData | |
| 4 | +{ | |
| 5 | + public static readonly string[] Roles = ["user", "admin"]; | |
| 6 | + | |
| 7 | + // The demo password for the ordinary accounts is in the source on purpose: | |
| 8 | + // the running instance is a demo and anyone reading the code is meant to be | |
| 9 | + // able to sign in and look around. | |
| 10 | + // | |
| 11 | + // The administrator is not. That account can change other people's data, and | |
| 12 | + // this source is shared read-only with people outside the project, so its | |
| 13 | + // password comes from SEED_ADMIN_PASSWORD and there is no default. No | |
| 14 | + // variable, no administrator: seeding skips the account rather than falling | |
| 15 | + // back to something guessable. | |
| 16 | + public const string DemoPassword = "Kala.12345"; | |
| 17 | + | |
| 18 | + public static string? AdminPassword => | |
| 19 | + Environment.GetEnvironmentVariable("SEED_ADMIN_PASSWORD"); | |
| 20 | + | |
| 21 | + public static readonly (string email, string password, string firstName, string lastName, string[] roles)[] Users = | |
| 22 | + [ | |
| 23 | + ("user@taltech.ee", DemoPassword, "Test", "User", ["user"]), | |
| 24 | + ("alice@taltech.ee", DemoPassword, "Alice", "Johnson", ["user"]), | |
| 25 | + ("bob@taltech.ee", DemoPassword, "Bob", "Smith", ["user"]), | |
| 26 | + ("charlie@taltech.ee", DemoPassword, "Charlie", "Brown", ["user"]), | |
| 27 | + ("diana@taltech.ee", DemoPassword, "Diana", "Miller", ["user"]), | |
| 28 | + ]; | |
| 29 | + | |
| 30 | + /// <summary> | |
| 31 | + /// The seed users, with the administrator included only when | |
| 32 | + /// SEED_ADMIN_PASSWORD is set. | |
| 33 | + /// </summary> | |
| 34 | + public static IEnumerable<(string email, string password, string firstName, string lastName, string[] roles)> SeedUsers() | |
| 35 | + { | |
| 36 | + var adminPassword = AdminPassword; | |
| 37 | + if (!string.IsNullOrWhiteSpace(adminPassword)) | |
| 38 | + { | |
| 39 | + yield return ("admin@taltech.ee", adminPassword, "Admin", "User", ["admin"]); | |
| 40 | + } | |
| 41 | + | |
| 42 | + foreach (var user in Users) | |
| 43 | + { | |
| 44 | + yield return user; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static readonly (string Code, string NameEn, string NameEt, string Symbol)[] Currencies = | |
| 49 | + [ | |
| 50 | + ("EUR", "Euro", "Euro", "\u20ac"), | |
| 51 | + ("USD", "US Dollar", "USA dollar", "$"), | |
| 52 | + ("GBP", "British Pound", "Briti nael", "\u00a3"), | |
| 53 | + ("SEK", "Swedish Krona", "Rootsi kroon", "kr"), | |
| 54 | + ("NOK", "Norwegian Krone", "Norra kroon", "kr"), | |
| 55 | + ]; | |
| 56 | + | |
| 57 | + public static readonly (string nameEn, string nameEt, string? icon)[] BudgetCategories = | |
| 58 | + [ | |
| 59 | + ("Food", "Toit", "utensils"), | |
| 60 | + ("Accommodation", "Majutus", "bed"), | |
| 61 | + ("Transport", "Transport", "car"), | |
| 62 | + ("Activities", "Tegevused", "hiking"), | |
| 63 | + ("Shopping", "Ostlemine", "shopping-bag"), | |
| 64 | + ]; | |
| 65 | +} |
added SplitApp/App.DAL.EF/ServiceCollectionExtensions.cs +33 −0
| @@ -0,0 +1,33 @@ | ||
| 1 | +using App.Domain.Contracts; | |
| 2 | +using Microsoft.EntityFrameworkCore; | |
| 3 | +using Microsoft.EntityFrameworkCore.Diagnostics; | |
| 4 | +using Microsoft.Extensions.DependencyInjection; | |
| 5 | + | |
| 6 | +namespace App.DAL.EF; | |
| 7 | + | |
| 8 | +/// <summary> | |
| 9 | +/// DAL composition — Program.cs calls AddDalServices(connectionString) without referencing | |
| 10 | +/// individual DAL types (AppDbContext, AppUnitOfWork). Keeps WebApp decoupled from DAL internals. | |
| 11 | +/// </summary> | |
| 12 | +public static class ServiceCollectionExtensions | |
| 13 | +{ | |
| 14 | + public static IServiceCollection AddDalServices(this IServiceCollection services, string connectionString) | |
| 15 | + { | |
| 16 | + services.AddDbContext<AppDbContext>(options => options | |
| 17 | + .UseNpgsql( | |
| 18 | + connectionString, | |
| 19 | + o => { o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); } | |
| 20 | + ) | |
| 21 | + .ConfigureWarnings(w => | |
| 22 | + w.Throw(RelationalEventId.MultipleCollectionIncludeWarning) | |
| 23 | + ) | |
| 24 | + .EnableDetailedErrors() | |
| 25 | + .EnableSensitiveDataLogging() | |
| 26 | + .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTrackingWithIdentityResolution) | |
| 27 | + ); | |
| 28 | + | |
| 29 | + services.AddScoped<IAppUnitOfWork, AppUnitOfWork>(); | |
| 30 | + | |
| 31 | + return services; | |
| 32 | + } | |
| 33 | +} |
added SplitApp/App.DAL.EF/UtcDateTimeConverter.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; | |
| 2 | + | |
| 3 | +namespace App.DAL.EF; | |
| 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/App.DTO/App.DTO.csproj +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <ItemGroup> | |
| 4 | + <ProjectReference Include="..\App.Domain\App.Domain.csproj" /> | |
| 5 | + <ProjectReference Include="..\App.BLL\App.BLL.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/App.DTO/Mappers/BudgetCategoryMapper.cs +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class BudgetCategoryMapper | |
| 7 | +{ | |
| 8 | + public static BudgetCategoryDto MapToDto(BudgetCategoryBllDto category) | |
| 9 | + { | |
| 10 | + return new BudgetCategoryDto | |
| 11 | + { | |
| 12 | + Id = category.Id, | |
| 13 | + TripId = category.TripId, | |
| 14 | + Name = category.Name, | |
| 15 | + IconName = category.IconName, | |
| 16 | + PlannedAmount = category.PlannedAmount, | |
| 17 | + SpentAmount = category.SpentAmount, | |
| 18 | + DisplayOrder = category.DisplayOrder | |
| 19 | + }; | |
| 20 | + } | |
| 21 | +} |
added SplitApp/App.DTO/Mappers/CurrencyMapper.cs +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class CurrencyMapper | |
| 7 | +{ | |
| 8 | + public static CurrencyDto MapToDto(CurrencyBllDto currency) | |
| 9 | + { | |
| 10 | + return new CurrencyDto | |
| 11 | + { | |
| 12 | + Id = currency.Id, | |
| 13 | + Code = currency.Code, | |
| 14 | + Name = currency.Name, | |
| 15 | + Symbol = currency.Symbol | |
| 16 | + }; | |
| 17 | + } | |
| 18 | +} |
added SplitApp/App.DTO/Mappers/ExpenseMapper.cs +35 −0
| @@ -0,0 +1,35 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class ExpenseMapper | |
| 7 | +{ | |
| 8 | + public static ExpenseDto MapToDto(ExpenseBllDto expense) | |
| 9 | + { | |
| 10 | + return new ExpenseDto | |
| 11 | + { | |
| 12 | + Id = expense.Id, | |
| 13 | + TripId = expense.TripId, | |
| 14 | + PaidByUserId = expense.PaidByUserId, | |
| 15 | + PaidByUserName = expense.PaidByUserFullName, | |
| 16 | + BudgetCategoryId = expense.BudgetCategoryId, | |
| 17 | + BudgetCategoryName = expense.BudgetCategory?.Name, | |
| 18 | + CurrencyId = expense.CurrencyId, | |
| 19 | + CurrencyCode = expense.Currency?.Code, | |
| 20 | + CurrencySymbol = expense.Currency?.Symbol, | |
| 21 | + Amount = expense.Amount, | |
| 22 | + Description = expense.Description, | |
| 23 | + ExpenseDate = expense.ExpenseDate, | |
| 24 | + SplitMethod = expense.SplitMethod.ToString(), | |
| 25 | + Splits = expense.Splits?.Select(s => new ExpenseSplitDto | |
| 26 | + { | |
| 27 | + Id = s.Id, | |
| 28 | + UserId = s.UserId, | |
| 29 | + UserName = s.UserFullName, | |
| 30 | + Amount = s.Amount, | |
| 31 | + Percentage = s.Percentage | |
| 32 | + }).ToList() | |
| 33 | + }; | |
| 34 | + } | |
| 35 | +} |
added SplitApp/App.DTO/Mappers/InvitationMapper.cs +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class InvitationMapper | |
| 7 | +{ | |
| 8 | + public static InvitationDto MapToDto(TripInvitationBllDto invitation) | |
| 9 | + { | |
| 10 | + return new InvitationDto | |
| 11 | + { | |
| 12 | + Id = invitation.Id, | |
| 13 | + TripId = invitation.TripId, | |
| 14 | + TripName = invitation.TripName, | |
| 15 | + Token = invitation.Token, | |
| 16 | + Status = invitation.Status.ToString(), | |
| 17 | + ExpiresAt = invitation.ExpiresAt, | |
| 18 | + InvitedByUserName = invitation.InvitedByUserFullName | |
| 19 | + }; | |
| 20 | + } | |
| 21 | +} |
added SplitApp/App.DTO/Mappers/PollMapper.cs +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class PollMapper | |
| 7 | +{ | |
| 8 | + public static PollDto MapToDto(TripPollBllDto poll, Guid currentUserId) | |
| 9 | + { | |
| 10 | + return new PollDto | |
| 11 | + { | |
| 12 | + Id = poll.Id, | |
| 13 | + TripId = poll.TripId, | |
| 14 | + CreatedByUserId = poll.CreatedByUserId, | |
| 15 | + Question = poll.Question, | |
| 16 | + AllowMultipleVotes = poll.AllowMultipleVotes, | |
| 17 | + IsAnonymous = poll.IsAnonymous, | |
| 18 | + ClosedAt = poll.ClosedAt, | |
| 19 | + Options = poll.Options?.OrderBy(o => o.DisplayOrder).Select(o => new PollOptionDto | |
| 20 | + { | |
| 21 | + Id = o.Id, | |
| 22 | + Text = o.Text, | |
| 23 | + VoteCount = o.VoteCount, | |
| 24 | + VotedByCurrentUser = o.VoterUserIds.Contains(currentUserId), | |
| 25 | + DisplayOrder = o.DisplayOrder | |
| 26 | + }).ToList() | |
| 27 | + }; | |
| 28 | + } | |
| 29 | +} |
added SplitApp/App.DTO/Mappers/SettlementMapper.cs +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class SettlementMapper | |
| 7 | +{ | |
| 8 | + public static SettlementPlanDto MapToDto(SettlementPlanBllDto plan) | |
| 9 | + { | |
| 10 | + return new SettlementPlanDto | |
| 11 | + { | |
| 12 | + Id = plan.Id, | |
| 13 | + TripId = plan.TripId, | |
| 14 | + TotalAmount = plan.TotalAmount, | |
| 15 | + Status = plan.Status.ToString(), | |
| 16 | + CompletedAt = plan.CompletedAt, | |
| 17 | + Payments = plan.Payments?.Select(MapPaymentToDto).ToList() | |
| 18 | + }; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public static SettlementPaymentDto MapPaymentToDto(SettlementPaymentBllDto payment) | |
| 22 | + { | |
| 23 | + return new SettlementPaymentDto | |
| 24 | + { | |
| 25 | + Id = payment.Id, | |
| 26 | + FromUserId = payment.FromUserId, | |
| 27 | + FromUserName = payment.FromUserFullName, | |
| 28 | + ToUserId = payment.ToUserId, | |
| 29 | + ToUserName = payment.ToUserFullName, | |
| 30 | + Amount = payment.Amount, | |
| 31 | + Status = payment.Status.ToString(), | |
| 32 | + MarkedPaidAt = payment.MarkedPaidAt, | |
| 33 | + ConfirmedAt = payment.ConfirmedAt | |
| 34 | + }; | |
| 35 | + } | |
| 36 | +} |
added SplitApp/App.DTO/Mappers/SplitPresetMapper.cs +27 −0
| @@ -0,0 +1,27 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class SplitPresetMapper | |
| 7 | +{ | |
| 8 | + public static SplitPresetDto MapToDto(SplitPresetBllDto preset) | |
| 9 | + { | |
| 10 | + return new SplitPresetDto | |
| 11 | + { | |
| 12 | + Id = preset.Id, | |
| 13 | + TripId = preset.TripId, | |
| 14 | + Name = preset.Name, | |
| 15 | + SplitMethod = preset.SplitMethod.ToString(), | |
| 16 | + CreatedByUserName = preset.CreatedByFullName, | |
| 17 | + Members = preset.Members?.Select(m => new SplitPresetMemberDto | |
| 18 | + { | |
| 19 | + Id = m.Id, | |
| 20 | + UserId = m.UserId, | |
| 21 | + UserName = m.UserFullName, | |
| 22 | + ShareWeight = m.ShareWeight, | |
| 23 | + Percentage = m.Percentage, | |
| 24 | + }).ToList() | |
| 25 | + }; | |
| 26 | + } | |
| 27 | +} |
added SplitApp/App.DTO/Mappers/TripMapper.cs +52 −0
| @@ -0,0 +1,52 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class TripMapper | |
| 7 | +{ | |
| 8 | + public static TripDto MapToDto(TripBllDto trip, bool includeParticipants = false) | |
| 9 | + { | |
| 10 | + var dto = new TripDto | |
| 11 | + { | |
| 12 | + Id = trip.Id, | |
| 13 | + Name = trip.Name, | |
| 14 | + Description = trip.Description, | |
| 15 | + Destination = trip.Destination, | |
| 16 | + StartDate = trip.StartDate, | |
| 17 | + EndDate = trip.EndDate, | |
| 18 | + Status = trip.Status.ToString(), | |
| 19 | + DefaultCurrencyId = trip.DefaultCurrencyId, | |
| 20 | + DefaultCurrencyCode = trip.DefaultCurrency?.Code, | |
| 21 | + DefaultCurrencySymbol = trip.DefaultCurrency?.Symbol, | |
| 22 | + CreatedById = trip.CreatedById, | |
| 23 | + ParticipantCount = trip.Participants?.Count(p => p.IsActive) ?? 0 | |
| 24 | + }; | |
| 25 | + | |
| 26 | + if (includeParticipants && trip.Participants != null) | |
| 27 | + { | |
| 28 | + dto.Participants = trip.Participants | |
| 29 | + .Where(p => p.IsActive) | |
| 30 | + .Select(MapParticipantToDto) | |
| 31 | + .ToList(); | |
| 32 | + } | |
| 33 | + | |
| 34 | + return dto; | |
| 35 | + } | |
| 36 | + | |
| 37 | + public static TripParticipantDto MapParticipantToDto(TripParticipantBllDto tp) | |
| 38 | + { | |
| 39 | + return new TripParticipantDto | |
| 40 | + { | |
| 41 | + Id = tp.Id, | |
| 42 | + TripId = tp.TripId, | |
| 43 | + UserId = tp.UserId, | |
| 44 | + UserName = tp.User != null ? $"{tp.User.FirstName} {tp.User.LastName}".Trim() : null, | |
| 45 | + UserEmail = tp.User?.Email, | |
| 46 | + Role = tp.Role.ToString(), | |
| 47 | + Nickname = tp.Nickname, | |
| 48 | + JoinedAt = tp.JoinedAt, | |
| 49 | + IsActive = tp.IsActive | |
| 50 | + }; | |
| 51 | + } | |
| 52 | +} |
added SplitApp/App.DTO/Mappers/WishlistMapper.cs +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.DTO.v1; | |
| 3 | + | |
| 4 | +namespace App.DTO.Mappers; | |
| 5 | + | |
| 6 | +public static class WishlistMapper | |
| 7 | +{ | |
| 8 | + public static WishlistItemDto MapToDto(TripWishlistItemBllDto item, Guid currentUserId) | |
| 9 | + { | |
| 10 | + return new WishlistItemDto | |
| 11 | + { | |
| 12 | + Id = item.Id, | |
| 13 | + TripId = item.TripId, | |
| 14 | + AddedByUserId = item.AddedByUserId, | |
| 15 | + AddedByUserName = item.AddedByUserFullName, | |
| 16 | + Title = item.Title, | |
| 17 | + Description = item.Description, | |
| 18 | + Category = item.Category.ToString(), | |
| 19 | + Priority = item.Priority.ToString(), | |
| 20 | + EstimatedCost = item.EstimatedCost, | |
| 21 | + Url = item.Url, | |
| 22 | + Location = item.Location, | |
| 23 | + IsCompleted = item.IsCompleted, | |
| 24 | + VoteCount = item.VoteCount, | |
| 25 | + UserHasVoted = item.VoterUserIds.Contains(currentUserId), | |
| 26 | + DisplayOrder = item.DisplayOrder | |
| 27 | + }; | |
| 28 | + } | |
| 29 | +} |
added SplitApp/App.DTO/v1/BalanceDto.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class BalanceDto | |
| 4 | +{ | |
| 5 | + public Guid UserId { get; set; } | |
| 6 | + public string? UserName { get; set; } | |
| 7 | + public decimal Balance { get; set; } | |
| 8 | +} |
added SplitApp/App.DTO/v1/BudgetCategoryCreateDto.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class BudgetCategoryCreateDto | |
| 4 | +{ | |
| 5 | + public Guid TripId { get; set; } | |
| 6 | + public string Name { get; set; } = default!; | |
| 7 | + public string? IconName { get; set; } | |
| 8 | + public decimal? PlannedAmount { get; set; } | |
| 9 | + public int DisplayOrder { get; set; } | |
| 10 | +} |
added SplitApp/App.DTO/v1/BudgetCategoryDto.cs +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +namespace App.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 | +} |
added SplitApp/App.DTO/v1/CurrencyDto.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +namespace App.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/App.DTO/v1/ExpenseCreateDto.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class ExpenseCreateDto | |
| 4 | +{ | |
| 5 | + public Guid TripId { get; set; } | |
| 6 | + public Guid? PaidByUserId { get; set; } | |
| 7 | + public Guid? BudgetCategoryId { get; set; } | |
| 8 | + public Guid? CurrencyId { get; set; } | |
| 9 | + public decimal Amount { get; set; } | |
| 10 | + public string? Description { get; set; } | |
| 11 | + public DateTime ExpenseDate { get; set; } | |
| 12 | + public string SplitMethod { get; set; } = default!; | |
| 13 | + public List<ExpenseSplitCreateDto>? Splits { get; set; } | |
| 14 | +} |
added SplitApp/App.DTO/v1/ExpenseDto.cs +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +namespace App.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 | +} |
added SplitApp/App.DTO/v1/ExpenseSplitCreateDto.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class ExpenseSplitCreateDto | |
| 4 | +{ | |
| 5 | + public Guid UserId { get; set; } | |
| 6 | + public decimal Amount { get; set; } | |
| 7 | + public decimal? Percentage { get; set; } | |
| 8 | +} |
added SplitApp/App.DTO/v1/ExpenseSplitDto.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class ExpenseSplitDto | |
| 4 | +{ | |
| 5 | + public Guid Id { get; set; } | |
| 6 | + public Guid UserId { get; set; } | |
| 7 | + public string? UserName { get; set; } | |
| 8 | + public decimal Amount { get; set; } | |
| 9 | + public decimal? Percentage { get; set; } | |
| 10 | +} |
added SplitApp/App.DTO/v1/Identity/JWTResponse.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +namespace App.DTO.v1.Identity; | |
| 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/App.DTO/v1/Identity/LoginInfo.cs +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +namespace App.DTO.v1.Identity; | |
| 2 | + | |
| 3 | +public class LoginInfo | |
| 4 | +{ | |
| 5 | + public string Email { get; set; } = default!; | |
| 6 | + public string Password { get; set; } = default!; | |
| 7 | +} |
added SplitApp/App.DTO/v1/Identity/LogoutInfo.cs +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +namespace App.DTO.v1.Identity; | |
| 2 | + | |
| 3 | +public class LogoutInfo | |
| 4 | +{ | |
| 5 | + public string RefreshToken { get; set; } = default!; | |
| 6 | +} |
added SplitApp/App.DTO/v1/Identity/RegisterInfo.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +namespace App.DTO.v1.Identity; | |
| 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/App.DTO/v1/Identity/TokenRefreshInfo.cs +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +namespace App.DTO.v1.Identity; | |
| 2 | + | |
| 3 | +public class TokenRefreshInfo | |
| 4 | +{ | |
| 5 | + public string Jwt { get; set; } = default!; | |
| 6 | + public string RefreshToken { get; set; } = default!; | |
| 7 | +} |
added SplitApp/App.DTO/v1/InvitationCreateDto.cs +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class InvitationCreateDto | |
| 4 | +{ | |
| 5 | + public Guid TripId { get; set; } | |
| 6 | +} |
added SplitApp/App.DTO/v1/InvitationDto.cs +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +namespace App.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 | +} |
added SplitApp/App.DTO/v1/PollCreateDto.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class PollCreateDto | |
| 4 | +{ | |
| 5 | + public Guid TripId { get; set; } | |
| 6 | + public string Question { get; set; } = default!; | |
| 7 | + public bool AllowMultipleVotes { get; set; } | |
| 8 | + public bool IsAnonymous { get; set; } | |
| 9 | + public List<string> Options { get; set; } = default!; | |
| 10 | +} |
added SplitApp/App.DTO/v1/PollDto.cs +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +namespace App.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 | +} |
added SplitApp/App.DTO/v1/PollOptionDto.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class PollOptionDto | |
| 4 | +{ | |
| 5 | + public Guid Id { get; set; } | |
| 6 | + public string Text { get; set; } = default!; | |
| 7 | + public int VoteCount { get; set; } | |
| 8 | + public bool VotedByCurrentUser { get; set; } | |
| 9 | + public int DisplayOrder { get; set; } | |
| 10 | +} |
added SplitApp/App.DTO/v1/RestApiErrorResponse.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +using System.Net; | |
| 2 | + | |
| 3 | +namespace App.DTO.v1; | |
| 4 | + | |
| 5 | +public class RestApiErrorResponse | |
| 6 | +{ | |
| 7 | + public HttpStatusCode Status { get; set; } | |
| 8 | + public string Error { get; set; } = default!; | |
| 9 | +} |
added SplitApp/App.DTO/v1/SettlementPaymentDto.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class SettlementPaymentDto | |
| 4 | +{ | |
| 5 | + public Guid Id { get; set; } | |
| 6 | + public Guid FromUserId { get; set; } | |
| 7 | + public string? FromUserName { get; set; } | |
| 8 | + public Guid ToUserId { get; set; } | |
| 9 | + public string? ToUserName { get; set; } | |
| 10 | + public decimal Amount { get; set; } | |
| 11 | + public string Status { get; set; } = default!; | |
| 12 | + public DateTime? MarkedPaidAt { get; set; } | |
| 13 | + public DateTime? ConfirmedAt { get; set; } | |
| 14 | +} |
added SplitApp/App.DTO/v1/SettlementPlanDto.cs +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +namespace App.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 | +} |
added SplitApp/App.DTO/v1/SettlementSummaryDto.cs +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class SettlementSummaryDto | |
| 4 | +{ | |
| 5 | + public List<BalanceDto> Balances { get; set; } = new(); | |
| 6 | + public SettlementPlanDto? LatestPlan { get; set; } | |
| 7 | +} |
added SplitApp/App.DTO/v1/SplitPresetCreateDto.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class SplitPresetCreateDto | |
| 4 | +{ | |
| 5 | + public Guid TripId { get; set; } | |
| 6 | + public string Name { get; set; } = default!; | |
| 7 | + public string SplitMethod { get; set; } = default!; | |
| 8 | + public List<SplitPresetMemberCreateDto>? Members { get; set; } | |
| 9 | +} | |
| 10 | + | |
| 11 | +public class SplitPresetMemberCreateDto | |
| 12 | +{ | |
| 13 | + public Guid UserId { get; set; } | |
| 14 | + public decimal? ShareWeight { get; set; } | |
| 15 | + public decimal? Percentage { get; set; } | |
| 16 | +} |
added SplitApp/App.DTO/v1/SplitPresetDto.cs +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +namespace App.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 | +} |
added SplitApp/App.DTO/v1/SplitPresetMemberDto.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class SplitPresetMemberDto | |
| 4 | +{ | |
| 5 | + public Guid Id { get; set; } | |
| 6 | + public Guid UserId { get; set; } | |
| 7 | + public string? UserName { get; set; } | |
| 8 | + public decimal? ShareWeight { get; set; } | |
| 9 | + public decimal? Percentage { get; set; } | |
| 10 | +} |
added SplitApp/App.DTO/v1/TripCreateDto.cs +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class TripCreateDto | |
| 4 | +{ | |
| 5 | + public string Name { get; set; } = default!; | |
| 6 | + public string? Description { get; set; } | |
| 7 | + public string? Destination { get; set; } | |
| 8 | + public DateTime? StartDate { get; set; } | |
| 9 | + public DateTime? EndDate { get; set; } | |
| 10 | + public Guid DefaultCurrencyId { get; set; } | |
| 11 | +} |
added SplitApp/App.DTO/v1/TripDto.cs +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +namespace App.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 | +} |
added SplitApp/App.DTO/v1/TripParticipantDto.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class TripParticipantDto | |
| 4 | +{ | |
| 5 | + public Guid Id { get; set; } | |
| 6 | + public Guid TripId { get; set; } | |
| 7 | + public Guid UserId { get; set; } | |
| 8 | + public string? UserName { get; set; } | |
| 9 | + public string? UserEmail { get; set; } | |
| 10 | + public string Role { get; set; } = default!; | |
| 11 | + public string? Nickname { get; set; } | |
| 12 | + public DateTime JoinedAt { get; set; } | |
| 13 | + public bool IsActive { get; set; } | |
| 14 | +} |
added SplitApp/App.DTO/v1/TripUpdateDto.cs +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class TripUpdateDto | |
| 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; } | |
| 12 | + public Guid DefaultCurrencyId { get; set; } | |
| 13 | +} |
added SplitApp/App.DTO/v1/WishlistItemCreateDto.cs +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +namespace App.DTO.v1; | |
| 2 | + | |
| 3 | +public class WishlistItemCreateDto | |
| 4 | +{ | |
| 5 | + public Guid TripId { get; set; } | |
| 6 | + public string Title { get; set; } = default!; | |
| 7 | + public string? Description { get; set; } | |
| 8 | + public string Category { get; set; } = default!; | |
| 9 | + public string Priority { get; set; } = default!; | |
| 10 | + public decimal? EstimatedCost { get; set; } | |
| 11 | + public string? Url { get; set; } | |
| 12 | + public string? Location { get; set; } | |
| 13 | +} |
added SplitApp/App.DTO/v1/WishlistItemDto.cs +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +namespace App.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 | +} |
added SplitApp/App.Domain/App.Domain.csproj +19 −0
| @@ -0,0 +1,19 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <ItemGroup> | |
| 4 | + <ProjectReference Include="..\Base.Domain\Base.Domain.csproj" /> | |
| 5 | + <ProjectReference Include="..\Base.Contracts\Base.Contracts.csproj" /> | |
| 6 | + <ProjectReference Include="..\App.Resources\App.Resources.csproj" /> | |
| 7 | + </ItemGroup> | |
| 8 | + | |
| 9 | + <ItemGroup> | |
| 10 | + <PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="10.0.5" /> | |
| 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/App.Domain/BudgetCategory.cs +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.Domain; | |
| 5 | + | |
| 6 | +public class BudgetCategory : BaseEntity | |
| 7 | +{ | |
| 8 | + public Guid TripId { get; set; } | |
| 9 | + public Trip? Trip { get; set; } | |
| 10 | + | |
| 11 | + [Display(Name = nameof(Name), ResourceType = typeof(App.Resources.Domain.BudgetCategory))] | |
| 12 | + public LangStr Name { get; set; } = new(); | |
| 13 | + | |
| 14 | + [MaxLength(100, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 15 | + [Display(Name = nameof(IconName), ResourceType = typeof(App.Resources.Domain.BudgetCategory))] | |
| 16 | + public string? IconName { get; set; } | |
| 17 | + | |
| 18 | + [Display(Name = nameof(PlannedAmount), ResourceType = typeof(App.Resources.Domain.BudgetCategory))] | |
| 19 | + public decimal? PlannedAmount { get; set; } | |
| 20 | + | |
| 21 | + [Display(Name = nameof(DisplayOrder), ResourceType = typeof(App.Resources.Domain.BudgetCategory))] | |
| 22 | + public int DisplayOrder { get; set; } | |
| 23 | + | |
| 24 | + public ICollection<Expense>? Expenses { get; set; } | |
| 25 | +} |
added SplitApp/App.Domain/Contracts/IAppUnitOfWork.cs +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface IAppUnitOfWork : IUnitOfWork | |
| 6 | +{ | |
| 7 | + ITripRepository Trips { get; } | |
| 8 | + IExpenseRepository Expenses { get; } | |
| 9 | + ITripParticipantRepository TripParticipants { get; } | |
| 10 | + ITripInvitationRepository TripInvitations { get; } | |
| 11 | + ISettlementPlanRepository SettlementPlans { get; } | |
| 12 | + ISettlementPaymentRepository SettlementPayments { get; } | |
| 13 | + ITripPollRepository TripPolls { get; } | |
| 14 | + ITripWishlistItemRepository TripWishlistItems { get; } | |
| 15 | + ISplitPresetRepository SplitPresets { get; } | |
| 16 | + IBudgetCategoryRepository BudgetCategories { get; } | |
| 17 | + IRefreshTokenRepository RefreshTokens { get; } | |
| 18 | + IUserRepository Users { get; } | |
| 19 | + IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class, IBaseEntity; | |
| 20 | +} |
added SplitApp/App.Domain/Contracts/IBudgetCategoryRepository.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface IBudgetCategoryRepository : IBaseRepository<BudgetCategory> | |
| 6 | +{ | |
| 7 | + Task<IEnumerable<BudgetCategory>> GetByTripIdAsync(Guid tripId); | |
| 8 | +} |
added SplitApp/App.Domain/Contracts/IExpenseRepository.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface IExpenseRepository : IBaseRepository<Expense> | |
| 6 | +{ | |
| 7 | + Task<IEnumerable<Expense>> GetByTripIdAsync(Guid tripId); | |
| 8 | + Task<Expense?> GetByIdWithDetailsAsync(Guid id); | |
| 9 | +} |
added SplitApp/App.Domain/Contracts/IRefreshTokenRepository.cs +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +using App.Domain.Identity; | |
| 2 | +using Base.Contracts; | |
| 3 | + | |
| 4 | +namespace App.Domain.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/App.Domain/Contracts/ISettlementPaymentRepository.cs +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface ISettlementPaymentRepository : IBaseRepository<SettlementPayment> | |
| 6 | +{ | |
| 7 | +} |
added SplitApp/App.Domain/Contracts/ISettlementPlanRepository.cs +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface ISettlementPlanRepository : IBaseRepository<SettlementPlan> | |
| 6 | +{ | |
| 7 | + Task<SettlementPlan?> GetLatestByTripIdAsync(Guid tripId); | |
| 8 | + | |
| 9 | + /// <summary> | |
| 10 | + /// Hard-deletes a settlement plan and all of its payments, bypassing EF change-tracking. | |
| 11 | + /// Used when "reopening" a trip to clear the auto-generated plan. | |
| 12 | + /// </summary> | |
| 13 | + Task DeletePlanWithPaymentsAsync(Guid planId); | |
| 14 | +} |
added SplitApp/App.Domain/Contracts/ISplitPresetRepository.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface ISplitPresetRepository : IBaseRepository<SplitPreset> | |
| 6 | +{ | |
| 7 | + Task<IEnumerable<SplitPreset>> GetByTripIdAsync(Guid tripId); | |
| 8 | +} |
added SplitApp/App.Domain/Contracts/ITripInvitationRepository.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface ITripInvitationRepository : IBaseRepository<TripInvitation> | |
| 6 | +{ | |
| 7 | + Task<TripInvitation?> GetByTokenAsync(string token); | |
| 8 | + Task<IEnumerable<TripInvitation>> GetPendingByTripIdAsync(Guid tripId); | |
| 9 | +} |
added SplitApp/App.Domain/Contracts/ITripParticipantRepository.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface ITripParticipantRepository : IBaseRepository<TripParticipant> | |
| 6 | +{ | |
| 7 | + Task<bool> IsParticipantAsync(Guid tripId, Guid userId); | |
| 8 | + Task<bool> IsOrganizerAsync(Guid tripId, Guid userId); | |
| 9 | + Task<IEnumerable<TripParticipant>> GetByTripIdAsync(Guid tripId); | |
| 10 | +} |
added SplitApp/App.Domain/Contracts/ITripPollRepository.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface ITripPollRepository : IBaseRepository<TripPoll> | |
| 6 | +{ | |
| 7 | + Task<IEnumerable<TripPoll>> GetByTripIdAsync(Guid tripId); | |
| 8 | + Task<TripPoll?> GetByIdWithDetailsAsync(Guid id); | |
| 9 | +} |
added SplitApp/App.Domain/Contracts/ITripRepository.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface ITripRepository : IBaseRepository<Trip> | |
| 6 | +{ | |
| 7 | + Task<IEnumerable<Trip>> GetUserTripsAsync(Guid userId); | |
| 8 | + Task<Trip?> GetByIdWithDetailsAsync(Guid id); | |
| 9 | +} |
added SplitApp/App.Domain/Contracts/ITripWishlistItemRepository.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace App.Domain.Contracts; | |
| 4 | + | |
| 5 | +public interface ITripWishlistItemRepository : IBaseRepository<TripWishlistItem> | |
| 6 | +{ | |
| 7 | + Task<IEnumerable<TripWishlistItem>> GetByTripIdAsync(Guid tripId); | |
| 8 | +} |
added SplitApp/App.Domain/Contracts/IUserRepository.cs +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +using App.Domain.Identity; | |
| 2 | +using Base.Contracts; | |
| 3 | + | |
| 4 | +namespace App.Domain.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 | +} |
added SplitApp/App.Domain/Currency.cs +19 −0
| @@ -0,0 +1,19 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.Domain; | |
| 5 | + | |
| 6 | +public class Currency : BaseEntity | |
| 7 | +{ | |
| 8 | + [MaxLength(3, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 9 | + [Display(Name = nameof(Code), ResourceType = typeof(App.Resources.Domain.Currency))] | |
| 10 | + public string Code { get; set; } = default!; | |
| 11 | + | |
| 12 | + [MaxLength(100, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 13 | + [Display(Name = nameof(Name), ResourceType = typeof(App.Resources.Domain.Currency))] | |
| 14 | + public LangStr Name { get; set; } = new(); | |
| 15 | + | |
| 16 | + [MaxLength(10, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 17 | + [Display(Name = nameof(Symbol), ResourceType = typeof(App.Resources.Domain.Currency))] | |
| 18 | + public string Symbol { get; set; } = default!; | |
| 19 | +} |
added SplitApp/App.Domain/EInvitationStatus.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +namespace App.Domain; | |
| 2 | + | |
| 3 | +public enum EInvitationStatus | |
| 4 | +{ | |
| 5 | + Pending, | |
| 6 | + Accepted, | |
| 7 | + Declined, | |
| 8 | + Expired, | |
| 9 | + Revoked | |
| 10 | +} |
added SplitApp/App.Domain/EParticipantRole.cs +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +namespace App.Domain; | |
| 2 | + | |
| 3 | +public enum EParticipantRole | |
| 4 | +{ | |
| 5 | + Organizer, | |
| 6 | + Participant | |
| 7 | +} |
added SplitApp/App.Domain/EPaymentStatus.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +namespace App.Domain; | |
| 2 | + | |
| 3 | +public enum EPaymentStatus | |
| 4 | +{ | |
| 5 | + Pending, | |
| 6 | + MarkedPaid, | |
| 7 | + Confirmed | |
| 8 | +} |
added SplitApp/App.Domain/ESettlementStatus.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +namespace App.Domain; | |
| 2 | + | |
| 3 | +public enum ESettlementStatus | |
| 4 | +{ | |
| 5 | + Pending, | |
| 6 | + InProgress, | |
| 7 | + Completed | |
| 8 | +} |
added SplitApp/App.Domain/ESplitMethod.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +namespace App.Domain; | |
| 2 | + | |
| 3 | +public enum ESplitMethod | |
| 4 | +{ | |
| 5 | + EqualAll, | |
| 6 | + EqualSubset, | |
| 7 | + ExactAmounts, | |
| 8 | + Percentages | |
| 9 | +} |
added SplitApp/App.Domain/ETripStatus.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +namespace App.Domain; | |
| 2 | + | |
| 3 | +public enum ETripStatus | |
| 4 | +{ | |
| 5 | + Active, | |
| 6 | + Settled, | |
| 7 | + Archived, | |
| 8 | + Finalizing | |
| 9 | +} |
added SplitApp/App.Domain/EWishlistCategory.cs +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +namespace App.Domain; | |
| 2 | + | |
| 3 | +public enum EWishlistCategory | |
| 4 | +{ | |
| 5 | + Place, | |
| 6 | + Activity, | |
| 7 | + Restaurant, | |
| 8 | + Other | |
| 9 | +} |
added SplitApp/App.Domain/EWishlistPriority.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +namespace App.Domain; | |
| 2 | + | |
| 3 | +public enum EWishlistPriority | |
| 4 | +{ | |
| 5 | + MustDo, | |
| 6 | + NiceToHave, | |
| 7 | + Optional | |
| 8 | +} |
added SplitApp/App.Domain/Expense.cs +35 −0
| @@ -0,0 +1,35 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class Expense : BaseEntity | |
| 8 | +{ | |
| 9 | + public Guid TripId { get; set; } | |
| 10 | + public Trip? Trip { get; set; } | |
| 11 | + | |
| 12 | + public Guid PaidByUserId { get; set; } | |
| 13 | + public AppUser? PaidByUser { get; set; } | |
| 14 | + | |
| 15 | + public Guid? BudgetCategoryId { get; set; } | |
| 16 | + public BudgetCategory? BudgetCategory { get; set; } | |
| 17 | + | |
| 18 | + public Guid? CurrencyId { get; set; } | |
| 19 | + public Currency? Currency { get; set; } | |
| 20 | + | |
| 21 | + [Display(Name = nameof(Amount), ResourceType = typeof(App.Resources.Domain.Expense))] | |
| 22 | + public decimal Amount { get; set; } | |
| 23 | + | |
| 24 | + [MaxLength(500, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 25 | + [Display(Name = nameof(Description), ResourceType = typeof(App.Resources.Domain.Expense))] | |
| 26 | + public string? Description { get; set; } | |
| 27 | + | |
| 28 | + [Display(Name = nameof(ExpenseDate), ResourceType = typeof(App.Resources.Domain.Expense))] | |
| 29 | + public DateTime ExpenseDate { get; set; } | |
| 30 | + | |
| 31 | + [Display(Name = nameof(SplitMethod), ResourceType = typeof(App.Resources.Domain.Expense))] | |
| 32 | + public ESplitMethod SplitMethod { get; set; } | |
| 33 | + | |
| 34 | + public ICollection<ExpenseSplit>? Splits { get; set; } | |
| 35 | +} |
added SplitApp/App.Domain/ExpenseSplit.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +using App.Domain.Identity; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.Domain; | |
| 5 | + | |
| 6 | +public class ExpenseSplit : BaseEntity | |
| 7 | +{ | |
| 8 | + public Guid ExpenseId { get; set; } | |
| 9 | + public Expense? Expense { get; set; } | |
| 10 | + | |
| 11 | + public Guid UserId { get; set; } | |
| 12 | + public AppUser? User { get; set; } | |
| 13 | + | |
| 14 | + public decimal Amount { get; set; } | |
| 15 | + public decimal? Percentage { get; set; } | |
| 16 | +} |
added SplitApp/App.Domain/Identity/AppRefreshToken.cs +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.Domain.Identity; | |
| 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/App.Domain/Identity/AppRole.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | +using Microsoft.AspNetCore.Identity; | |
| 3 | + | |
| 4 | +namespace App.Domain.Identity; | |
| 5 | + | |
| 6 | +public class AppRole : IdentityRole<Guid>, IBaseEntity | |
| 7 | +{ | |
| 8 | +} |
added SplitApp/App.Domain/Identity/AppUser.cs +17 −0
| @@ -0,0 +1,17 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using Base.Contracts; | |
| 3 | +using Microsoft.AspNetCore.Identity; | |
| 4 | + | |
| 5 | +namespace App.Domain.Identity; | |
| 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 | + public ICollection<TripParticipant>? TripParticipants { get; set; } | |
| 17 | +} |
added SplitApp/App.Domain/SettlementPayment.cs +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class SettlementPayment : BaseEntity | |
| 8 | +{ | |
| 9 | + public Guid SettlementPlanId { get; set; } | |
| 10 | + public SettlementPlan? SettlementPlan { get; set; } | |
| 11 | + | |
| 12 | + public Guid FromUserId { get; set; } | |
| 13 | + public AppUser? FromUser { get; set; } | |
| 14 | + | |
| 15 | + public Guid ToUserId { get; set; } | |
| 16 | + public AppUser? ToUser { get; set; } | |
| 17 | + | |
| 18 | + [Display(Name = nameof(Amount), ResourceType = typeof(App.Resources.Domain.SettlementPayment))] | |
| 19 | + public decimal Amount { get; set; } | |
| 20 | + | |
| 21 | + [Display(Name = nameof(Status), ResourceType = typeof(App.Resources.Domain.SettlementPayment))] | |
| 22 | + public EPaymentStatus Status { get; set; } = EPaymentStatus.Pending; | |
| 23 | + | |
| 24 | + [Display(Name = nameof(MarkedPaidAt), ResourceType = typeof(App.Resources.Domain.SettlementPayment))] | |
| 25 | + public DateTime? MarkedPaidAt { get; set; } | |
| 26 | + | |
| 27 | + [Display(Name = nameof(ConfirmedAt), ResourceType = typeof(App.Resources.Domain.SettlementPayment))] | |
| 28 | + public DateTime? ConfirmedAt { get; set; } | |
| 29 | +} |
added SplitApp/App.Domain/SettlementPlan.cs +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class SettlementPlan : BaseEntity | |
| 8 | +{ | |
| 9 | + public Guid TripId { get; set; } | |
| 10 | + public Trip? Trip { get; set; } | |
| 11 | + | |
| 12 | + public Guid CreatedByUserId { get; set; } | |
| 13 | + public AppUser? CreatedByUser { get; set; } | |
| 14 | + | |
| 15 | + [Display(Name = nameof(TotalAmount), ResourceType = typeof(App.Resources.Domain.SettlementPlan))] | |
| 16 | + public decimal TotalAmount { get; set; } | |
| 17 | + | |
| 18 | + [Display(Name = nameof(Status), ResourceType = typeof(App.Resources.Domain.SettlementPlan))] | |
| 19 | + public ESettlementStatus Status { get; set; } = ESettlementStatus.Pending; | |
| 20 | + | |
| 21 | + [Display(Name = nameof(CompletedAt), ResourceType = typeof(App.Resources.Domain.SettlementPlan))] | |
| 22 | + public DateTime? CompletedAt { get; set; } | |
| 23 | + | |
| 24 | + public ICollection<SettlementPayment>? Payments { get; set; } | |
| 25 | +} |
added SplitApp/App.Domain/SplitPreset.cs +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class SplitPreset : BaseEntity | |
| 8 | +{ | |
| 9 | + public Guid TripId { get; set; } | |
| 10 | + public Trip? Trip { get; set; } | |
| 11 | + | |
| 12 | + [MaxLength(200)] | |
| 13 | + public string Name { get; set; } = default!; | |
| 14 | + | |
| 15 | + public ESplitMethod SplitMethod { get; set; } | |
| 16 | + | |
| 17 | + public Guid CreatedById { get; set; } | |
| 18 | + public AppUser? CreatedBy { get; set; } | |
| 19 | + | |
| 20 | + public ICollection<SplitPresetMember>? Members { get; set; } | |
| 21 | +} |
added SplitApp/App.Domain/SplitPresetMember.cs +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +using App.Domain.Identity; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.Domain; | |
| 5 | + | |
| 6 | +public class SplitPresetMember : BaseEntity | |
| 7 | +{ | |
| 8 | + public Guid SplitPresetId { get; set; } | |
| 9 | + public SplitPreset? SplitPreset { get; set; } | |
| 10 | + | |
| 11 | + public Guid UserId { get; set; } | |
| 12 | + public AppUser? User { get; set; } | |
| 13 | + | |
| 14 | + public decimal? ShareWeight { get; set; } | |
| 15 | + public decimal? Percentage { get; set; } | |
| 16 | +} |
added SplitApp/App.Domain/Trip.cs +54 −0
| @@ -0,0 +1,54 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class Trip : BaseEntity, IValidatableObject | |
| 8 | +{ | |
| 9 | + [MaxLength(200, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 10 | + [Display(Name = nameof(Name), ResourceType = typeof(App.Resources.Domain.Trip))] | |
| 11 | + public string Name { get; set; } = default!; | |
| 12 | + | |
| 13 | + [Display(Name = nameof(Description), ResourceType = typeof(App.Resources.Domain.Trip))] | |
| 14 | + public string? Description { get; set; } | |
| 15 | + | |
| 16 | + [MaxLength(200, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 17 | + [Display(Name = nameof(Destination), ResourceType = typeof(App.Resources.Domain.Trip))] | |
| 18 | + public string? Destination { get; set; } | |
| 19 | + | |
| 20 | + [Display(Name = nameof(StartDate), ResourceType = typeof(App.Resources.Domain.Trip))] | |
| 21 | + public DateTime? StartDate { get; set; } | |
| 22 | + | |
| 23 | + [Display(Name = nameof(EndDate), ResourceType = typeof(App.Resources.Domain.Trip))] | |
| 24 | + public DateTime? EndDate { get; set; } | |
| 25 | + | |
| 26 | + [Display(Name = nameof(Status), ResourceType = typeof(App.Resources.Domain.Trip))] | |
| 27 | + public ETripStatus Status { get; set; } = ETripStatus.Active; | |
| 28 | + | |
| 29 | + [Display(Name = "DefaultCurrency", ResourceType = typeof(App.Resources.Domain.Trip))] | |
| 30 | + public Guid DefaultCurrencyId { get; set; } | |
| 31 | + public Currency? DefaultCurrency { get; set; } | |
| 32 | + | |
| 33 | + [Display(Name = "CreatedBy", ResourceType = typeof(App.Resources.Domain.Trip))] | |
| 34 | + public Guid CreatedById { get; set; } | |
| 35 | + public AppUser? CreatedBy { get; set; } | |
| 36 | + | |
| 37 | + public ICollection<TripParticipant>? Participants { get; set; } | |
| 38 | + public ICollection<Expense>? Expenses { get; set; } | |
| 39 | + public ICollection<BudgetCategory>? BudgetCategories { get; set; } | |
| 40 | + public ICollection<TripWishlistItem>? WishlistItems { get; set; } | |
| 41 | + public ICollection<TripPoll>? Polls { get; set; } | |
| 42 | + public ICollection<TripInvitation>? Invitations { get; set; } | |
| 43 | + public ICollection<SettlementPlan>? SettlementPlans { get; set; } | |
| 44 | + | |
| 45 | + public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) | |
| 46 | + { | |
| 47 | + if (StartDate.HasValue && EndDate.HasValue && EndDate.Value < StartDate.Value) | |
| 48 | + { | |
| 49 | + yield return new ValidationResult( | |
| 50 | + "End date must be on or after start date.", | |
| 51 | + new[] { nameof(EndDate) }); | |
| 52 | + } | |
| 53 | + } | |
| 54 | +} |
added SplitApp/App.Domain/TripInvitation.cs +22 −0
| @@ -0,0 +1,22 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class TripInvitation : BaseEntity | |
| 8 | +{ | |
| 9 | + public Guid TripId { get; set; } | |
| 10 | + public Trip? Trip { get; set; } | |
| 11 | + | |
| 12 | + public Guid InvitedByUserId { get; set; } | |
| 13 | + public AppUser? InvitedByUser { get; set; } | |
| 14 | + | |
| 15 | + [MaxLength(256)] | |
| 16 | + public string Token { get; set; } = default!; | |
| 17 | + | |
| 18 | + public EInvitationStatus Status { get; set; } = EInvitationStatus.Pending; | |
| 19 | + | |
| 20 | + public DateTime ExpiresAt { get; set; } | |
| 21 | + public DateTime? RespondedAt { get; set; } | |
| 22 | +} |
added SplitApp/App.Domain/TripParticipant.cs +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class TripParticipant : BaseEntity | |
| 8 | +{ | |
| 9 | + public Guid TripId { get; set; } | |
| 10 | + public Trip? Trip { get; set; } | |
| 11 | + | |
| 12 | + public Guid UserId { get; set; } | |
| 13 | + public AppUser? User { get; set; } | |
| 14 | + | |
| 15 | + [Display(Name = nameof(Role), ResourceType = typeof(App.Resources.Domain.TripParticipant))] | |
| 16 | + public EParticipantRole Role { get; set; } = EParticipantRole.Participant; | |
| 17 | + | |
| 18 | + [MaxLength(100, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 19 | + [Display(Name = nameof(Nickname), ResourceType = typeof(App.Resources.Domain.TripParticipant))] | |
| 20 | + public string? Nickname { get; set; } | |
| 21 | + | |
| 22 | + [Display(Name = nameof(JoinedAt), ResourceType = typeof(App.Resources.Domain.TripParticipant))] | |
| 23 | + public DateTime JoinedAt { get; set; } = DateTime.UtcNow; | |
| 24 | + | |
| 25 | + [Display(Name = nameof(LeftAt), ResourceType = typeof(App.Resources.Domain.TripParticipant))] | |
| 26 | + public DateTime? LeftAt { get; set; } | |
| 27 | + | |
| 28 | + [Display(Name = nameof(IsActive), ResourceType = typeof(App.Resources.Domain.TripParticipant))] | |
| 29 | + public bool IsActive { get; set; } = true; | |
| 30 | +} |
added SplitApp/App.Domain/TripPoll.cs +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class TripPoll : BaseEntity | |
| 8 | +{ | |
| 9 | + public Guid TripId { get; set; } | |
| 10 | + public Trip? Trip { get; set; } | |
| 11 | + | |
| 12 | + public Guid CreatedByUserId { get; set; } | |
| 13 | + public AppUser? CreatedByUser { get; set; } | |
| 14 | + | |
| 15 | + [MaxLength(500, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 16 | + [Display(Name = nameof(Question), ResourceType = typeof(App.Resources.Domain.TripPoll))] | |
| 17 | + public string Question { get; set; } = default!; | |
| 18 | + | |
| 19 | + [Display(Name = nameof(AllowMultipleVotes), ResourceType = typeof(App.Resources.Domain.TripPoll))] | |
| 20 | + public bool AllowMultipleVotes { get; set; } | |
| 21 | + | |
| 22 | + [Display(Name = nameof(IsAnonymous), ResourceType = typeof(App.Resources.Domain.TripPoll))] | |
| 23 | + public bool IsAnonymous { get; set; } | |
| 24 | + | |
| 25 | + [Display(Name = nameof(ClosedAt), ResourceType = typeof(App.Resources.Domain.TripPoll))] | |
| 26 | + public DateTime? ClosedAt { get; set; } | |
| 27 | + | |
| 28 | + public ICollection<TripPollOption>? Options { get; set; } | |
| 29 | +} |
added SplitApp/App.Domain/TripPollOption.cs +19 −0
| @@ -0,0 +1,19 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.Domain; | |
| 5 | + | |
| 6 | +public class TripPollOption : BaseEntity | |
| 7 | +{ | |
| 8 | + public Guid PollId { get; set; } | |
| 9 | + public TripPoll? Poll { get; set; } | |
| 10 | + | |
| 11 | + [MaxLength(300, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 12 | + [Display(Name = nameof(Text), ResourceType = typeof(App.Resources.Domain.TripPollOption))] | |
| 13 | + public string Text { get; set; } = default!; | |
| 14 | + | |
| 15 | + [Display(Name = nameof(DisplayOrder), ResourceType = typeof(App.Resources.Domain.TripPollOption))] | |
| 16 | + public int DisplayOrder { get; set; } | |
| 17 | + | |
| 18 | + public ICollection<TripPollVote>? Votes { get; set; } | |
| 19 | +} |
added SplitApp/App.Domain/TripPollVote.cs +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +using App.Domain.Identity; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.Domain; | |
| 5 | + | |
| 6 | +public class TripPollVote : BaseEntity | |
| 7 | +{ | |
| 8 | + public Guid PollOptionId { get; set; } | |
| 9 | + public TripPollOption? PollOption { get; set; } | |
| 10 | + | |
| 11 | + public Guid UserId { get; set; } | |
| 12 | + public AppUser? User { get; set; } | |
| 13 | +} |
added SplitApp/App.Domain/TripWishlistItem.cs +47 −0
| @@ -0,0 +1,47 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Base.Domain; | |
| 4 | + | |
| 5 | +namespace App.Domain; | |
| 6 | + | |
| 7 | +public class TripWishlistItem : BaseEntity | |
| 8 | +{ | |
| 9 | + public Guid TripId { get; set; } | |
| 10 | + public Trip? Trip { get; set; } | |
| 11 | + | |
| 12 | + public Guid AddedByUserId { get; set; } | |
| 13 | + public AppUser? AddedByUser { get; set; } | |
| 14 | + | |
| 15 | + [MaxLength(200, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 16 | + [Display(Name = nameof(Title), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 17 | + public string Title { get; set; } = default!; | |
| 18 | + | |
| 19 | + [Display(Name = nameof(Description), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 20 | + public string? Description { get; set; } | |
| 21 | + | |
| 22 | + [Display(Name = nameof(Category), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 23 | + public EWishlistCategory Category { get; set; } | |
| 24 | + | |
| 25 | + [Display(Name = nameof(Priority), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 26 | + public EWishlistPriority Priority { get; set; } | |
| 27 | + | |
| 28 | + [Display(Name = nameof(EstimatedCost), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 29 | + public decimal? EstimatedCost { get; set; } | |
| 30 | + | |
| 31 | + [MaxLength(500, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 32 | + [Display(Name = nameof(Url), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 33 | + public string? Url { get; set; } | |
| 34 | + | |
| 35 | + [MaxLength(300, ErrorMessageResourceType = typeof(App.Resources.Common), ErrorMessageResourceName = "ErrorMaxLength")] | |
| 36 | + [Display(Name = nameof(Location), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 37 | + public string? Location { get; set; } | |
| 38 | + | |
| 39 | + [Display(Name = nameof(IsCompleted), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 40 | + public bool IsCompleted { get; set; } | |
| 41 | + public DateTime? CompletedAt { get; set; } | |
| 42 | + | |
| 43 | + [Display(Name = nameof(DisplayOrder), ResourceType = typeof(App.Resources.Domain.TripWishlistItem))] | |
| 44 | + public int DisplayOrder { get; set; } | |
| 45 | + | |
| 46 | + public ICollection<TripWishlistVote>? Votes { get; set; } | |
| 47 | +} |
added SplitApp/App.Domain/TripWishlistVote.cs +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +using App.Domain.Identity; | |
| 2 | +using Base.Domain; | |
| 3 | + | |
| 4 | +namespace App.Domain; | |
| 5 | + | |
| 6 | +public class TripWishlistVote : BaseEntity | |
| 7 | +{ | |
| 8 | + public Guid WishlistItemId { get; set; } | |
| 9 | + public TripWishlistItem? WishlistItem { get; set; } | |
| 10 | + | |
| 11 | + public Guid UserId { get; set; } | |
| 12 | + public AppUser? User { get; set; } | |
| 13 | + | |
| 14 | + public bool IsInterested { get; set; } = true; | |
| 15 | +} |
added SplitApp/App.Resources/App.Resources.csproj +132 −0
| @@ -0,0 +1,132 @@ | ||
| 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 | + <EmbeddedResource Update="Views\Shared.resx"> | |
| 11 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 12 | + <LastGenOutput>Shared.Designer.cs</LastGenOutput> | |
| 13 | + </EmbeddedResource> | |
| 14 | + <EmbeddedResource Update="Common.resx"> | |
| 15 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 16 | + <LastGenOutput>Common.Designer.cs</LastGenOutput> | |
| 17 | + </EmbeddedResource> | |
| 18 | + <EmbeddedResource Update="Domain\Trip.resx"> | |
| 19 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 20 | + <LastGenOutput>Trip.Designer.cs</LastGenOutput> | |
| 21 | + </EmbeddedResource> | |
| 22 | + <EmbeddedResource Update="Domain\Expense.resx"> | |
| 23 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 24 | + <LastGenOutput>Expense.Designer.cs</LastGenOutput> | |
| 25 | + </EmbeddedResource> | |
| 26 | + <EmbeddedResource Update="Domain\BudgetCategory.resx"> | |
| 27 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 28 | + <LastGenOutput>BudgetCategory.Designer.cs</LastGenOutput> | |
| 29 | + </EmbeddedResource> | |
| 30 | + <EmbeddedResource Update="Domain\Currency.resx"> | |
| 31 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 32 | + <LastGenOutput>Currency.Designer.cs</LastGenOutput> | |
| 33 | + </EmbeddedResource> | |
| 34 | + <EmbeddedResource Update="Domain\TripWishlistItem.resx"> | |
| 35 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 36 | + <LastGenOutput>TripWishlistItem.Designer.cs</LastGenOutput> | |
| 37 | + </EmbeddedResource> | |
| 38 | + <EmbeddedResource Update="Domain\TripPoll.resx"> | |
| 39 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 40 | + <LastGenOutput>TripPoll.Designer.cs</LastGenOutput> | |
| 41 | + </EmbeddedResource> | |
| 42 | + <EmbeddedResource Update="Domain\TripPollOption.resx"> | |
| 43 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 44 | + <LastGenOutput>TripPollOption.Designer.cs</LastGenOutput> | |
| 45 | + </EmbeddedResource> | |
| 46 | + <EmbeddedResource Update="Domain\SettlementPlan.resx"> | |
| 47 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 48 | + <LastGenOutput>SettlementPlan.Designer.cs</LastGenOutput> | |
| 49 | + </EmbeddedResource> | |
| 50 | + <EmbeddedResource Update="Domain\SettlementPayment.resx"> | |
| 51 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 52 | + <LastGenOutput>SettlementPayment.Designer.cs</LastGenOutput> | |
| 53 | + </EmbeddedResource> | |
| 54 | + <EmbeddedResource Update="Domain\TripParticipant.resx"> | |
| 55 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 56 | + <LastGenOutput>TripParticipant.Designer.cs</LastGenOutput> | |
| 57 | + </EmbeddedResource> | |
| 58 | + <EmbeddedResource Update="Domain\Enums.resx"> | |
| 59 | + <Generator>PublicResXFileCodeGenerator</Generator> | |
| 60 | + <LastGenOutput>Enums.Designer.cs</LastGenOutput> | |
| 61 | + </EmbeddedResource> | |
| 62 | + </ItemGroup> | |
| 63 | + | |
| 64 | + <ItemGroup> | |
| 65 | + <Compile Update="Views\Shared.Designer.cs"> | |
| 66 | + <DesignTime>True</DesignTime> | |
| 67 | + <AutoGen>True</AutoGen> | |
| 68 | + <DependentUpon>Shared.resx</DependentUpon> | |
| 69 | + </Compile> | |
| 70 | + <Compile Update="Common.Designer.cs"> | |
| 71 | + <DesignTime>True</DesignTime> | |
| 72 | + <AutoGen>True</AutoGen> | |
| 73 | + <DependentUpon>Common.resx</DependentUpon> | |
| 74 | + </Compile> | |
| 75 | + <Compile Update="Domain\Trip.Designer.cs"> | |
| 76 | + <DesignTime>True</DesignTime> | |
| 77 | + <AutoGen>True</AutoGen> | |
| 78 | + <DependentUpon>Trip.resx</DependentUpon> | |
| 79 | + </Compile> | |
| 80 | + <Compile Update="Domain\Expense.Designer.cs"> | |
| 81 | + <DesignTime>True</DesignTime> | |
| 82 | + <AutoGen>True</AutoGen> | |
| 83 | + <DependentUpon>Expense.resx</DependentUpon> | |
| 84 | + </Compile> | |
| 85 | + <Compile Update="Domain\BudgetCategory.Designer.cs"> | |
| 86 | + <DesignTime>True</DesignTime> | |
| 87 | + <AutoGen>True</AutoGen> | |
| 88 | + <DependentUpon>BudgetCategory.resx</DependentUpon> | |
| 89 | + </Compile> | |
| 90 | + <Compile Update="Domain\Currency.Designer.cs"> | |
| 91 | + <DesignTime>True</DesignTime> | |
| 92 | + <AutoGen>True</AutoGen> | |
| 93 | + <DependentUpon>Currency.resx</DependentUpon> | |
| 94 | + </Compile> | |
| 95 | + <Compile Update="Domain\TripWishlistItem.Designer.cs"> | |
| 96 | + <DesignTime>True</DesignTime> | |
| 97 | + <AutoGen>True</AutoGen> | |
| 98 | + <DependentUpon>TripWishlistItem.resx</DependentUpon> | |
| 99 | + </Compile> | |
| 100 | + <Compile Update="Domain\TripPoll.Designer.cs"> | |
| 101 | + <DesignTime>True</DesignTime> | |
| 102 | + <AutoGen>True</AutoGen> | |
| 103 | + <DependentUpon>TripPoll.resx</DependentUpon> | |
| 104 | + </Compile> | |
| 105 | + <Compile Update="Domain\TripPollOption.Designer.cs"> | |
| 106 | + <DesignTime>True</DesignTime> | |
| 107 | + <AutoGen>True</AutoGen> | |
| 108 | + <DependentUpon>TripPollOption.resx</DependentUpon> | |
| 109 | + </Compile> | |
| 110 | + <Compile Update="Domain\SettlementPlan.Designer.cs"> | |
| 111 | + <DesignTime>True</DesignTime> | |
| 112 | + <AutoGen>True</AutoGen> | |
| 113 | + <DependentUpon>SettlementPlan.resx</DependentUpon> | |
| 114 | + </Compile> | |
| 115 | + <Compile Update="Domain\SettlementPayment.Designer.cs"> | |
| 116 | + <DesignTime>True</DesignTime> | |
| 117 | + <AutoGen>True</AutoGen> | |
| 118 | + <DependentUpon>SettlementPayment.resx</DependentUpon> | |
| 119 | + </Compile> | |
| 120 | + <Compile Update="Domain\TripParticipant.Designer.cs"> | |
| 121 | + <DesignTime>True</DesignTime> | |
| 122 | + <AutoGen>True</AutoGen> | |
| 123 | + <DependentUpon>TripParticipant.resx</DependentUpon> | |
| 124 | + </Compile> | |
| 125 | + <Compile Update="Domain\Enums.Designer.cs"> | |
| 126 | + <DesignTime>True</DesignTime> | |
| 127 | + <AutoGen>True</AutoGen> | |
| 128 | + <DependentUpon>Enums.resx</DependentUpon> | |
| 129 | + </Compile> | |
| 130 | + </ItemGroup> | |
| 131 | + | |
| 132 | +</Project> |
added SplitApp/App.Resources/Common.Designer.cs +72 −0
| @@ -0,0 +1,72 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class Common { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal Common() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Common", typeof(Common).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string ErrorRequired { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("ErrorRequired", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string ErrorMaxLength { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("ErrorMaxLength", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string ErrorStringLengthMinMax { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("ErrorStringLengthMinMax", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string ErrorRange { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("ErrorRange", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} |
added SplitApp/App.Resources/Common.et.resx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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="ErrorRequired" xml:space="preserve"><value>{0} on kohustuslik.</value></data> | |
| 43 | + <data name="ErrorMaxLength" xml:space="preserve"><value>{0} ei tohi ületada {1} tähemärki.</value></data> | |
| 44 | + <data name="ErrorStringLengthMinMax" xml:space="preserve"><value>{0} peab olema {2} kuni {1} tähemärki.</value></data> | |
| 45 | + <data name="ErrorRange" xml:space="preserve"><value>{0} peab olema vahemikus {1} kuni {2}.</value></data> | |
| 46 | +</root> |
added SplitApp/App.Resources/Common.resx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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="ErrorRequired" xml:space="preserve"><value>{0} is required.</value></data> | |
| 43 | + <data name="ErrorMaxLength" xml:space="preserve"><value>{0} cannot exceed {1} characters.</value></data> | |
| 44 | + <data name="ErrorStringLengthMinMax" xml:space="preserve"><value>{0} must be between {2} and {1} characters.</value></data> | |
| 45 | + <data name="ErrorRange" xml:space="preserve"><value>{0} must be between {1} and {2}.</value></data> | |
| 46 | +</root> |
added SplitApp/App.Resources/Domain/BudgetCategory.Designer.cs +72 −0
| @@ -0,0 +1,72 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class BudgetCategory { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal BudgetCategory() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.BudgetCategory", typeof(BudgetCategory).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Name { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Name", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string IconName { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("IconName", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string PlannedAmount { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("PlannedAmount", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string DisplayOrder { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("DisplayOrder", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} |
added SplitApp/App.Resources/Domain/BudgetCategory.et.resx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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="Name" xml:space="preserve"><value>Nimi</value></data> | |
| 43 | + <data name="IconName" xml:space="preserve"><value>Ikooni nimi</value></data> | |
| 44 | + <data name="PlannedAmount" xml:space="preserve"><value>Planeeritud summa</value></data> | |
| 45 | + <data name="DisplayOrder" xml:space="preserve"><value>Kuvamisjärjekord</value></data> | |
| 46 | +</root> |
added SplitApp/App.Resources/Domain/BudgetCategory.resx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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="Name" xml:space="preserve"><value>Name</value></data> | |
| 43 | + <data name="IconName" xml:space="preserve"><value>Icon name</value></data> | |
| 44 | + <data name="PlannedAmount" xml:space="preserve"><value>Planned amount</value></data> | |
| 45 | + <data name="DisplayOrder" xml:space="preserve"><value>Display order</value></data> | |
| 46 | +</root> |
added SplitApp/App.Resources/Domain/Currency.Designer.cs +66 −0
| @@ -0,0 +1,66 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class Currency { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal Currency() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.Currency", typeof(Currency).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Code { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Code", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string Name { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("Name", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string Symbol { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("Symbol", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} |
added SplitApp/App.Resources/Domain/Currency.et.resx +45 −0
| @@ -0,0 +1,45 @@ | ||
| 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="Code" xml:space="preserve"><value>Kood</value></data> | |
| 43 | + <data name="Name" xml:space="preserve"><value>Nimi</value></data> | |
| 44 | + <data name="Symbol" xml:space="preserve"><value>Sümbol</value></data> | |
| 45 | +</root> |
added SplitApp/App.Resources/Domain/Currency.resx +45 −0
| @@ -0,0 +1,45 @@ | ||
| 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="Code" xml:space="preserve"><value>Code</value></data> | |
| 43 | + <data name="Name" xml:space="preserve"><value>Name</value></data> | |
| 44 | + <data name="Symbol" xml:space="preserve"><value>Symbol</value></data> | |
| 45 | +</root> |
added SplitApp/App.Resources/Domain/Enums.Designer.cs +216 −0
| @@ -0,0 +1,216 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class Enums { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal Enums() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.Enums", typeof(Enums).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string ETripStatus_Active { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("ETripStatus_Active", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string ETripStatus_Settled { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("ETripStatus_Settled", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string ETripStatus_Archived { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("ETripStatus_Archived", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string ETripStatus_Finalizing { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("ETripStatus_Finalizing", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + public static string ESplitMethod_EqualAll { | |
| 73 | + get { | |
| 74 | + return ResourceManager.GetString("ESplitMethod_EqualAll", resourceCulture); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + public static string ESplitMethod_EqualSubset { | |
| 79 | + get { | |
| 80 | + return ResourceManager.GetString("ESplitMethod_EqualSubset", resourceCulture); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + public static string ESplitMethod_ExactAmounts { | |
| 85 | + get { | |
| 86 | + return ResourceManager.GetString("ESplitMethod_ExactAmounts", resourceCulture); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + public static string ESplitMethod_Percentages { | |
| 91 | + get { | |
| 92 | + return ResourceManager.GetString("ESplitMethod_Percentages", resourceCulture); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + public static string EParticipantRole_Organizer { | |
| 97 | + get { | |
| 98 | + return ResourceManager.GetString("EParticipantRole_Organizer", resourceCulture); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + public static string EParticipantRole_Participant { | |
| 103 | + get { | |
| 104 | + return ResourceManager.GetString("EParticipantRole_Participant", resourceCulture); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + public static string EInvitationStatus_Pending { | |
| 109 | + get { | |
| 110 | + return ResourceManager.GetString("EInvitationStatus_Pending", resourceCulture); | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + public static string EInvitationStatus_Accepted { | |
| 115 | + get { | |
| 116 | + return ResourceManager.GetString("EInvitationStatus_Accepted", resourceCulture); | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + public static string EInvitationStatus_Declined { | |
| 121 | + get { | |
| 122 | + return ResourceManager.GetString("EInvitationStatus_Declined", resourceCulture); | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + public static string EInvitationStatus_Expired { | |
| 127 | + get { | |
| 128 | + return ResourceManager.GetString("EInvitationStatus_Expired", resourceCulture); | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + public static string EInvitationStatus_Revoked { | |
| 133 | + get { | |
| 134 | + return ResourceManager.GetString("EInvitationStatus_Revoked", resourceCulture); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + public static string EPaymentStatus_Pending { | |
| 139 | + get { | |
| 140 | + return ResourceManager.GetString("EPaymentStatus_Pending", resourceCulture); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + public static string EPaymentStatus_MarkedPaid { | |
| 145 | + get { | |
| 146 | + return ResourceManager.GetString("EPaymentStatus_MarkedPaid", resourceCulture); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + public static string EPaymentStatus_Confirmed { | |
| 151 | + get { | |
| 152 | + return ResourceManager.GetString("EPaymentStatus_Confirmed", resourceCulture); | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + public static string ESettlementStatus_Pending { | |
| 157 | + get { | |
| 158 | + return ResourceManager.GetString("ESettlementStatus_Pending", resourceCulture); | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + public static string ESettlementStatus_InProgress { | |
| 163 | + get { | |
| 164 | + return ResourceManager.GetString("ESettlementStatus_InProgress", resourceCulture); | |
| 165 | + } | |
| 166 | + } | |
| 167 | + | |
| 168 | + public static string ESettlementStatus_Completed { | |
| 169 | + get { | |
| 170 | + return ResourceManager.GetString("ESettlementStatus_Completed", resourceCulture); | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + public static string EWishlistCategory_Place { | |
| 175 | + get { | |
| 176 | + return ResourceManager.GetString("EWishlistCategory_Place", resourceCulture); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + public static string EWishlistCategory_Activity { | |
| 181 | + get { | |
| 182 | + return ResourceManager.GetString("EWishlistCategory_Activity", resourceCulture); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + public static string EWishlistCategory_Restaurant { | |
| 187 | + get { | |
| 188 | + return ResourceManager.GetString("EWishlistCategory_Restaurant", resourceCulture); | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + public static string EWishlistCategory_Other { | |
| 193 | + get { | |
| 194 | + return ResourceManager.GetString("EWishlistCategory_Other", resourceCulture); | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + public static string EWishlistPriority_MustDo { | |
| 199 | + get { | |
| 200 | + return ResourceManager.GetString("EWishlistPriority_MustDo", resourceCulture); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + public static string EWishlistPriority_NiceToHave { | |
| 205 | + get { | |
| 206 | + return ResourceManager.GetString("EWishlistPriority_NiceToHave", resourceCulture); | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + public static string EWishlistPriority_Optional { | |
| 211 | + get { | |
| 212 | + return ResourceManager.GetString("EWishlistPriority_Optional", resourceCulture); | |
| 213 | + } | |
| 214 | + } | |
| 215 | + } | |
| 216 | +} |
added SplitApp/App.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/App.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/App.Resources/Domain/Expense.Designer.cs +90 −0
| @@ -0,0 +1,90 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class Expense { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal Expense() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.Expense", typeof(Expense).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Amount { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Amount", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string Description { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("Description", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string ExpenseDate { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("ExpenseDate", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string SplitMethod { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("SplitMethod", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + public static string PaidBy { | |
| 73 | + get { | |
| 74 | + return ResourceManager.GetString("PaidBy", resourceCulture); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + public static string Category { | |
| 79 | + get { | |
| 80 | + return ResourceManager.GetString("Category", resourceCulture); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + public static string Currency { | |
| 85 | + get { | |
| 86 | + return ResourceManager.GetString("Currency", resourceCulture); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + } | |
| 90 | +} |
added SplitApp/App.Resources/Domain/Expense.et.resx +49 −0
| @@ -0,0 +1,49 @@ | ||
| 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="Amount" xml:space="preserve"><value>Summa</value></data> | |
| 43 | + <data name="Description" xml:space="preserve"><value>Kirjeldus</value></data> | |
| 44 | + <data name="ExpenseDate" xml:space="preserve"><value>Kulu kuupäev</value></data> | |
| 45 | + <data name="SplitMethod" xml:space="preserve"><value>Jagamise meetod</value></data> | |
| 46 | + <data name="PaidBy" xml:space="preserve"><value>Maksis</value></data> | |
| 47 | + <data name="Category" xml:space="preserve"><value>Kategooria</value></data> | |
| 48 | + <data name="Currency" xml:space="preserve"><value>Valuuta</value></data> | |
| 49 | +</root> |
added SplitApp/App.Resources/Domain/Expense.resx +49 −0
| @@ -0,0 +1,49 @@ | ||
| 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="Amount" xml:space="preserve"><value>Amount</value></data> | |
| 43 | + <data name="Description" xml:space="preserve"><value>Description</value></data> | |
| 44 | + <data name="ExpenseDate" xml:space="preserve"><value>Expense date</value></data> | |
| 45 | + <data name="SplitMethod" xml:space="preserve"><value>Split method</value></data> | |
| 46 | + <data name="PaidBy" xml:space="preserve"><value>Paid by</value></data> | |
| 47 | + <data name="Category" xml:space="preserve"><value>Category</value></data> | |
| 48 | + <data name="Currency" xml:space="preserve"><value>Currency</value></data> | |
| 49 | +</root> |
added SplitApp/App.Resources/Domain/SettlementPayment.Designer.cs +72 −0
| @@ -0,0 +1,72 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class SettlementPayment { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal SettlementPayment() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.SettlementPayment", typeof(SettlementPayment).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Amount { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Amount", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string Status { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("Status", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string MarkedPaidAt { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("MarkedPaidAt", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string ConfirmedAt { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("ConfirmedAt", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} |
added SplitApp/App.Resources/Domain/SettlementPayment.et.resx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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="Amount" xml:space="preserve"><value>Summa</value></data> | |
| 43 | + <data name="Status" xml:space="preserve"><value>Olek</value></data> | |
| 44 | + <data name="MarkedPaidAt" xml:space="preserve"><value>Märgitud makstuks</value></data> | |
| 45 | + <data name="ConfirmedAt" xml:space="preserve"><value>Kinnitatud</value></data> | |
| 46 | +</root> |
added SplitApp/App.Resources/Domain/SettlementPayment.resx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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="Amount" xml:space="preserve"><value>Amount</value></data> | |
| 43 | + <data name="Status" xml:space="preserve"><value>Status</value></data> | |
| 44 | + <data name="MarkedPaidAt" xml:space="preserve"><value>Marked paid at</value></data> | |
| 45 | + <data name="ConfirmedAt" xml:space="preserve"><value>Confirmed at</value></data> | |
| 46 | +</root> |
added SplitApp/App.Resources/Domain/SettlementPlan.Designer.cs +66 −0
| @@ -0,0 +1,66 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class SettlementPlan { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal SettlementPlan() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.SettlementPlan", typeof(SettlementPlan).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string TotalAmount { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("TotalAmount", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string Status { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("Status", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string CompletedAt { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("CompletedAt", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} |
added SplitApp/App.Resources/Domain/SettlementPlan.et.resx +45 −0
| @@ -0,0 +1,45 @@ | ||
| 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="TotalAmount" xml:space="preserve"><value>Kogusumma</value></data> | |
| 43 | + <data name="Status" xml:space="preserve"><value>Olek</value></data> | |
| 44 | + <data name="CompletedAt" xml:space="preserve"><value>Lõpetatud</value></data> | |
| 45 | +</root> |
added SplitApp/App.Resources/Domain/SettlementPlan.resx +45 −0
| @@ -0,0 +1,45 @@ | ||
| 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="TotalAmount" xml:space="preserve"><value>Total amount</value></data> | |
| 43 | + <data name="Status" xml:space="preserve"><value>Status</value></data> | |
| 44 | + <data name="CompletedAt" xml:space="preserve"><value>Completed at</value></data> | |
| 45 | +</root> |
added SplitApp/App.Resources/Domain/Trip.Designer.cs +96 −0
| @@ -0,0 +1,96 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class Trip { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal Trip() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.Trip", typeof(Trip).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Name { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Name", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string Description { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("Description", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string Destination { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("Destination", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string StartDate { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("StartDate", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + public static string EndDate { | |
| 73 | + get { | |
| 74 | + return ResourceManager.GetString("EndDate", resourceCulture); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + public static string Status { | |
| 79 | + get { | |
| 80 | + return ResourceManager.GetString("Status", resourceCulture); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + public static string DefaultCurrency { | |
| 85 | + get { | |
| 86 | + return ResourceManager.GetString("DefaultCurrency", resourceCulture); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + public static string CreatedBy { | |
| 91 | + get { | |
| 92 | + return ResourceManager.GetString("CreatedBy", resourceCulture); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} |
added SplitApp/App.Resources/Domain/Trip.et.resx +50 −0
| @@ -0,0 +1,50 @@ | ||
| 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="Name" xml:space="preserve"><value>Nimi</value></data> | |
| 43 | + <data name="Description" xml:space="preserve"><value>Kirjeldus</value></data> | |
| 44 | + <data name="Destination" xml:space="preserve"><value>Sihtkoht</value></data> | |
| 45 | + <data name="StartDate" xml:space="preserve"><value>Alguskuupäev</value></data> | |
| 46 | + <data name="EndDate" xml:space="preserve"><value>Lõppkuupäev</value></data> | |
| 47 | + <data name="Status" xml:space="preserve"><value>Olek</value></data> | |
| 48 | + <data name="DefaultCurrency" xml:space="preserve"><value>Vaikimisi valuuta</value></data> | |
| 49 | + <data name="CreatedBy" xml:space="preserve"><value>Loodud</value></data> | |
| 50 | +</root> |
added SplitApp/App.Resources/Domain/Trip.resx +50 −0
| @@ -0,0 +1,50 @@ | ||
| 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="Name" xml:space="preserve"><value>Name</value></data> | |
| 43 | + <data name="Description" xml:space="preserve"><value>Description</value></data> | |
| 44 | + <data name="Destination" xml:space="preserve"><value>Destination</value></data> | |
| 45 | + <data name="StartDate" xml:space="preserve"><value>Start date</value></data> | |
| 46 | + <data name="EndDate" xml:space="preserve"><value>End date</value></data> | |
| 47 | + <data name="Status" xml:space="preserve"><value>Status</value></data> | |
| 48 | + <data name="DefaultCurrency" xml:space="preserve"><value>Default currency</value></data> | |
| 49 | + <data name="CreatedBy" xml:space="preserve"><value>Created by</value></data> | |
| 50 | +</root> |
added SplitApp/App.Resources/Domain/TripParticipant.Designer.cs +78 −0
| @@ -0,0 +1,78 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class TripParticipant { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal TripParticipant() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.TripParticipant", typeof(TripParticipant).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Role { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Role", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string Nickname { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("Nickname", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string JoinedAt { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("JoinedAt", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string LeftAt { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("LeftAt", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + public static string IsActive { | |
| 73 | + get { | |
| 74 | + return ResourceManager.GetString("IsActive", resourceCulture); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + } | |
| 78 | +} |
added SplitApp/App.Resources/Domain/TripParticipant.et.resx +47 −0
| @@ -0,0 +1,47 @@ | ||
| 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="Role" xml:space="preserve"><value>Roll</value></data> | |
| 43 | + <data name="Nickname" xml:space="preserve"><value>Hüüdnimi</value></data> | |
| 44 | + <data name="JoinedAt" xml:space="preserve"><value>Liitunud</value></data> | |
| 45 | + <data name="LeftAt" xml:space="preserve"><value>Lahkunud</value></data> | |
| 46 | + <data name="IsActive" xml:space="preserve"><value>Aktiivne</value></data> | |
| 47 | +</root> |
added SplitApp/App.Resources/Domain/TripParticipant.resx +47 −0
| @@ -0,0 +1,47 @@ | ||
| 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="Role" xml:space="preserve"><value>Role</value></data> | |
| 43 | + <data name="Nickname" xml:space="preserve"><value>Nickname</value></data> | |
| 44 | + <data name="JoinedAt" xml:space="preserve"><value>Joined at</value></data> | |
| 45 | + <data name="LeftAt" xml:space="preserve"><value>Left at</value></data> | |
| 46 | + <data name="IsActive" xml:space="preserve"><value>Active</value></data> | |
| 47 | +</root> |
added SplitApp/App.Resources/Domain/TripPoll.Designer.cs +72 −0
| @@ -0,0 +1,72 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class TripPoll { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal TripPoll() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.TripPoll", typeof(TripPoll).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Question { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Question", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string AllowMultipleVotes { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("AllowMultipleVotes", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string IsAnonymous { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("IsAnonymous", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string ClosedAt { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("ClosedAt", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + } | |
| 72 | +} |
added SplitApp/App.Resources/Domain/TripPoll.et.resx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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="Question" xml:space="preserve"><value>Küsimus</value></data> | |
| 43 | + <data name="AllowMultipleVotes" xml:space="preserve"><value>Luba mitu häält</value></data> | |
| 44 | + <data name="IsAnonymous" xml:space="preserve"><value>Anonuumne hääletamine</value></data> | |
| 45 | + <data name="ClosedAt" xml:space="preserve"><value>Suletud</value></data> | |
| 46 | +</root> |
added SplitApp/App.Resources/Domain/TripPoll.resx +46 −0
| @@ -0,0 +1,46 @@ | ||
| 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="Question" xml:space="preserve"><value>Question</value></data> | |
| 43 | + <data name="AllowMultipleVotes" xml:space="preserve"><value>Allow multiple votes</value></data> | |
| 44 | + <data name="IsAnonymous" xml:space="preserve"><value>Anonymous voting</value></data> | |
| 45 | + <data name="ClosedAt" xml:space="preserve"><value>Closed at</value></data> | |
| 46 | +</root> |
added SplitApp/App.Resources/Domain/TripPollOption.Designer.cs +60 −0
| @@ -0,0 +1,60 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class TripPollOption { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal TripPollOption() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.TripPollOption", typeof(TripPollOption).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Text { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Text", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string DisplayOrder { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("DisplayOrder", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + } | |
| 60 | +} |
added SplitApp/App.Resources/Domain/TripPollOption.et.resx +44 −0
| @@ -0,0 +1,44 @@ | ||
| 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="Text" xml:space="preserve"><value>Tekst</value></data> | |
| 43 | + <data name="DisplayOrder" xml:space="preserve"><value>Kuvamisjärjekord</value></data> | |
| 44 | +</root> |
added SplitApp/App.Resources/Domain/TripPollOption.resx +44 −0
| @@ -0,0 +1,44 @@ | ||
| 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="Text" xml:space="preserve"><value>Text</value></data> | |
| 43 | + <data name="DisplayOrder" xml:space="preserve"><value>Display order</value></data> | |
| 44 | +</root> |
added SplitApp/App.Resources/Domain/TripWishlistItem.Designer.cs +102 −0
| @@ -0,0 +1,102 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Domain { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class TripWishlistItem { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal TripWishlistItem() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Domain.TripWishlistItem", typeof(TripWishlistItem).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Title { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Title", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string Description { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("Description", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string Category { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("Category", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string Priority { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("Priority", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + public static string EstimatedCost { | |
| 73 | + get { | |
| 74 | + return ResourceManager.GetString("EstimatedCost", resourceCulture); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + public static string Url { | |
| 79 | + get { | |
| 80 | + return ResourceManager.GetString("Url", resourceCulture); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + public static string Location { | |
| 85 | + get { | |
| 86 | + return ResourceManager.GetString("Location", resourceCulture); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + public static string IsCompleted { | |
| 91 | + get { | |
| 92 | + return ResourceManager.GetString("IsCompleted", resourceCulture); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + public static string DisplayOrder { | |
| 97 | + get { | |
| 98 | + return ResourceManager.GetString("DisplayOrder", resourceCulture); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + } | |
| 102 | +} |
added SplitApp/App.Resources/Domain/TripWishlistItem.et.resx +51 −0
| @@ -0,0 +1,51 @@ | ||
| 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="Title" xml:space="preserve"><value>Pealkiri</value></data> | |
| 43 | + <data name="Description" xml:space="preserve"><value>Kirjeldus</value></data> | |
| 44 | + <data name="Category" xml:space="preserve"><value>Kategooria</value></data> | |
| 45 | + <data name="Priority" xml:space="preserve"><value>Prioriteet</value></data> | |
| 46 | + <data name="EstimatedCost" xml:space="preserve"><value>Hinnanguline maksumus</value></data> | |
| 47 | + <data name="Url" xml:space="preserve"><value>URL</value></data> | |
| 48 | + <data name="Location" xml:space="preserve"><value>Asukoht</value></data> | |
| 49 | + <data name="IsCompleted" xml:space="preserve"><value>Tehtud</value></data> | |
| 50 | + <data name="DisplayOrder" xml:space="preserve"><value>Kuvamisjärjekord</value></data> | |
| 51 | +</root> |
added SplitApp/App.Resources/Domain/TripWishlistItem.resx +51 −0
| @@ -0,0 +1,51 @@ | ||
| 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="Title" xml:space="preserve"><value>Title</value></data> | |
| 43 | + <data name="Description" xml:space="preserve"><value>Description</value></data> | |
| 44 | + <data name="Category" xml:space="preserve"><value>Category</value></data> | |
| 45 | + <data name="Priority" xml:space="preserve"><value>Priority</value></data> | |
| 46 | + <data name="EstimatedCost" xml:space="preserve"><value>Estimated cost</value></data> | |
| 47 | + <data name="Url" xml:space="preserve"><value>URL</value></data> | |
| 48 | + <data name="Location" xml:space="preserve"><value>Location</value></data> | |
| 49 | + <data name="IsCompleted" xml:space="preserve"><value>Completed</value></data> | |
| 50 | + <data name="DisplayOrder" xml:space="preserve"><value>Display order</value></data> | |
| 51 | +</root> |
added SplitApp/App.Resources/Views/Shared.Designer.cs +240 −0
| @@ -0,0 +1,240 @@ | ||
| 1 | +//------------------------------------------------------------------------------ | |
| 2 | +// <auto-generated> | |
| 3 | +// This code was generated by a tool. | |
| 4 | +// | |
| 5 | +// Changes to this file may cause incorrect behavior and will be lost if | |
| 6 | +// the code is regenerated. | |
| 7 | +// </auto-generated> | |
| 8 | +//------------------------------------------------------------------------------ | |
| 9 | + | |
| 10 | +namespace App.Resources.Views { | |
| 11 | + using System; | |
| 12 | + | |
| 13 | + | |
| 14 | + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] | |
| 15 | + [System.Diagnostics.DebuggerNonUserCodeAttribute()] | |
| 16 | + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] | |
| 17 | + public class Shared { | |
| 18 | + | |
| 19 | + private static System.Resources.ResourceManager resourceMan; | |
| 20 | + | |
| 21 | + private static System.Globalization.CultureInfo resourceCulture; | |
| 22 | + | |
| 23 | + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] | |
| 24 | + internal Shared() { | |
| 25 | + } | |
| 26 | + | |
| 27 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 28 | + public static System.Resources.ResourceManager ResourceManager { | |
| 29 | + get { | |
| 30 | + if (object.Equals(null, resourceMan)) { | |
| 31 | + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("App.Resources.Views.Shared", typeof(Shared).Assembly); | |
| 32 | + resourceMan = temp; | |
| 33 | + } | |
| 34 | + return resourceMan; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] | |
| 39 | + public static System.Globalization.CultureInfo Culture { | |
| 40 | + get { | |
| 41 | + return resourceCulture; | |
| 42 | + } | |
| 43 | + set { | |
| 44 | + resourceCulture = value; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + public static string Save { | |
| 49 | + get { | |
| 50 | + return ResourceManager.GetString("Save", resourceCulture); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + | |
| 54 | + public static string Cancel { | |
| 55 | + get { | |
| 56 | + return ResourceManager.GetString("Cancel", resourceCulture); | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + public static string Delete { | |
| 61 | + get { | |
| 62 | + return ResourceManager.GetString("Delete", resourceCulture); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + public static string Edit { | |
| 67 | + get { | |
| 68 | + return ResourceManager.GetString("Edit", resourceCulture); | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + public static string Create { | |
| 73 | + get { | |
| 74 | + return ResourceManager.GetString("Create", resourceCulture); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + public static string Back { | |
| 79 | + get { | |
| 80 | + return ResourceManager.GetString("Back", resourceCulture); | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + public static string Confirm { | |
| 85 | + get { | |
| 86 | + return ResourceManager.GetString("Confirm", resourceCulture); | |
| 87 | + } | |
| 88 | + } | |
| 89 | + | |
| 90 | + public static string Search { | |
| 91 | + get { | |
| 92 | + return ResourceManager.GetString("Search", resourceCulture); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + public static string Actions { | |
| 97 | + get { | |
| 98 | + return ResourceManager.GetString("Actions", resourceCulture); | |
| 99 | + } | |
| 100 | + } | |
| 101 | + | |
| 102 | + public static string Loading { | |
| 103 | + get { | |
| 104 | + return ResourceManager.GetString("Loading", resourceCulture); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + | |
| 108 | + public static string NoData { | |
| 109 | + get { | |
| 110 | + return ResourceManager.GetString("NoData", resourceCulture); | |
| 111 | + } | |
| 112 | + } | |
| 113 | + | |
| 114 | + public static string LogIn { | |
| 115 | + get { | |
| 116 | + return ResourceManager.GetString("LogIn", resourceCulture); | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + public static string LogOut { | |
| 121 | + get { | |
| 122 | + return ResourceManager.GetString("LogOut", resourceCulture); | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 126 | + public static string Register { | |
| 127 | + get { | |
| 128 | + return ResourceManager.GetString("Register", resourceCulture); | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + public static string Trips { | |
| 133 | + get { | |
| 134 | + return ResourceManager.GetString("Trips", resourceCulture); | |
| 135 | + } | |
| 136 | + } | |
| 137 | + | |
| 138 | + public static string Expenses { | |
| 139 | + get { | |
| 140 | + return ResourceManager.GetString("Expenses", resourceCulture); | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + public static string Budget { | |
| 145 | + get { | |
| 146 | + return ResourceManager.GetString("Budget", resourceCulture); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + public static string Members { | |
| 151 | + get { | |
| 152 | + return ResourceManager.GetString("Members", resourceCulture); | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + public static string Wishlist { | |
| 157 | + get { | |
| 158 | + return ResourceManager.GetString("Wishlist", resourceCulture); | |
| 159 | + } | |
| 160 | + } | |
| 161 | + | |
| 162 | + public static string Polls { | |
| 163 | + get { | |
| 164 | + return ResourceManager.GetString("Polls", resourceCulture); | |
| 165 | + } | |
| 166 | + } | |
| 167 | + | |
| 168 | + public static string Settlement { | |
| 169 | + get { | |
| 170 | + return ResourceManager.GetString("Settlement", resourceCulture); | |
| 171 | + } | |
| 172 | + } | |
| 173 | + | |
| 174 | + public static string Categories { | |
| 175 | + get { | |
| 176 | + return ResourceManager.GetString("Categories", resourceCulture); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + | |
| 180 | + public static string Dashboard { | |
| 181 | + get { | |
| 182 | + return ResourceManager.GetString("Dashboard", resourceCulture); | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + public static string AreYouSure { | |
| 187 | + get { | |
| 188 | + return ResourceManager.GetString("AreYouSure", resourceCulture); | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + public static string DeleteConfirm { | |
| 193 | + get { | |
| 194 | + return ResourceManager.GetString("DeleteConfirm", resourceCulture); | |
| 195 | + } | |
| 196 | + } | |
| 197 | + | |
| 198 | + public static string Details { | |
| 199 | + get { | |
| 200 | + return ResourceManager.GetString("Details", resourceCulture); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + public static string Name { | |
| 205 | + get { | |
| 206 | + return ResourceManager.GetString("Name", resourceCulture); | |
| 207 | + } | |
| 208 | + } | |
| 209 | + | |
| 210 | + public static string Description { | |
| 211 | + get { | |
| 212 | + return ResourceManager.GetString("Description", resourceCulture); | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 216 | + public static string Amount { | |
| 217 | + get { | |
| 218 | + return ResourceManager.GetString("Amount", resourceCulture); | |
| 219 | + } | |
| 220 | + } | |
| 221 | + | |
| 222 | + public static string Date { | |
| 223 | + get { | |
| 224 | + return ResourceManager.GetString("Date", resourceCulture); | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 228 | + public static string Status { | |
| 229 | + get { | |
| 230 | + return ResourceManager.GetString("Status", resourceCulture); | |
| 231 | + } | |
| 232 | + } | |
| 233 | + | |
| 234 | + public static string Admin { | |
| 235 | + get { | |
| 236 | + return ResourceManager.GetString("Admin", resourceCulture); | |
| 237 | + } | |
| 238 | + } | |
| 239 | + } | |
| 240 | +} |
added SplitApp/App.Resources/Views/Shared.et.resx +516 −0
| @@ -0,0 +1,516 @@ | ||
| 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 | + <data name="No items yet" xml:space="preserve"><value>Üksusi veel pole</value></data> | |
| 504 | + <data name="Invitation details" xml:space="preserve"><value>Kutse andmed</value></data> | |
| 505 | + <data name="Settlement plan details" xml:space="preserve"><value>Arveldusplaani andmed</value></data> | |
| 506 | + <data name="New settlement plan" xml:space="preserve"><value>Uus arveldusplaan</value></data> | |
| 507 | + <data name="Edit settlement plan" xml:space="preserve"><value>Muuda arveldusplaani</value></data> | |
| 508 | + <data name="Delete settlement plan" xml:space="preserve"><value>Kustuta arveldusplaan</value></data> | |
| 509 | + <data name="Settlement payment details" xml:space="preserve"><value>Arveldusmakse andmed</value></data> | |
| 510 | + <data name="Split preset details" xml:space="preserve"><value>Jaotuse eelseadistuse andmed</value></data> | |
| 511 | + <data name="Trip participant details" xml:space="preserve"><value>Reisi osaleja andmed</value></data> | |
| 512 | + <data name="New trip participant" xml:space="preserve"><value>Uus reisi osaleja</value></data> | |
| 513 | + <data name="Edit trip participant" xml:space="preserve"><value>Muuda reisi osalejat</value></data> | |
| 514 | + <data name="Delete trip participant" xml:space="preserve"><value>Kustuta reisi osaleja</value></data> | |
| 515 | + <data name="No items yet" xml:space="preserve"><value>Üksusi veel pole</value></data> | |
| 516 | +</root> |
added SplitApp/App.Resources/Views/Shared.resx +516 −0
| @@ -0,0 +1,516 @@ | ||
| 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 | + <data name="No items yet" xml:space="preserve"><value>No items yet</value></data> | |
| 504 | + <data name="Invitation details" xml:space="preserve"><value>Invitation details</value></data> | |
| 505 | + <data name="Settlement plan details" xml:space="preserve"><value>Settlement plan details</value></data> | |
| 506 | + <data name="New settlement plan" xml:space="preserve"><value>New settlement plan</value></data> | |
| 507 | + <data name="Edit settlement plan" xml:space="preserve"><value>Edit settlement plan</value></data> | |
| 508 | + <data name="Delete settlement plan" xml:space="preserve"><value>Delete settlement plan</value></data> | |
| 509 | + <data name="Settlement payment details" xml:space="preserve"><value>Settlement payment details</value></data> | |
| 510 | + <data name="Split preset details" xml:space="preserve"><value>Split preset details</value></data> | |
| 511 | + <data name="Trip participant details" xml:space="preserve"><value>Trip participant details</value></data> | |
| 512 | + <data name="New trip participant" xml:space="preserve"><value>New trip participant</value></data> | |
| 513 | + <data name="Edit trip participant" xml:space="preserve"><value>Edit trip participant</value></data> | |
| 514 | + <data name="Delete trip participant" xml:space="preserve"><value>Delete trip participant</value></data> | |
| 515 | + <data name="No items yet" xml:space="preserve"><value>No items yet</value></data> | |
| 516 | +</root> |
added SplitApp/App.Tests/App.Tests.csproj +32 −0
| @@ -0,0 +1,32 @@ | ||
| 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="FluentAssertions" Version="6.12.2" /> | |
| 13 | + <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.7" /> | |
| 14 | + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" /> | |
| 15 | + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | |
| 16 | + <PackageReference Include="Moq" Version="4.20.72" /> | |
| 17 | + <PackageReference Include="xunit" Version="2.9.3" /> | |
| 18 | + <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> | |
| 19 | + </ItemGroup> | |
| 20 | + | |
| 21 | + <ItemGroup> | |
| 22 | + <Using Include="Xunit" /> | |
| 23 | + </ItemGroup> | |
| 24 | + | |
| 25 | + <ItemGroup> | |
| 26 | + <ProjectReference Include="..\App.DAL.EF\App.DAL.EF.csproj" /> | |
| 27 | + <ProjectReference Include="..\App.BLL\App.BLL.csproj" /> | |
| 28 | + <ProjectReference Include="..\App.Domain\App.Domain.csproj" /> | |
| 29 | + <ProjectReference Include="..\WebApp\WebApp.csproj" /> | |
| 30 | + </ItemGroup> | |
| 31 | + | |
| 32 | +</Project> | |
| No newline at end of file |
added SplitApp/App.Tests/BLL/BudgetCategoryServiceTests.cs +75 −0
| @@ -0,0 +1,75 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | +using Base.Domain; | |
| 6 | +using FluentAssertions; | |
| 7 | +using Moq; | |
| 8 | + | |
| 9 | +namespace App.Tests.BLL; | |
| 10 | + | |
| 11 | +public class BudgetCategoryServiceTests | |
| 12 | +{ | |
| 13 | + private readonly Mock<IAppUnitOfWork> _uow = new(); | |
| 14 | + private readonly Mock<IBudgetCategoryRepository> _categories = new(); | |
| 15 | + private readonly Mock<ITripParticipantRepository> _participants = new(); | |
| 16 | + private readonly BudgetCategoryService _sut; | |
| 17 | + | |
| 18 | + public BudgetCategoryServiceTests() | |
| 19 | + { | |
| 20 | + _uow.Setup(u => u.BudgetCategories).Returns(_categories.Object); | |
| 21 | + _uow.Setup(u => u.TripParticipants).Returns(_participants.Object); | |
| 22 | + _sut = new BudgetCategoryService(_uow.Object); | |
| 23 | + } | |
| 24 | + | |
| 25 | + [Fact] | |
| 26 | + public async Task CreateAsync_WhenUserIsOrganizer_AddsCategoryAndReturnsIt() | |
| 27 | + { | |
| 28 | + // Arrange | |
| 29 | + var userId = Guid.NewGuid(); | |
| 30 | + var tripId = Guid.NewGuid(); | |
| 31 | + var input = new BudgetCategoryBllDto | |
| 32 | + { | |
| 33 | + TripId = tripId, | |
| 34 | + Name = new LangStr("Food"), | |
| 35 | + PlannedAmount = 500m, | |
| 36 | + DisplayOrder = 1 | |
| 37 | + }; | |
| 38 | + | |
| 39 | + _participants.Setup(r => r.IsOrganizerAsync(tripId, userId)).ReturnsAsync(true); | |
| 40 | + BudgetCategory? added = null; | |
| 41 | + _categories.Setup(r => r.Add(It.IsAny<BudgetCategory>())) | |
| 42 | + .Callback<BudgetCategory>(c => added = c) | |
| 43 | + .Returns((BudgetCategory c) => c); | |
| 44 | + _categories.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())) | |
| 45 | + .ReturnsAsync(() => added); | |
| 46 | + | |
| 47 | + // Act | |
| 48 | + var (result, error) = await _sut.CreateAsync(input, userId); | |
| 49 | + | |
| 50 | + // Assert | |
| 51 | + error.Should().BeNull(); | |
| 52 | + result.Should().NotBeNull(); | |
| 53 | + result!.PlannedAmount.Should().Be(500m); | |
| 54 | + _categories.Verify(r => r.Add(It.IsAny<BudgetCategory>()), Times.Once); | |
| 55 | + _uow.Verify(u => u.SaveChangesAsync(), Times.Once); | |
| 56 | + } | |
| 57 | + | |
| 58 | + [Fact] | |
| 59 | + public async Task CreateAsync_WhenUserIsNotOrganizer_ReturnsForbiddenAndDoesNotAdd() | |
| 60 | + { | |
| 61 | + // Arrange | |
| 62 | + var userId = Guid.NewGuid(); | |
| 63 | + var input = new BudgetCategoryBllDto { TripId = Guid.NewGuid(), Name = new LangStr("Food") }; | |
| 64 | + _participants.Setup(r => r.IsOrganizerAsync(input.TripId, userId)).ReturnsAsync(false); | |
| 65 | + | |
| 66 | + // Act | |
| 67 | + var (result, error) = await _sut.CreateAsync(input, userId); | |
| 68 | + | |
| 69 | + // Assert | |
| 70 | + result.Should().BeNull(); | |
| 71 | + error.Should().Be("forbidden"); | |
| 72 | + _categories.Verify(r => r.Add(It.IsAny<BudgetCategory>()), Times.Never); | |
| 73 | + _uow.Verify(u => u.SaveChangesAsync(), Times.Never); | |
| 74 | + } | |
| 75 | +} |
added SplitApp/App.Tests/BLL/CurrencyConverterTests.cs +31 −0
| @@ -0,0 +1,31 @@ | ||
| 1 | +using App.BLL.Helpers; | |
| 2 | +using FluentAssertions; | |
| 3 | + | |
| 4 | +namespace App.Tests.BLL; | |
| 5 | + | |
| 6 | +public class CurrencyConverterTests | |
| 7 | +{ | |
| 8 | + [Fact] | |
| 9 | + public void Convert_SameCurrency_ReturnsAmountUnchanged() | |
| 10 | + { | |
| 11 | + CurrencyConverter.Convert(100m, "EUR", "EUR").Should().Be(100m); | |
| 12 | + } | |
| 13 | + | |
| 14 | + // Parameterized matrix of conversions — each row is one independent assertion | |
| 15 | + [Theory] | |
| 16 | + [InlineData(100, "USD", "EUR", 92.0)] // 100 USD * 0.92 = 92 EUR | |
| 17 | + [InlineData(100, "EUR", "USD", 108.70)] // 100 EUR / 0.92 = 108.6957 → 108.70 | |
| 18 | + [InlineData(100, "GBP", "EUR", 116.0)] // 100 GBP * 1.16 = 116 EUR | |
| 19 | + public void Convert_KnownCurrencies_ReturnsCorrectExchange(decimal amount, string from, string to, decimal expected) | |
| 20 | + { | |
| 21 | + CurrencyConverter.Convert(amount, from, to).Should().Be(expected); | |
| 22 | + } | |
| 23 | + | |
| 24 | + [Fact] | |
| 25 | + public void Convert_UnknownCurrency_FallsBackToOneToOne() | |
| 26 | + { | |
| 27 | + // Defensive default: "XYZ" not in rate table → return amount as-is rather than throw | |
| 28 | + CurrencyConverter.Convert(100m, "XYZ", "EUR").Should().Be(100m); | |
| 29 | + CurrencyConverter.Convert(100m, "EUR", "XYZ").Should().Be(100m); | |
| 30 | + } | |
| 31 | +} |
added SplitApp/App.Tests/BLL/ExpenseServiceTests.cs +84 −0
| @@ -0,0 +1,84 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | +using Base.Contracts; | |
| 6 | +using FluentAssertions; | |
| 7 | +using Moq; | |
| 8 | + | |
| 9 | +namespace App.Tests.BLL; | |
| 10 | + | |
| 11 | +public class ExpenseServiceTests | |
| 12 | +{ | |
| 13 | + private readonly Mock<IAppUnitOfWork> _uow = new(); | |
| 14 | + private readonly Mock<IExpenseRepository> _expenses = new(); | |
| 15 | + private readonly Mock<ITripParticipantRepository> _participants = new(); | |
| 16 | + private readonly Mock<IBaseRepository<ExpenseSplit>> _splitsRepo = new(); | |
| 17 | + private readonly ExpenseService _sut; | |
| 18 | + | |
| 19 | + public ExpenseServiceTests() | |
| 20 | + { | |
| 21 | + _uow.Setup(u => u.Expenses).Returns(_expenses.Object); | |
| 22 | + _uow.Setup(u => u.TripParticipants).Returns(_participants.Object); | |
| 23 | + _uow.Setup(u => u.GetRepository<ExpenseSplit>()).Returns(_splitsRepo.Object); | |
| 24 | + _sut = new ExpenseService(_uow.Object); | |
| 25 | + } | |
| 26 | + | |
| 27 | + [Fact] | |
| 28 | + public async Task CreateExpenseWithSplitsAsync_EqualAll_CreatesSplitForEachParticipant() | |
| 29 | + { | |
| 30 | + // Arrange — 2 participants, expense 100 → each gets 50 | |
| 31 | + var tripId = Guid.NewGuid(); | |
| 32 | + var userA = Guid.NewGuid(); | |
| 33 | + var userB = Guid.NewGuid(); | |
| 34 | + var dto = new ExpenseBllDto | |
| 35 | + { | |
| 36 | + TripId = tripId, | |
| 37 | + PaidByUserId = userA, | |
| 38 | + Amount = 100m, | |
| 39 | + ExpenseDate = DateTime.UtcNow, | |
| 40 | + SplitMethod = ESplitMethod.EqualAll | |
| 41 | + }; | |
| 42 | + | |
| 43 | + _participants.Setup(r => r.GetByTripIdAsync(tripId)).ReturnsAsync(new List<TripParticipant> | |
| 44 | + { | |
| 45 | + new() { TripId = tripId, UserId = userA, IsActive = true }, | |
| 46 | + new() { TripId = tripId, UserId = userB, IsActive = true } | |
| 47 | + }); | |
| 48 | + _expenses.Setup(r => r.Add(It.IsAny<Expense>())).Returns((Expense e) => e); | |
| 49 | + _splitsRepo.Setup(r => r.Add(It.IsAny<ExpenseSplit>())).Returns((ExpenseSplit s) => s); | |
| 50 | + | |
| 51 | + // Act | |
| 52 | + await _sut.CreateExpenseWithSplitsAsync(dto, Array.Empty<Guid>(), Array.Empty<decimal>(), Array.Empty<decimal>()); | |
| 53 | + | |
| 54 | + // Assert | |
| 55 | + _expenses.Verify(r => r.Add(It.IsAny<Expense>()), Times.Once); | |
| 56 | + _splitsRepo.Verify(r => r.Add(It.Is<ExpenseSplit>(s => s.Amount == 50m)), Times.Exactly(2)); | |
| 57 | + } | |
| 58 | + | |
| 59 | + [Fact] | |
| 60 | + public async Task CreateExpenseWithSplitsAsync_EqualAll_WhenNoParticipants_CreatesExpenseButNoSplits() | |
| 61 | + { | |
| 62 | + // Arrange — edge case: trip with zero active participants. Expense should still | |
| 63 | + // be added (organizer might have left), but no splits get generated. | |
| 64 | + var tripId = Guid.NewGuid(); | |
| 65 | + var dto = new ExpenseBllDto | |
| 66 | + { | |
| 67 | + TripId = tripId, | |
| 68 | + PaidByUserId = Guid.NewGuid(), | |
| 69 | + Amount = 100m, | |
| 70 | + ExpenseDate = DateTime.UtcNow, | |
| 71 | + SplitMethod = ESplitMethod.EqualAll | |
| 72 | + }; | |
| 73 | + | |
| 74 | + _participants.Setup(r => r.GetByTripIdAsync(tripId)).ReturnsAsync(new List<TripParticipant>()); | |
| 75 | + _expenses.Setup(r => r.Add(It.IsAny<Expense>())).Returns((Expense e) => e); | |
| 76 | + | |
| 77 | + // Act | |
| 78 | + await _sut.CreateExpenseWithSplitsAsync(dto, Array.Empty<Guid>(), Array.Empty<decimal>(), Array.Empty<decimal>()); | |
| 79 | + | |
| 80 | + // Assert | |
| 81 | + _expenses.Verify(r => r.Add(It.IsAny<Expense>()), Times.Once); | |
| 82 | + _splitsRepo.Verify(r => r.Add(It.IsAny<ExpenseSplit>()), Times.Never); | |
| 83 | + } | |
| 84 | +} |
added SplitApp/App.Tests/BLL/SettlementServiceTests.cs +70 −0
| @@ -0,0 +1,70 @@ | ||
| 1 | +using App.BLL.Services; | |
| 2 | +using App.Domain; | |
| 3 | +using App.Domain.Contracts; | |
| 4 | +using FluentAssertions; | |
| 5 | +using Moq; | |
| 6 | + | |
| 7 | +namespace App.Tests.BLL; | |
| 8 | + | |
| 9 | +public class SettlementServiceTests | |
| 10 | +{ | |
| 11 | + private readonly Mock<IAppUnitOfWork> _uow = new(); | |
| 12 | + private readonly Mock<ITripRepository> _trips = new(); | |
| 13 | + private readonly SettlementService _sut; | |
| 14 | + | |
| 15 | + public SettlementServiceTests() | |
| 16 | + { | |
| 17 | + _uow.Setup(u => u.Trips).Returns(_trips.Object); | |
| 18 | + _sut = new SettlementService(_uow.Object); | |
| 19 | + } | |
| 20 | + | |
| 21 | + [Fact] | |
| 22 | + public async Task CalculateBalancesAsync_WhenTripDoesNotExist_ReturnsEmptyList() | |
| 23 | + { | |
| 24 | + // Arrange | |
| 25 | + var tripId = Guid.NewGuid(); | |
| 26 | + _trips.Setup(r => r.GetByIdWithDetailsAsync(tripId)).ReturnsAsync((Trip?)null); | |
| 27 | + | |
| 28 | + // Act | |
| 29 | + var result = await _sut.CalculateBalancesAsync(tripId); | |
| 30 | + | |
| 31 | + // Assert | |
| 32 | + result.Should().NotBeNull(); | |
| 33 | + result.Should().BeEmpty(); | |
| 34 | + } | |
| 35 | + | |
| 36 | + [Fact] | |
| 37 | + public async Task CalculateBalancesAsync_WhenNoExpenses_ReturnsZeroBalanceForEachParticipant() | |
| 38 | + { | |
| 39 | + // Arrange — edge case: trip exists, has 2 participants, but nobody spent money yet. | |
| 40 | + // Expected: each participant has TotalPaid = 0 and TotalOwed = 0. | |
| 41 | + var tripId = Guid.NewGuid(); | |
| 42 | + var userA = Guid.NewGuid(); | |
| 43 | + var userB = Guid.NewGuid(); | |
| 44 | + var participantsRepo = new Mock<ITripParticipantRepository>(); | |
| 45 | + var expensesRepo = new Mock<IExpenseRepository>(); | |
| 46 | + | |
| 47 | + _uow.Setup(u => u.TripParticipants).Returns(participantsRepo.Object); | |
| 48 | + _uow.Setup(u => u.Expenses).Returns(expensesRepo.Object); | |
| 49 | + | |
| 50 | + _trips.Setup(r => r.GetByIdWithDetailsAsync(tripId)).ReturnsAsync(new Trip | |
| 51 | + { | |
| 52 | + Id = tripId, | |
| 53 | + Name = "Test", | |
| 54 | + DefaultCurrency = new Currency { Code = "EUR" } | |
| 55 | + }); | |
| 56 | + participantsRepo.Setup(r => r.GetByTripIdAsync(tripId)).ReturnsAsync(new List<TripParticipant> | |
| 57 | + { | |
| 58 | + new() { TripId = tripId, UserId = userA, User = new App.Domain.Identity.AppUser { Id = userA, FirstName = "A", LastName = "A" } }, | |
| 59 | + new() { TripId = tripId, UserId = userB, User = new App.Domain.Identity.AppUser { Id = userB, FirstName = "B", LastName = "B" } } | |
| 60 | + }); | |
| 61 | + expensesRepo.Setup(r => r.GetByTripIdAsync(tripId)).ReturnsAsync(new List<Expense>()); | |
| 62 | + | |
| 63 | + // Act | |
| 64 | + var result = await _sut.CalculateBalancesAsync(tripId); | |
| 65 | + | |
| 66 | + // Assert | |
| 67 | + result.Should().HaveCount(2); | |
| 68 | + result.Should().OnlyContain(b => b.TotalPaid == 0m && b.TotalOwed == 0m && b.NetBalance == 0m); | |
| 69 | + } | |
| 70 | +} |
added SplitApp/App.Tests/BLL/TripServiceTests.cs +111 −0
| @@ -0,0 +1,111 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | +using FluentAssertions; | |
| 6 | +using Moq; | |
| 7 | + | |
| 8 | +namespace App.Tests.BLL; | |
| 9 | + | |
| 10 | +public class TripServiceTests | |
| 11 | +{ | |
| 12 | + private readonly Mock<IAppUnitOfWork> _uow = new(); | |
| 13 | + private readonly Mock<ITripRepository> _trips = new(); | |
| 14 | + private readonly Mock<ITripParticipantRepository> _participants = new(); | |
| 15 | + private readonly Mock<ISettlementService> _settlement = new(); | |
| 16 | + private readonly TripService _sut; | |
| 17 | + | |
| 18 | + public TripServiceTests() | |
| 19 | + { | |
| 20 | + _uow.Setup(u => u.Trips).Returns(_trips.Object); | |
| 21 | + _uow.Setup(u => u.TripParticipants).Returns(_participants.Object); | |
| 22 | + _sut = new TripService(_uow.Object, _settlement.Object); | |
| 23 | + } | |
| 24 | + | |
| 25 | + [Fact] | |
| 26 | + public async Task CreateTripAsync_AddsTripAndOrganizerParticipant_ThenSaves() | |
| 27 | + { | |
| 28 | + // Arrange | |
| 29 | + var userId = Guid.NewGuid(); | |
| 30 | + var dto = new TripBllDto { Name = "Paris", DefaultCurrencyId = Guid.NewGuid() }; | |
| 31 | + | |
| 32 | + _trips.Setup(r => r.Add(It.IsAny<Trip>())).Returns((Trip t) => t); | |
| 33 | + _participants.Setup(r => r.Add(It.IsAny<TripParticipant>())).Returns((TripParticipant p) => p); | |
| 34 | + | |
| 35 | + // Act | |
| 36 | + var result = await _sut.CreateTripAsync(dto, userId); | |
| 37 | + | |
| 38 | + // Assert | |
| 39 | + result.Should().NotBeNull(); | |
| 40 | + result.Name.Should().Be("Paris"); | |
| 41 | + result.CreatedById.Should().Be(userId); | |
| 42 | + result.Status.Should().Be(ETripStatus.Active); | |
| 43 | + | |
| 44 | + _trips.Verify(r => r.Add(It.Is<Trip>(t => t.CreatedById == userId)), Times.Once); | |
| 45 | + _participants.Verify(r => r.Add(It.Is<TripParticipant>( | |
| 46 | + p => p.UserId == userId && p.Role == EParticipantRole.Organizer)), Times.Once); | |
| 47 | + _uow.Verify(u => u.SaveChangesAsync(), Times.Once); | |
| 48 | + } | |
| 49 | + | |
| 50 | + [Fact] | |
| 51 | + public async Task IsParticipantAsync_DelegatesToParticipantRepo() | |
| 52 | + { | |
| 53 | + var tripId = Guid.NewGuid(); | |
| 54 | + var userId = Guid.NewGuid(); | |
| 55 | + _participants.Setup(r => r.IsParticipantAsync(tripId, userId)).ReturnsAsync(true); | |
| 56 | + | |
| 57 | + var result = await _sut.IsParticipantAsync(tripId, userId); | |
| 58 | + | |
| 59 | + result.Should().BeTrue(); | |
| 60 | + _participants.Verify(r => r.IsParticipantAsync(tripId, userId), Times.Once); | |
| 61 | + } | |
| 62 | + | |
| 63 | + [Fact] | |
| 64 | + public async Task IsOrganizerAsync_DelegatesToParticipantRepo() | |
| 65 | + { | |
| 66 | + var tripId = Guid.NewGuid(); | |
| 67 | + var userId = Guid.NewGuid(); | |
| 68 | + _participants.Setup(r => r.IsOrganizerAsync(tripId, userId)).ReturnsAsync(true); | |
| 69 | + | |
| 70 | + var result = await _sut.IsOrganizerAsync(tripId, userId); | |
| 71 | + | |
| 72 | + result.Should().BeTrue(); | |
| 73 | + _participants.Verify(r => r.IsOrganizerAsync(tripId, userId), Times.Once); | |
| 74 | + } | |
| 75 | + | |
| 76 | + // ----- Sad-path / IDOR negatives ----- | |
| 77 | + | |
| 78 | + [Fact] | |
| 79 | + public async Task GetByIdAsync_WhenUserIsNotParticipant_ReturnsNull() | |
| 80 | + { | |
| 81 | + // Arrange — IDOR enforcement: non-participants must not see the trip | |
| 82 | + var tripId = Guid.NewGuid(); | |
| 83 | + var userId = Guid.NewGuid(); | |
| 84 | + _participants.Setup(r => r.IsParticipantAsync(tripId, userId)).ReturnsAsync(false); | |
| 85 | + | |
| 86 | + // Act | |
| 87 | + var result = await _sut.GetByIdAsync(tripId, userId); | |
| 88 | + | |
| 89 | + // Assert | |
| 90 | + result.Should().BeNull(); | |
| 91 | + // Crucial — repo must not even be called when user has no access | |
| 92 | + _trips.Verify(r => r.GetByIdAsync(It.IsAny<Guid>()), Times.Never); | |
| 93 | + } | |
| 94 | + | |
| 95 | + [Fact] | |
| 96 | + public async Task DeleteAsync_WhenUserIsNotOrganizer_ReturnsFalseAndDoesNotDelete() | |
| 97 | + { | |
| 98 | + // Arrange | |
| 99 | + var tripId = Guid.NewGuid(); | |
| 100 | + var userId = Guid.NewGuid(); | |
| 101 | + _participants.Setup(r => r.IsOrganizerAsync(tripId, userId)).ReturnsAsync(false); | |
| 102 | + | |
| 103 | + // Act | |
| 104 | + var result = await _sut.DeleteAsync(tripId, userId); | |
| 105 | + | |
| 106 | + // Assert | |
| 107 | + result.Should().BeFalse(); | |
| 108 | + _trips.Verify(r => r.RemoveAsync(It.IsAny<Guid>()), Times.Never); | |
| 109 | + _uow.Verify(u => u.SaveChangesAsync(), Times.Never); | |
| 110 | + } | |
| 111 | +} |
added SplitApp/App.Tests/BLL/WishlistServiceTests.cs +72 −0
| @@ -0,0 +1,72 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Contracts; | |
| 5 | +using FluentAssertions; | |
| 6 | +using Moq; | |
| 7 | + | |
| 8 | +namespace App.Tests.BLL; | |
| 9 | + | |
| 10 | +public class WishlistServiceTests | |
| 11 | +{ | |
| 12 | + private readonly Mock<IAppUnitOfWork> _uow = new(); | |
| 13 | + private readonly Mock<ITripWishlistItemRepository> _items = new(); | |
| 14 | + private readonly Mock<ITripParticipantRepository> _participants = new(); | |
| 15 | + private readonly WishlistService _sut; | |
| 16 | + | |
| 17 | + public WishlistServiceTests() | |
| 18 | + { | |
| 19 | + _uow.Setup(u => u.TripWishlistItems).Returns(_items.Object); | |
| 20 | + _uow.Setup(u => u.TripParticipants).Returns(_participants.Object); | |
| 21 | + _sut = new WishlistService(_uow.Object); | |
| 22 | + } | |
| 23 | + | |
| 24 | + [Fact] | |
| 25 | + public async Task GetByTripIdAsync_WhenUserIsNotParticipant_ReturnsEmptyList() | |
| 26 | + { | |
| 27 | + // Arrange — IDOR check: non-participants get an empty list, not an exception | |
| 28 | + var tripId = Guid.NewGuid(); | |
| 29 | + var userId = Guid.NewGuid(); | |
| 30 | + _participants.Setup(r => r.IsParticipantAsync(tripId, userId)).ReturnsAsync(false); | |
| 31 | + | |
| 32 | + // Act | |
| 33 | + var result = await _sut.GetByTripIdAsync(tripId, userId); | |
| 34 | + | |
| 35 | + // Assert | |
| 36 | + result.Should().BeEmpty(); | |
| 37 | + _items.Verify(r => r.GetByTripIdAsync(It.IsAny<Guid>()), Times.Never); | |
| 38 | + } | |
| 39 | + | |
| 40 | + [Fact] | |
| 41 | + public async Task CreateAsync_WhenUserIsParticipant_AddsItemAndStampsAddedByUser() | |
| 42 | + { | |
| 43 | + // Arrange | |
| 44 | + var tripId = Guid.NewGuid(); | |
| 45 | + var userId = Guid.NewGuid(); | |
| 46 | + var input = new TripWishlistItemBllDto | |
| 47 | + { | |
| 48 | + TripId = tripId, | |
| 49 | + Title = "Eiffel Tower", | |
| 50 | + Category = EWishlistCategory.Activity, | |
| 51 | + Priority = EWishlistPriority.MustDo | |
| 52 | + }; | |
| 53 | + | |
| 54 | + _participants.Setup(r => r.IsParticipantAsync(tripId, userId)).ReturnsAsync(true); | |
| 55 | + TripWishlistItem? added = null; | |
| 56 | + _items.Setup(r => r.Add(It.IsAny<TripWishlistItem>())) | |
| 57 | + .Callback<TripWishlistItem>(i => added = i) | |
| 58 | + .Returns((TripWishlistItem i) => i); | |
| 59 | + _items.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())) | |
| 60 | + .ReturnsAsync(() => added); | |
| 61 | + | |
| 62 | + // Act | |
| 63 | + var (result, error) = await _sut.CreateAsync(input, userId); | |
| 64 | + | |
| 65 | + // Assert | |
| 66 | + error.Should().BeNull(); | |
| 67 | + result.Should().NotBeNull(); | |
| 68 | + added.Should().NotBeNull(); | |
| 69 | + added!.AddedByUserId.Should().Be(userId); | |
| 70 | + _uow.Verify(u => u.SaveChangesAsync(), Times.Once); | |
| 71 | + } | |
| 72 | +} |
added SplitApp/App.Tests/DAL/BudgetCategoryRepositoryTests.cs +31 −0
| @@ -0,0 +1,31 @@ | ||
| 1 | +using App.DAL.EF.Repositories; | |
| 2 | +using App.Domain; | |
| 3 | +using Base.Domain; | |
| 4 | +using FluentAssertions; | |
| 5 | + | |
| 6 | +namespace App.Tests.DAL; | |
| 7 | + | |
| 8 | +public class BudgetCategoryRepositoryTests : RepositoryTestBase | |
| 9 | +{ | |
| 10 | + [Fact] | |
| 11 | + public async Task GetByTripIdAsync_ReturnsCategoriesOrderedByDisplayOrder() | |
| 12 | + { | |
| 13 | + // Arrange | |
| 14 | + var (_, _, trip) = await SeedTripAsync(); | |
| 15 | + Context.BudgetCategories.AddRange( | |
| 16 | + new BudgetCategory { TripId = trip.Id, Name = new LangStr("Food"), DisplayOrder = 2 }, | |
| 17 | + new BudgetCategory { TripId = trip.Id, Name = new LangStr("Transport"), DisplayOrder = 1 } | |
| 18 | + ); | |
| 19 | + await Context.SaveChangesAsync(); | |
| 20 | + | |
| 21 | + var repo = new BudgetCategoryRepository(Context); | |
| 22 | + | |
| 23 | + // Act | |
| 24 | + var result = (await repo.GetByTripIdAsync(trip.Id)).ToList(); | |
| 25 | + | |
| 26 | + // Assert | |
| 27 | + result.Should().HaveCount(2); | |
| 28 | + result[0].DisplayOrder.Should().Be(1); | |
| 29 | + result[1].DisplayOrder.Should().Be(2); | |
| 30 | + } | |
| 31 | +} |
added SplitApp/App.Tests/DAL/ExpenseRepositoryTests.cs +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +using App.DAL.EF.Repositories; | |
| 2 | +using App.Domain; | |
| 3 | +using FluentAssertions; | |
| 4 | + | |
| 5 | +namespace App.Tests.DAL; | |
| 6 | + | |
| 7 | +public class ExpenseRepositoryTests : RepositoryTestBase | |
| 8 | +{ | |
| 9 | + [Fact] | |
| 10 | + public async Task GetByTripIdAsync_ReturnsExpensesForThatTripOnly() | |
| 11 | + { | |
| 12 | + // Arrange | |
| 13 | + var (currency, user, trip) = await SeedTripAsync(); | |
| 14 | + var expense = new Expense | |
| 15 | + { | |
| 16 | + TripId = trip.Id, | |
| 17 | + PaidByUserId = user.Id, | |
| 18 | + CurrencyId = currency.Id, | |
| 19 | + Amount = 42.50m, | |
| 20 | + Description = "Dinner", | |
| 21 | + ExpenseDate = DateTime.UtcNow, | |
| 22 | + SplitMethod = ESplitMethod.EqualAll | |
| 23 | + }; | |
| 24 | + Context.Expenses.Add(expense); | |
| 25 | + await Context.SaveChangesAsync(); | |
| 26 | + | |
| 27 | + var repo = new ExpenseRepository(Context); | |
| 28 | + | |
| 29 | + // Act | |
| 30 | + var result = await repo.GetByTripIdAsync(trip.Id); | |
| 31 | + | |
| 32 | + // Assert | |
| 33 | + result.Should().ContainSingle() | |
| 34 | + .Which.Amount.Should().Be(42.50m); | |
| 35 | + } | |
| 36 | +} |
added SplitApp/App.Tests/DAL/TripInvitationRepositoryTests.cs +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +using App.DAL.EF.Repositories; | |
| 2 | +using App.Domain; | |
| 3 | +using FluentAssertions; | |
| 4 | + | |
| 5 | +namespace App.Tests.DAL; | |
| 6 | + | |
| 7 | +public class TripInvitationRepositoryTests : RepositoryTestBase | |
| 8 | +{ | |
| 9 | + [Fact] | |
| 10 | + public async Task GetByTokenAsync_WhenTokenExists_ReturnsInvitation() | |
| 11 | + { | |
| 12 | + // Arrange | |
| 13 | + var (_, user, trip) = await SeedTripAsync(); | |
| 14 | + var token = "test-invite-token-abc123"; | |
| 15 | + Context.TripInvitations.Add(new TripInvitation | |
| 16 | + { | |
| 17 | + TripId = trip.Id, | |
| 18 | + InvitedByUserId = user.Id, | |
| 19 | + Token = token, | |
| 20 | + Status = EInvitationStatus.Pending, | |
| 21 | + ExpiresAt = DateTime.UtcNow.AddDays(7) | |
| 22 | + }); | |
| 23 | + await Context.SaveChangesAsync(); | |
| 24 | + var repo = new TripInvitationRepository(Context); | |
| 25 | + | |
| 26 | + // Act | |
| 27 | + var result = await repo.GetByTokenAsync(token); | |
| 28 | + | |
| 29 | + // Assert | |
| 30 | + result.Should().NotBeNull(); | |
| 31 | + result!.Token.Should().Be(token); | |
| 32 | + result.Status.Should().Be(EInvitationStatus.Pending); | |
| 33 | + } | |
| 34 | + | |
| 35 | + [Fact] | |
| 36 | + public async Task GetByTokenAsync_WhenTokenDoesNotExist_ReturnsNull() | |
| 37 | + { | |
| 38 | + var repo = new TripInvitationRepository(Context); | |
| 39 | + | |
| 40 | + var result = await repo.GetByTokenAsync("nonexistent-token"); | |
| 41 | + | |
| 42 | + result.Should().BeNull(); | |
| 43 | + } | |
| 44 | +} |
added SplitApp/App.Tests/DAL/TripParticipantRepositoryTests.cs +56 −0
| @@ -0,0 +1,56 @@ | ||
| 1 | +using App.DAL.EF.Repositories; | |
| 2 | +using App.Domain; | |
| 3 | +using FluentAssertions; | |
| 4 | + | |
| 5 | +namespace App.Tests.DAL; | |
| 6 | + | |
| 7 | +public class TripParticipantRepositoryTests : RepositoryTestBase | |
| 8 | +{ | |
| 9 | + [Fact] | |
| 10 | + public async Task IsParticipantAsync_WhenUserIsActiveParticipant_ReturnsTrue() | |
| 11 | + { | |
| 12 | + // Arrange — SeedTripAsync creates an active organizer participant | |
| 13 | + var (_, user, trip) = await SeedTripAsync(); | |
| 14 | + var repo = new TripParticipantRepository(Context); | |
| 15 | + | |
| 16 | + // Act | |
| 17 | + var result = await repo.IsParticipantAsync(trip.Id, user.Id); | |
| 18 | + | |
| 19 | + // Assert | |
| 20 | + result.Should().BeTrue(); | |
| 21 | + } | |
| 22 | + | |
| 23 | + [Fact] | |
| 24 | + public async Task IsParticipantAsync_WhenUserHasLeft_ReturnsFalse() | |
| 25 | + { | |
| 26 | + // Arrange — flip the seeded participant to inactive (i.e. left the trip) | |
| 27 | + var (_, user, trip) = await SeedTripAsync(); | |
| 28 | + var participant = Context.TripParticipants.First(); | |
| 29 | + participant.IsActive = false; | |
| 30 | + await Context.SaveChangesAsync(); | |
| 31 | + var repo = new TripParticipantRepository(Context); | |
| 32 | + | |
| 33 | + // Act | |
| 34 | + var result = await repo.IsParticipantAsync(trip.Id, user.Id); | |
| 35 | + | |
| 36 | + // Assert — IsActive=false should exclude them from "is participant" | |
| 37 | + result.Should().BeFalse(); | |
| 38 | + } | |
| 39 | + | |
| 40 | + [Fact] | |
| 41 | + public async Task IsOrganizerAsync_WhenUserIsRegularParticipant_ReturnsFalse() | |
| 42 | + { | |
| 43 | + // Arrange — demote the seeded organizer to plain participant | |
| 44 | + var (_, user, trip) = await SeedTripAsync(); | |
| 45 | + var participant = Context.TripParticipants.First(); | |
| 46 | + participant.Role = EParticipantRole.Participant; | |
| 47 | + await Context.SaveChangesAsync(); | |
| 48 | + var repo = new TripParticipantRepository(Context); | |
| 49 | + | |
| 50 | + // Act | |
| 51 | + var result = await repo.IsOrganizerAsync(trip.Id, user.Id); | |
| 52 | + | |
| 53 | + // Assert | |
| 54 | + result.Should().BeFalse(); | |
| 55 | + } | |
| 56 | +} |
added SplitApp/App.Tests/DAL/TripRepositoryTests.cs +41 −0
| @@ -0,0 +1,41 @@ | ||
| 1 | +using App.DAL.EF.Repositories; | |
| 2 | +using FluentAssertions; | |
| 3 | + | |
| 4 | +namespace App.Tests.DAL; | |
| 5 | + | |
| 6 | +public class TripRepositoryTests : RepositoryTestBase | |
| 7 | +{ | |
| 8 | + [Fact] | |
| 9 | + public async Task GetByIdAsync_WhenTripExists_ReturnsTripWithCurrencyAndCreator() | |
| 10 | + { | |
| 11 | + // Arrange | |
| 12 | + var (_, user, trip) = await SeedTripAsync(); | |
| 13 | + var repo = new TripRepository(Context); | |
| 14 | + | |
| 15 | + // Act | |
| 16 | + var result = await repo.GetByIdAsync(trip.Id); | |
| 17 | + | |
| 18 | + // Assert | |
| 19 | + result.Should().NotBeNull(); | |
| 20 | + result!.Name.Should().Be("Paris"); | |
| 21 | + result.DefaultCurrency.Should().NotBeNull(); | |
| 22 | + result.DefaultCurrency!.Code.Should().Be("EUR"); | |
| 23 | + result.CreatedBy.Should().NotBeNull(); | |
| 24 | + result.CreatedBy!.Id.Should().Be(user.Id); | |
| 25 | + } | |
| 26 | + | |
| 27 | + [Fact] | |
| 28 | + public async Task GetUserTripsAsync_WhenUserIsActiveParticipant_ReturnsTrip() | |
| 29 | + { | |
| 30 | + // Arrange — SeedTripAsync adds the user as an active organizer participant | |
| 31 | + var (_, user, trip) = await SeedTripAsync(); | |
| 32 | + var repo = new TripRepository(Context); | |
| 33 | + | |
| 34 | + // Act | |
| 35 | + var result = await repo.GetUserTripsAsync(user.Id); | |
| 36 | + | |
| 37 | + // Assert | |
| 38 | + result.Should().ContainSingle() | |
| 39 | + .Which.Id.Should().Be(trip.Id); | |
| 40 | + } | |
| 41 | +} |
added SplitApp/App.Tests/Domain/LangStrTests.cs +58 −0
| @@ -0,0 +1,58 @@ | ||
| 1 | +using Base.Domain; | |
| 2 | +using FluentAssertions; | |
| 3 | + | |
| 4 | +namespace App.Tests.Domain; | |
| 5 | + | |
| 6 | +public class LangStrTests | |
| 7 | +{ | |
| 8 | + [Fact] | |
| 9 | + public void Constructor_WithDefaultCulture_StoresValueUnderDefaultKey() | |
| 10 | + { | |
| 11 | + var s = new LangStr("Hello", "en"); | |
| 12 | + | |
| 13 | + s.Translate("en").Should().Be("Hello"); | |
| 14 | + } | |
| 15 | + | |
| 16 | + [Fact] | |
| 17 | + public void Translate_WithUnknownCulture_FallsBackToDefault() | |
| 18 | + { | |
| 19 | + var s = new LangStr(); | |
| 20 | + s["en"] = "Hello"; | |
| 21 | + | |
| 22 | + s.Translate("fr").Should().Be("Hello"); | |
| 23 | + } | |
| 24 | + | |
| 25 | + [Fact] | |
| 26 | + public void Translate_WithRegionalCulture_FallsBackToNeutralCulture() | |
| 27 | + { | |
| 28 | + // "et-EE" not stored, but "et" is — should match neutral | |
| 29 | + var s = new LangStr(); | |
| 30 | + s["et"] = "Tere"; | |
| 31 | + | |
| 32 | + s.Translate("et-EE").Should().Be("Tere"); | |
| 33 | + } | |
| 34 | + | |
| 35 | + // Parameterized: same translate logic across multiple inputs (lecture: [Theory]) | |
| 36 | + [Theory] | |
| 37 | + [InlineData("en", "Hello")] | |
| 38 | + [InlineData("et", "Tere")] | |
| 39 | + [InlineData("de", "Hallo")] | |
| 40 | + public void Translate_ReturnsCorrectValueForKnownCulture(string culture, string expected) | |
| 41 | + { | |
| 42 | + var s = new LangStr(); | |
| 43 | + s["en"] = "Hello"; | |
| 44 | + s["et"] = "Tere"; | |
| 45 | + s["de"] = "Hallo"; | |
| 46 | + | |
| 47 | + s.Translate(culture).Should().Be(expected); | |
| 48 | + } | |
| 49 | + | |
| 50 | + [Fact] | |
| 51 | + public void ImplicitOperator_FromString_CreatesLangStrWithCurrentCulture() | |
| 52 | + { | |
| 53 | + // string → LangStr conversion (used in EF migrations and admin UX) | |
| 54 | + LangStr s = "Test"; | |
| 55 | + | |
| 56 | + s.Translate().Should().Be("Test"); | |
| 57 | + } | |
| 58 | +} |
added SplitApp/App.Tests/Domain/TripValidationTests.cs +46 −0
| @@ -0,0 +1,46 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain; | |
| 3 | +using FluentAssertions; | |
| 4 | + | |
| 5 | +namespace App.Tests.Domain; | |
| 6 | + | |
| 7 | +public class TripValidationTests | |
| 8 | +{ | |
| 9 | + [Fact] | |
| 10 | + public void Validate_WhenEndDateBeforeStartDate_YieldsValidationError() | |
| 11 | + { | |
| 12 | + var trip = new Trip | |
| 13 | + { | |
| 14 | + Name = "Bad trip", | |
| 15 | + StartDate = new DateTime(2026, 5, 10, 0, 0, 0, DateTimeKind.Utc), | |
| 16 | + EndDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc) | |
| 17 | + }; | |
| 18 | + | |
| 19 | + var results = trip.Validate(new ValidationContext(trip)).ToList(); | |
| 20 | + | |
| 21 | + results.Should().ContainSingle() | |
| 22 | + .Which.MemberNames.Should().Contain(nameof(Trip.EndDate)); | |
| 23 | + } | |
| 24 | + | |
| 25 | + [Fact] | |
| 26 | + public void Validate_WhenEndDateEqualsStartDate_YieldsNoErrors() | |
| 27 | + { | |
| 28 | + // Edge case: same-day trip is valid | |
| 29 | + var date = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); | |
| 30 | + var trip = new Trip { Name = "Day trip", StartDate = date, EndDate = date }; | |
| 31 | + | |
| 32 | + var results = trip.Validate(new ValidationContext(trip)); | |
| 33 | + | |
| 34 | + results.Should().BeEmpty(); | |
| 35 | + } | |
| 36 | + | |
| 37 | + [Fact] | |
| 38 | + public void Validate_WhenDatesAreNull_YieldsNoErrors() | |
| 39 | + { | |
| 40 | + var trip = new Trip { Name = "Open-ended" }; | |
| 41 | + | |
| 42 | + var results = trip.Validate(new ValidationContext(trip)); | |
| 43 | + | |
| 44 | + results.Should().BeEmpty(); | |
| 45 | + } | |
| 46 | +} |
added SplitApp/App.Tests/E2E/MvcPagesE2ETests.cs +52 −0
| @@ -0,0 +1,52 @@ | ||
| 1 | +using System.Net; | |
| 2 | +using App.Tests.Integration; | |
| 3 | +using FluentAssertions; | |
| 4 | +using Microsoft.AspNetCore.Mvc.Testing; | |
| 5 | + | |
| 6 | +namespace App.Tests.E2E; | |
| 7 | + | |
| 8 | +/// <summary> | |
| 9 | +/// End-to-end tests of the MVC client surface — full pipeline (routing → controller → | |
| 10 | +/// service → repo → SQLite → repo → service → controller → Razor → HTML). | |
| 11 | +/// Verifies real user-facing pages render and unauthenticated access redirects to login. | |
| 12 | +/// </summary> | |
| 13 | +public class MvcPagesE2ETests : IClassFixture<WebApiTestFactory> | |
| 14 | +{ | |
| 15 | + private readonly HttpClient _client; | |
| 16 | + | |
| 17 | + public MvcPagesE2ETests(WebApiTestFactory factory) | |
| 18 | + { | |
| 19 | + // Disable auto-redirect — we want to *observe* the 302 to /Identity/Account/Login | |
| 20 | + _client = factory.CreateClient(new WebApplicationFactoryClientOptions | |
| 21 | + { | |
| 22 | + AllowAutoRedirect = false | |
| 23 | + }); | |
| 24 | + } | |
| 25 | + | |
| 26 | + [Fact] | |
| 27 | + public async Task HomePage_AnonymousUser_RendersHtmlSuccessfully() | |
| 28 | + { | |
| 29 | + // Act — anonymous user hits root | |
| 30 | + var response = await _client.GetAsync("/"); | |
| 31 | + | |
| 32 | + // Assert — full Razor pipeline produced an HTML response | |
| 33 | + response.StatusCode.Should().Be(HttpStatusCode.OK); | |
| 34 | + response.Content.Headers.ContentType?.MediaType.Should().Be("text/html"); | |
| 35 | + | |
| 36 | + var html = await response.Content.ReadAsStringAsync(); | |
| 37 | + html.Should().Contain("<html"); | |
| 38 | + html.Should().Contain("</html>"); | |
| 39 | + } | |
| 40 | + | |
| 41 | + [Fact] | |
| 42 | + public async Task ProtectedMvcRoute_AnonymousUser_RedirectsToLogin() | |
| 43 | + { | |
| 44 | + // Act — protected MVC route without auth cookie | |
| 45 | + var response = await _client.GetAsync("/Trips"); | |
| 46 | + | |
| 47 | + // Assert — Identity middleware issues a 302 redirect to the login page | |
| 48 | + response.StatusCode.Should().Be(HttpStatusCode.Redirect); | |
| 49 | + response.Headers.Location.Should().NotBeNull(); | |
| 50 | + response.Headers.Location!.OriginalString.Should().Contain("/Identity/Account/Login"); | |
| 51 | + } | |
| 52 | +} |
added SplitApp/App.Tests/GlobalTestInit.cs +24 −0
| @@ -0,0 +1,24 @@ | ||
| 1 | +using System.Globalization; | |
| 2 | +using System.Runtime.CompilerServices; | |
| 3 | + | |
| 4 | +namespace App.Tests; | |
| 5 | + | |
| 6 | +/// <summary> | |
| 7 | +/// Sets the default thread culture before any test runs. Linux Docker containers | |
| 8 | +/// default to Invariant culture (CurrentUICulture.Name == ""), which makes | |
| 9 | +/// `new LangStr("Food")` throw "Culture is required!" because LangStr derives the | |
| 10 | +/// culture from Thread.CurrentThread.CurrentUICulture.Name. Locally on Windows | |
| 11 | +/// this never fires because Windows always has a non-empty culture. | |
| 12 | +/// </summary> | |
| 13 | +internal static class GlobalTestInit | |
| 14 | +{ | |
| 15 | + [ModuleInitializer] | |
| 16 | + public static void Init() | |
| 17 | + { | |
| 18 | + var en = new CultureInfo("en"); | |
| 19 | + CultureInfo.DefaultThreadCurrentCulture = en; | |
| 20 | + CultureInfo.DefaultThreadCurrentUICulture = en; | |
| 21 | + Thread.CurrentThread.CurrentCulture = en; | |
| 22 | + Thread.CurrentThread.CurrentUICulture = en; | |
| 23 | + } | |
| 24 | +} |
added SplitApp/App.Tests/Integration/AccountApiIntegrationTests.cs +111 −0
| @@ -0,0 +1,111 @@ | ||
| 1 | +using System.Net; | |
| 2 | +using System.Net.Http.Headers; | |
| 3 | +using System.Net.Http.Json; | |
| 4 | +using App.DTO.v1.Identity; | |
| 5 | +using FluentAssertions; | |
| 6 | + | |
| 7 | +namespace App.Tests.Integration; | |
| 8 | + | |
| 9 | +/// <summary> | |
| 10 | +/// HTTP-level integration tests — real ASP.NET pipeline (controllers + auth + | |
| 11 | +/// EF + SQLite), only the network socket is replaced by TestServer. | |
| 12 | +/// </summary> | |
| 13 | +public class AccountApiIntegrationTests : IClassFixture<WebApiTestFactory> | |
| 14 | +{ | |
| 15 | + private readonly HttpClient _client; | |
| 16 | + | |
| 17 | + private readonly WebApiTestFactory _factory; | |
| 18 | + | |
| 19 | + public AccountApiIntegrationTests(WebApiTestFactory factory) | |
| 20 | + { | |
| 21 | + _factory = factory; | |
| 22 | + _client = factory.CreateClient(); | |
| 23 | + } | |
| 24 | + | |
| 25 | + [Fact] | |
| 26 | + public async Task Register_NewUser_ReturnsJwtAndRefreshToken() | |
| 27 | + { | |
| 28 | + // Arrange | |
| 29 | + var register = new RegisterInfo | |
| 30 | + { | |
| 31 | + Email = $"int-{Guid.NewGuid():N}@test.local", | |
| 32 | + Password = "Test.123!", | |
| 33 | + Firstname = "Integration", | |
| 34 | + Lastname = "Tester" | |
| 35 | + }; | |
| 36 | + | |
| 37 | + // Act | |
| 38 | + var response = await _client.PostAsJsonAsync( | |
| 39 | + "/api/v1/identity/Account/Register?expiresInSeconds=3600", register); | |
| 40 | + | |
| 41 | + // Assert | |
| 42 | + var body = await response.Content.ReadAsStringAsync(); | |
| 43 | + response.StatusCode.Should().Be(HttpStatusCode.OK, $"server replied: {body}"); | |
| 44 | + var jwt = await response.Content.ReadFromJsonAsync<JWTResponse>(); | |
| 45 | + jwt.Should().NotBeNull(); | |
| 46 | + jwt!.Jwt.Should().NotBeNullOrEmpty(); | |
| 47 | + jwt.RefreshToken.Should().NotBeNullOrEmpty(); | |
| 48 | + jwt.FirstName.Should().Be("Integration"); | |
| 49 | + } | |
| 50 | + | |
| 51 | + [Fact] | |
| 52 | + public async Task Login_AfterRegister_ReturnsJwt() | |
| 53 | + { | |
| 54 | + // Arrange — register first | |
| 55 | + var register = new RegisterInfo | |
| 56 | + { | |
| 57 | + Email = $"int-{Guid.NewGuid():N}@test.local", | |
| 58 | + Password = "Test.123!", | |
| 59 | + Firstname = "Login", | |
| 60 | + Lastname = "Tester" | |
| 61 | + }; | |
| 62 | + await _client.PostAsJsonAsync( | |
| 63 | + "/api/v1/identity/Account/Register?expiresInSeconds=3600", register); | |
| 64 | + | |
| 65 | + // Act — log in with same credentials | |
| 66 | + var loginResponse = await _client.PostAsJsonAsync( | |
| 67 | + "/api/v1/identity/Account/Login?expiresInSeconds=3600", | |
| 68 | + new LoginInfo { Email = register.Email, Password = register.Password }); | |
| 69 | + | |
| 70 | + // Assert | |
| 71 | + loginResponse.StatusCode.Should().Be(HttpStatusCode.OK); | |
| 72 | + var jwt = await loginResponse.Content.ReadFromJsonAsync<JWTResponse>(); | |
| 73 | + jwt!.Jwt.Should().NotBeNullOrEmpty(); | |
| 74 | + } | |
| 75 | + | |
| 76 | + [Fact] | |
| 77 | + public async Task GetTrips_WithoutJwt_ReturnsUnauthorized() | |
| 78 | + { | |
| 79 | + // No Authorization header — protected endpoint must reject | |
| 80 | + var response = await _client.GetAsync("/api/v1/Trips"); | |
| 81 | + | |
| 82 | + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); | |
| 83 | + } | |
| 84 | + | |
| 85 | + [Fact] | |
| 86 | + public async Task GetTrips_WithValidJwt_ReturnsEmptyListForFreshUser() | |
| 87 | + { | |
| 88 | + // Arrange — register, get JWT, attach as Bearer | |
| 89 | + var register = new RegisterInfo | |
| 90 | + { | |
| 91 | + Email = $"int-{Guid.NewGuid():N}@test.local", | |
| 92 | + Password = "Test.123!", | |
| 93 | + Firstname = "Trips", | |
| 94 | + Lastname = "Tester" | |
| 95 | + }; | |
| 96 | + var registerResp = await _client.PostAsJsonAsync( | |
| 97 | + "/api/v1/identity/Account/Register?expiresInSeconds=3600", register); | |
| 98 | + var jwt = (await registerResp.Content.ReadFromJsonAsync<JWTResponse>())!.Jwt; | |
| 99 | + | |
| 100 | + // Use factory.CreateClient() — that gives an HttpClient pointed at TestServer. | |
| 101 | + // Don't `new HttpClient`, that would try real localhost:80. | |
| 102 | + var authClient = _factory.CreateClient(); | |
| 103 | + authClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", jwt); | |
| 104 | + | |
| 105 | + // Act | |
| 106 | + var response = await authClient.GetAsync("/api/v1/Trips"); | |
| 107 | + | |
| 108 | + // Assert | |
| 109 | + response.StatusCode.Should().Be(HttpStatusCode.OK); | |
| 110 | + } | |
| 111 | +} |
added SplitApp/App.Tests/Integration/WebApiTestFactory.cs +100 −0
| @@ -0,0 +1,100 @@ | ||
| 1 | +using App.DAL.EF; | |
| 2 | +using Microsoft.AspNetCore.Hosting; | |
| 3 | +using Microsoft.AspNetCore.Mvc.Testing; | |
| 4 | +using Microsoft.Data.Sqlite; | |
| 5 | +using Microsoft.EntityFrameworkCore; | |
| 6 | +using Microsoft.Extensions.Configuration; | |
| 7 | +using Microsoft.AspNetCore.Identity; | |
| 8 | +using Microsoft.Extensions.DependencyInjection; | |
| 9 | +using Microsoft.Extensions.Hosting; | |
| 10 | + | |
| 11 | +namespace App.Tests.Integration; | |
| 12 | + | |
| 13 | +/// <summary> | |
| 14 | +/// Real ASP.NET pipeline (controllers + auth + EF) but Postgres → SQLite in-memory. | |
| 15 | +/// One factory per test class via IClassFixture — DB lives for the class lifetime. | |
| 16 | +/// </summary> | |
| 17 | +public class WebApiTestFactory : WebApplicationFactory<Program> | |
| 18 | +{ | |
| 19 | + private readonly SqliteConnection _connection; | |
| 20 | + | |
| 21 | + public WebApiTestFactory() | |
| 22 | + { | |
| 23 | + _connection = new SqliteConnection("Data Source=:memory:"); | |
| 24 | + _connection.Open(); | |
| 25 | + } | |
| 26 | + | |
| 27 | + protected override void ConfigureWebHost(IWebHostBuilder builder) | |
| 28 | + { | |
| 29 | + builder.UseEnvironment("Testing"); | |
| 30 | + | |
| 31 | + builder.ConfigureAppConfiguration((_, configBuilder) => | |
| 32 | + { | |
| 33 | + configBuilder.AddInMemoryCollection(new Dictionary<string, string?> | |
| 34 | + { | |
| 35 | + // Skip data initialization (handled by Program.cs env check) | |
| 36 | + ["DataInitialization:DropDatabase"] = "false", | |
| 37 | + ["DataInitialization:MigrateDatabase"] = "false", | |
| 38 | + ["DataInitialization:SeedIdentity"] = "false", | |
| 39 | + ["DataInitialization:SeedData"] = "false", | |
| 40 | + | |
| 41 | + // JWT (HS256 needs ≥32-byte key) | |
| 42 | + ["JWT:Key"] = "test-jwt-key-for-integration-tests-only-1234567890", | |
| 43 | + ["JWT:Issuer"] = "test-issuer", | |
| 44 | + ["JWT:Audience"] = "test-audience", | |
| 45 | + | |
| 46 | + ["SupportedCultures:0"] = "en", | |
| 47 | + ["SupportedCultures:1"] = "et", | |
| 48 | + | |
| 49 | + ["ConnectionStrings:DefaultConnection"] = "Host=replaced;Database=replaced", | |
| 50 | + }); | |
| 51 | + }); | |
| 52 | + | |
| 53 | + builder.ConfigureServices(services => | |
| 54 | + { | |
| 55 | + // Nuke EVERY EF-related descriptor that the real app registered (AddDbContext | |
| 56 | + // with UseNpgsql leaves behind ~15 DbContextOptions / EF service entries). | |
| 57 | + // Without this, both Npgsql AND Sqlite providers end up configured on the | |
| 58 | + // same DbContextOptions and EF throws "multiple providers" errors. | |
| 59 | + var efDescriptors = services.Where(d => | |
| 60 | + d.ServiceType.FullName != null && | |
| 61 | + (d.ServiceType.FullName.Contains("EntityFrameworkCore") || | |
| 62 | + d.ServiceType == typeof(AppDbContext)) | |
| 63 | + ).ToList(); | |
| 64 | + foreach (var d in efDescriptors) services.Remove(d); | |
| 65 | + | |
| 66 | + // Re-add fresh with SQLite only | |
| 67 | + services.AddDbContext<AppDbContext>(opts => opts.UseSqlite(_connection)); | |
| 68 | + }); | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// <summary> | |
| 72 | + /// EnsureCreated runs against the fully-built service provider (host.Services), | |
| 73 | + /// not the half-built collection inside ConfigureServices. Also seeds the "user" | |
| 74 | + /// role since IdentityService.RegisterAsync calls AddToRoleAsync("user"). | |
| 75 | + /// </summary> | |
| 76 | + protected override IHost CreateHost(IHostBuilder builder) | |
| 77 | + { | |
| 78 | + var host = base.CreateHost(builder); | |
| 79 | + using var scope = host.Services.CreateScope(); | |
| 80 | + var sp = scope.ServiceProvider; | |
| 81 | + | |
| 82 | + var db = sp.GetRequiredService<AppDbContext>(); | |
| 83 | + db.Database.EnsureCreated(); | |
| 84 | + | |
| 85 | + var roleManager = sp.GetRequiredService<RoleManager<App.Domain.Identity.AppRole>>(); | |
| 86 | + if (!roleManager.RoleExistsAsync("user").GetAwaiter().GetResult()) | |
| 87 | + { | |
| 88 | + roleManager.CreateAsync(new App.Domain.Identity.AppRole { Name = "user" }) | |
| 89 | + .GetAwaiter().GetResult(); | |
| 90 | + } | |
| 91 | + | |
| 92 | + return host; | |
| 93 | + } | |
| 94 | + | |
| 95 | + protected override void Dispose(bool disposing) | |
| 96 | + { | |
| 97 | + base.Dispose(disposing); | |
| 98 | + if (disposing) _connection.Dispose(); | |
| 99 | + } | |
| 100 | +} |
added SplitApp/App.Tests/Mappers/BudgetCategoryBllDtoFactoryTests.cs +56 −0
| @@ -0,0 +1,56 @@ | ||
| 1 | +using App.BLL.Mappers; | |
| 2 | +using App.Domain; | |
| 3 | +using Base.Domain; | |
| 4 | +using FluentAssertions; | |
| 5 | + | |
| 6 | +namespace App.Tests.Mappers; | |
| 7 | + | |
| 8 | +public class BudgetCategoryBllDtoFactoryTests | |
| 9 | +{ | |
| 10 | + [Fact] | |
| 11 | + public void Create_MapsAllScalarFields() | |
| 12 | + { | |
| 13 | + var entity = new BudgetCategory | |
| 14 | + { | |
| 15 | + Id = Guid.NewGuid(), | |
| 16 | + TripId = Guid.NewGuid(), | |
| 17 | + Name = new LangStr("Food"), | |
| 18 | + IconName = "fa-utensils", | |
| 19 | + PlannedAmount = 250.50m, | |
| 20 | + DisplayOrder = 3 | |
| 21 | + }; | |
| 22 | + | |
| 23 | + var dto = BudgetCategoryBllDtoFactory.Create(entity); | |
| 24 | + | |
| 25 | + dto.Id.Should().Be(entity.Id); | |
| 26 | + dto.TripId.Should().Be(entity.TripId); | |
| 27 | + ((string)dto.Name).Should().Be("Food"); | |
| 28 | + dto.IconName.Should().Be("fa-utensils"); | |
| 29 | + dto.PlannedAmount.Should().Be(250.50m); | |
| 30 | + dto.DisplayOrder.Should().Be(3); | |
| 31 | + } | |
| 32 | + | |
| 33 | + [Fact] | |
| 34 | + public void ToEntity_RoundTrip_PreservesScalarFields() | |
| 35 | + { | |
| 36 | + var original = new App.BLL.DTO.BudgetCategoryBllDto | |
| 37 | + { | |
| 38 | + Id = Guid.NewGuid(), | |
| 39 | + TripId = Guid.NewGuid(), | |
| 40 | + Name = new LangStr("Transport"), | |
| 41 | + IconName = "fa-car", | |
| 42 | + PlannedAmount = 500m, | |
| 43 | + DisplayOrder = 2 | |
| 44 | + }; | |
| 45 | + | |
| 46 | + var entity = BudgetCategoryBllDtoFactory.ToEntity(original); | |
| 47 | + var roundTripped = BudgetCategoryBllDtoFactory.Create(entity); | |
| 48 | + | |
| 49 | + roundTripped.Id.Should().Be(original.Id); | |
| 50 | + roundTripped.TripId.Should().Be(original.TripId); | |
| 51 | + ((string)roundTripped.Name).Should().Be("Transport"); | |
| 52 | + roundTripped.IconName.Should().Be("fa-car"); | |
| 53 | + roundTripped.PlannedAmount.Should().Be(500m); | |
| 54 | + roundTripped.DisplayOrder.Should().Be(2); | |
| 55 | + } | |
| 56 | +} |
added SplitApp/App.Tests/Mappers/CurrencyBllDtoFactoryTests.cs +33 −0
| @@ -0,0 +1,33 @@ | ||
| 1 | +using App.BLL.Mappers; | |
| 2 | +using App.Domain; | |
| 3 | +using Base.Domain; | |
| 4 | +using FluentAssertions; | |
| 5 | + | |
| 6 | +namespace App.Tests.Mappers; | |
| 7 | + | |
| 8 | +public class CurrencyBllDtoFactoryTests | |
| 9 | +{ | |
| 10 | + [Fact] | |
| 11 | + public void Create_PreservesLangStrTranslations() | |
| 12 | + { | |
| 13 | + // Currency.Name is a LangStr (i18n in DB) — mapper must keep all translations | |
| 14 | + var name = new LangStr(); | |
| 15 | + name["en"] = "Euro"; | |
| 16 | + name["et"] = "Euro"; | |
| 17 | + | |
| 18 | + var entity = new Currency | |
| 19 | + { | |
| 20 | + Id = Guid.NewGuid(), | |
| 21 | + Code = "EUR", | |
| 22 | + Name = name, | |
| 23 | + Symbol = "€" | |
| 24 | + }; | |
| 25 | + | |
| 26 | + var dto = CurrencyBllDtoFactory.Create(entity); | |
| 27 | + | |
| 28 | + dto.Code.Should().Be("EUR"); | |
| 29 | + dto.Symbol.Should().Be("€"); | |
| 30 | + dto.Name.Translate("en").Should().Be("Euro"); | |
| 31 | + dto.Name.Translate("et").Should().Be("Euro"); | |
| 32 | + } | |
| 33 | +} |
added SplitApp/App.Tests/Mappers/ExpenseBllDtoFactoryTests.cs +37 −0
| @@ -0,0 +1,37 @@ | ||
| 1 | +using App.BLL.Mappers; | |
| 2 | +using App.Domain; | |
| 3 | +using FluentAssertions; | |
| 4 | + | |
| 5 | +namespace App.Tests.Mappers; | |
| 6 | + | |
| 7 | +public class ExpenseBllDtoFactoryTests | |
| 8 | +{ | |
| 9 | + [Fact] | |
| 10 | + public void Create_MapsAllScalarFields() | |
| 11 | + { | |
| 12 | + var entity = new Expense | |
| 13 | + { | |
| 14 | + Id = Guid.NewGuid(), | |
| 15 | + TripId = Guid.NewGuid(), | |
| 16 | + PaidByUserId = Guid.NewGuid(), | |
| 17 | + BudgetCategoryId = Guid.NewGuid(), | |
| 18 | + CurrencyId = Guid.NewGuid(), | |
| 19 | + Amount = 99.99m, | |
| 20 | + Description = "Taxi", | |
| 21 | + ExpenseDate = new DateTime(2026, 5, 2, 12, 0, 0, DateTimeKind.Utc), | |
| 22 | + SplitMethod = ESplitMethod.EqualAll | |
| 23 | + }; | |
| 24 | + | |
| 25 | + var dto = ExpenseBllDtoFactory.Create(entity); | |
| 26 | + | |
| 27 | + dto.Id.Should().Be(entity.Id); | |
| 28 | + dto.TripId.Should().Be(entity.TripId); | |
| 29 | + dto.PaidByUserId.Should().Be(entity.PaidByUserId); | |
| 30 | + dto.BudgetCategoryId.Should().Be(entity.BudgetCategoryId); | |
| 31 | + dto.CurrencyId.Should().Be(entity.CurrencyId); | |
| 32 | + dto.Amount.Should().Be(99.99m); | |
| 33 | + dto.Description.Should().Be("Taxi"); | |
| 34 | + dto.ExpenseDate.Should().Be(entity.ExpenseDate); | |
| 35 | + dto.SplitMethod.Should().Be(ESplitMethod.EqualAll); | |
| 36 | + } | |
| 37 | +} |
added SplitApp/App.Tests/Mappers/TripBllDtoFactoryTests.cs +65 −0
| @@ -0,0 +1,65 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Mappers; | |
| 3 | +using App.Domain; | |
| 4 | +using FluentAssertions; | |
| 5 | + | |
| 6 | +namespace App.Tests.Mappers; | |
| 7 | + | |
| 8 | +public class TripBllDtoFactoryTests | |
| 9 | +{ | |
| 10 | + [Fact] | |
| 11 | + public void Create_MapsAllScalarFields() | |
| 12 | + { | |
| 13 | + var entity = new Trip | |
| 14 | + { | |
| 15 | + Id = Guid.NewGuid(), | |
| 16 | + Name = "Paris", | |
| 17 | + Description = "Long weekend", | |
| 18 | + Destination = "France", | |
| 19 | + StartDate = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), | |
| 20 | + EndDate = new DateTime(2026, 5, 5, 0, 0, 0, DateTimeKind.Utc), | |
| 21 | + Status = ETripStatus.Active, | |
| 22 | + DefaultCurrencyId = Guid.NewGuid(), | |
| 23 | + CreatedById = Guid.NewGuid() | |
| 24 | + }; | |
| 25 | + | |
| 26 | + var dto = TripBllDtoFactory.Create(entity); | |
| 27 | + | |
| 28 | + dto.Id.Should().Be(entity.Id); | |
| 29 | + dto.Name.Should().Be("Paris"); | |
| 30 | + dto.Description.Should().Be("Long weekend"); | |
| 31 | + dto.Destination.Should().Be("France"); | |
| 32 | + dto.StartDate.Should().Be(entity.StartDate); | |
| 33 | + dto.EndDate.Should().Be(entity.EndDate); | |
| 34 | + dto.Status.Should().Be(ETripStatus.Active); | |
| 35 | + dto.DefaultCurrencyId.Should().Be(entity.DefaultCurrencyId); | |
| 36 | + dto.CreatedById.Should().Be(entity.CreatedById); | |
| 37 | + } | |
| 38 | + | |
| 39 | + [Fact] | |
| 40 | + public void ToEntity_RoundTrip_PreservesScalarFields() | |
| 41 | + { | |
| 42 | + var original = new TripBllDto | |
| 43 | + { | |
| 44 | + Id = Guid.NewGuid(), | |
| 45 | + Name = "Tokyo", | |
| 46 | + Description = "Cherry blossom", | |
| 47 | + Destination = "Japan", | |
| 48 | + StartDate = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), | |
| 49 | + EndDate = new DateTime(2026, 4, 14, 0, 0, 0, DateTimeKind.Utc), | |
| 50 | + Status = ETripStatus.Active, | |
| 51 | + DefaultCurrencyId = Guid.NewGuid(), | |
| 52 | + CreatedById = Guid.NewGuid() | |
| 53 | + }; | |
| 54 | + | |
| 55 | + var roundTripped = TripBllDtoFactory.Create(TripBllDtoFactory.ToEntity(original)); | |
| 56 | + | |
| 57 | + roundTripped.Should().BeEquivalentTo(original, opts => opts | |
| 58 | + .Excluding(x => x.CreatedAt) | |
| 59 | + .Excluding(x => x.UpdatedAt) | |
| 60 | + .Excluding(x => x.DefaultCurrency) | |
| 61 | + .Excluding(x => x.CreatedBy) | |
| 62 | + .Excluding(x => x.CreatedByFullName) | |
| 63 | + .Excluding(x => x.CreatedByEmail)); | |
| 64 | + } | |
| 65 | +} |
added SplitApp/App.Tests/RepositoryTestBase.cs +71 −0
| @@ -0,0 +1,71 @@ | ||
| 1 | +using App.DAL.EF; | |
| 2 | +using App.Domain; | |
| 3 | +using App.Domain.Identity; | |
| 4 | +using Base.Domain; | |
| 5 | +using Microsoft.Data.Sqlite; | |
| 6 | +using Microsoft.EntityFrameworkCore; | |
| 7 | + | |
| 8 | +namespace App.Tests; | |
| 9 | + | |
| 10 | +/// <summary> | |
| 11 | +/// SQLite in-memory base for DAL tests. Lecture explicitly prefers this over EF's | |
| 12 | +/// InMemory provider because it enforces foreign keys and behaves like a real RDBMS. | |
| 13 | +/// </summary> | |
| 14 | +public abstract class RepositoryTestBase : IDisposable | |
| 15 | +{ | |
| 16 | + protected readonly AppDbContext Context; | |
| 17 | + private readonly SqliteConnection _connection; | |
| 18 | + | |
| 19 | + protected RepositoryTestBase() | |
| 20 | + { | |
| 21 | + _connection = new SqliteConnection("Data Source=:memory:"); | |
| 22 | + _connection.Open(); | |
| 23 | + | |
| 24 | + var options = new DbContextOptionsBuilder<AppDbContext>() | |
| 25 | + .UseSqlite(_connection) | |
| 26 | + .Options; | |
| 27 | + | |
| 28 | + Context = new AppDbContext(options); | |
| 29 | + Context.Database.EnsureCreated(); | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// <summary>Seeds a Currency, an AppUser and a Trip; returns them. Trip has the user as organizer participant.</summary> | |
| 33 | + protected async Task<(Currency currency, AppUser user, Trip trip)> SeedTripAsync() | |
| 34 | + { | |
| 35 | + var currency = new Currency { Code = "EUR", Name = new LangStr("Euro"), Symbol = "€" }; | |
| 36 | + var user = new AppUser | |
| 37 | + { | |
| 38 | + Id = Guid.NewGuid(), | |
| 39 | + UserName = "test@example.com", | |
| 40 | + Email = "test@example.com", | |
| 41 | + FirstName = "Test", | |
| 42 | + LastName = "User" | |
| 43 | + }; | |
| 44 | + var trip = new Trip | |
| 45 | + { | |
| 46 | + Name = "Paris", | |
| 47 | + Destination = "France", | |
| 48 | + DefaultCurrencyId = currency.Id, | |
| 49 | + CreatedById = user.Id | |
| 50 | + }; | |
| 51 | + Context.Currencies.Add(currency); | |
| 52 | + Context.Users.Add(user); | |
| 53 | + Context.Trips.Add(trip); | |
| 54 | + Context.TripParticipants.Add(new TripParticipant | |
| 55 | + { | |
| 56 | + TripId = trip.Id, | |
| 57 | + UserId = user.Id, | |
| 58 | + Role = EParticipantRole.Organizer, | |
| 59 | + IsActive = true | |
| 60 | + }); | |
| 61 | + await Context.SaveChangesAsync(); | |
| 62 | + return (currency, user, trip); | |
| 63 | + } | |
| 64 | + | |
| 65 | + public void Dispose() | |
| 66 | + { | |
| 67 | + Context.Dispose(); | |
| 68 | + _connection.Dispose(); | |
| 69 | + GC.SuppressFinalize(this); | |
| 70 | + } | |
| 71 | +} |
added SplitApp/App.Tests/SanityTest.cs +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +using FluentAssertions; | |
| 2 | + | |
| 3 | +namespace App.Tests; | |
| 4 | + | |
| 5 | +public class SanityTest | |
| 6 | +{ | |
| 7 | + [Fact] | |
| 8 | + public void TestRunner_Works() | |
| 9 | + { | |
| 10 | + true.Should().BeTrue(); | |
| 11 | + } | |
| 12 | +} |
added SplitApp/Base.Contracts/Base.Contracts.csproj +9 −0
| @@ -0,0 +1,9 @@ | ||
| 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 | +</Project> |
added SplitApp/Base.Contracts/IBaseEntity.cs +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +namespace Base.Contracts; | |
| 2 | + | |
| 3 | +public interface IBaseEntity | |
| 4 | +{ | |
| 5 | + public Guid Id { get; set; } | |
| 6 | +} |
added SplitApp/Base.Contracts/IBaseRepository.cs +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +namespace Base.Contracts; | |
| 2 | + | |
| 3 | +public interface IBaseRepository<TEntity> where TEntity : class, IBaseEntity | |
| 4 | +{ | |
| 5 | + Task<IEnumerable<TEntity>> GetAllAsync(); | |
| 6 | + Task<TEntity?> GetByIdAsync(Guid id); | |
| 7 | + TEntity Add(TEntity entity); | |
| 8 | + TEntity Update(TEntity entity); | |
| 9 | + Task<TEntity?> RemoveAsync(Guid id); | |
| 10 | + Task<bool> ExistsAsync(Guid id); | |
| 11 | +} |
added SplitApp/Base.Contracts/IUnitOfWork.cs +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +namespace Base.Contracts; | |
| 2 | + | |
| 3 | +public interface IUnitOfWork | |
| 4 | +{ | |
| 5 | + Task<int> SaveChangesAsync(); | |
| 6 | +} |
added SplitApp/Base.Domain/Base.Domain.csproj +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk"> | |
| 2 | + | |
| 3 | + <ItemGroup> | |
| 4 | + <ProjectReference Include="..\Base.Contracts\Base.Contracts.csproj" /> | |
| 5 | + </ItemGroup> | |
| 6 | + | |
| 7 | + <PropertyGroup> | |
| 8 | + <TargetFramework>net10.0</TargetFramework> | |
| 9 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 10 | + <Nullable>enable</Nullable> | |
| 11 | + </PropertyGroup> | |
| 12 | + | |
| 13 | +</Project> |
added SplitApp/Base.Domain/BaseEntity.cs +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +using Base.Contracts; | |
| 2 | + | |
| 3 | +namespace Base.Domain; | |
| 4 | + | |
| 5 | +public abstract class BaseEntity : IBaseEntity | |
| 6 | +{ | |
| 7 | + public Guid Id { get; set; } = Guid.NewGuid(); | |
| 8 | + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; | |
| 9 | + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; | |
| 10 | +} |
added SplitApp/Base.Domain/LangStr.cs +80 −0
| @@ -0,0 +1,80 @@ | ||
| 1 | +namespace Base.Domain; | |
| 2 | + | |
| 3 | +public class LangStr : Dictionary<string, string> | |
| 4 | +{ | |
| 5 | + | |
| 6 | + // look at appsettings.json LangStrDefaultCulture value | |
| 7 | + public static string DefaultCulture { get; set; } = "en"; | |
| 8 | + | |
| 9 | + // s["en"] = "foo"; | |
| 10 | + // var bar = s["en"]; | |
| 11 | + public new string this[string key] | |
| 12 | + { | |
| 13 | + get => base[key]; | |
| 14 | + set => base[key] = value; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public LangStr() | |
| 18 | + { | |
| 19 | + } | |
| 20 | + | |
| 21 | + public LangStr(string value) : this(value, Thread.CurrentThread.CurrentUICulture.Name) | |
| 22 | + { | |
| 23 | + } | |
| 24 | + | |
| 25 | + public LangStr(string value, string culture) | |
| 26 | + { | |
| 27 | + if (culture.Length < 1) throw new ApplicationException("Culture is required!"); | |
| 28 | + | |
| 29 | + var neutralCulture = culture.Split('-')[0]; | |
| 30 | + this[neutralCulture] = value; | |
| 31 | + | |
| 32 | + // check for default culture also. if not set - do so | |
| 33 | + if (!ContainsKey(DefaultCulture)) | |
| 34 | + { | |
| 35 | + this[DefaultCulture] = value; | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + public string? Translate(string? culture = null) | |
| 40 | + { | |
| 41 | + if (Count == 0) return null; | |
| 42 | + culture = culture?.Trim() ?? Thread.CurrentThread.CurrentUICulture.Name; | |
| 43 | + | |
| 44 | + if (ContainsKey(culture)) | |
| 45 | + { | |
| 46 | + return this[culture]; | |
| 47 | + } | |
| 48 | + | |
| 49 | + var neutralCulture = culture.Split('-')[0]; | |
| 50 | + if (ContainsKey(neutralCulture)) | |
| 51 | + { | |
| 52 | + return this[neutralCulture]; | |
| 53 | + } | |
| 54 | + | |
| 55 | + if (ContainsKey(DefaultCulture)) | |
| 56 | + { | |
| 57 | + return this[DefaultCulture]; | |
| 58 | + } | |
| 59 | + | |
| 60 | + return null; | |
| 61 | + } | |
| 62 | + | |
| 63 | + public void SetTranslation(string value, string? culture = null) | |
| 64 | + { | |
| 65 | + culture = culture?.Trim() ?? Thread.CurrentThread.CurrentUICulture.Name; | |
| 66 | + var neutralCulture = culture.Split('-')[0]; | |
| 67 | + this[neutralCulture] = value; | |
| 68 | + } | |
| 69 | + | |
| 70 | + public override string ToString() | |
| 71 | + { | |
| 72 | + return Translate() ?? "????"; | |
| 73 | + } | |
| 74 | + | |
| 75 | + // string xxx = new LangStr("foo","et-EE"); xxx == "foo"; | |
| 76 | + public static implicit operator string(LangStr? langStr) => langStr?.ToString() ?? "null"; | |
| 77 | + | |
| 78 | + // LangStr xxx = "foobar"; | |
| 79 | + public static implicit operator LangStr(string value) => new LangStr(value); | |
| 80 | +} |
added SplitApp/Base.Helpers/Base.Helpers.csproj +13 −0
| @@ -0,0 +1,13 @@ | ||
| 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="System.IdentityModel.Tokens.Jwt" Version="8.17.0" /> | |
| 11 | + </ItemGroup> | |
| 12 | + | |
| 13 | +</Project> |
added SplitApp/Base.Helpers/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 Base.Helpers; | |
| 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 // allow expired tokens during refresh | |
| 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/Directory.Build.Props +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +<Project> | |
| 2 | + <PropertyGroup> | |
| 3 | + <LangVersion>latest</LangVersion> | |
| 4 | + <Nullable>enable</Nullable> | |
| 5 | + <WarningsAsErrors>CS8600,CS8602,CS8603,CS8613,CS8618,CS8625</WarningsAsErrors> | |
| 6 | + </PropertyGroup> | |
| 7 | +</Project> | |
| No newline at end of file |
added SplitApp/SplitApp.sln +157 −0
| @@ -0,0 +1,157 @@ | ||
| 1 | + | |
| 2 | +Microsoft Visual Studio Solution File, Format Version 12.00 | |
| 3 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.csproj", "{EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}" | |
| 4 | +EndProject | |
| 5 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Base.Contracts", "Base.Contracts\Base.Contracts.csproj", "{6A30751E-124D-4B54-922F-CA3223684935}" | |
| 6 | +EndProject | |
| 7 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Base.Domain", "Base.Domain\Base.Domain.csproj", "{D526C29F-840F-4649-BB62-37F68967B0EA}" | |
| 8 | +EndProject | |
| 9 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Base.Helpers", "Base.Helpers\Base.Helpers.csproj", "{1914A773-410A-48C2-BB38-9D291C8BD7D4}" | |
| 10 | +EndProject | |
| 11 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Domain", "App.Domain\App.Domain.csproj", "{E599E802-F555-4D80-A02A-2BD436E58098}" | |
| 12 | +EndProject | |
| 13 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.DAL.EF", "App.DAL.EF\App.DAL.EF.csproj", "{0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}" | |
| 14 | +EndProject | |
| 15 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.DTO", "App.DTO\App.DTO.csproj", "{CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}" | |
| 16 | +EndProject | |
| 17 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Resources", "App.Resources\App.Resources.csproj", "{5844D206-3794-4A30-9636-53942E4101F2}" | |
| 18 | +EndProject | |
| 19 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.BLL", "App.BLL\App.BLL.csproj", "{D810C105-D212-4A26-9F20-9031F7E3BE4A}" | |
| 20 | +EndProject | |
| 21 | +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App.Tests", "App.Tests\App.Tests.csproj", "{75A8984F-7052-4929-A26F-8C430DBCC212}" | |
| 22 | +EndProject | |
| 23 | +Global | |
| 24 | + GlobalSection(SolutionConfigurationPlatforms) = preSolution | |
| 25 | + Debug|Any CPU = Debug|Any CPU | |
| 26 | + Debug|x64 = Debug|x64 | |
| 27 | + Debug|x86 = Debug|x86 | |
| 28 | + Release|Any CPU = Release|Any CPU | |
| 29 | + Release|x64 = Release|x64 | |
| 30 | + Release|x86 = Release|x86 | |
| 31 | + EndGlobalSection | |
| 32 | + GlobalSection(ProjectConfigurationPlatforms) = postSolution | |
| 33 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 34 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 35 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 36 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Debug|x64.Build.0 = Debug|Any CPU | |
| 37 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 38 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Debug|x86.Build.0 = Debug|Any CPU | |
| 39 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 40 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 41 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Release|x64.ActiveCfg = Release|Any CPU | |
| 42 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Release|x64.Build.0 = Release|Any CPU | |
| 43 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Release|x86.ActiveCfg = Release|Any CPU | |
| 44 | + {EAFA77C2-1DF1-4FBC-920A-7B65988F3FC1}.Release|x86.Build.0 = Release|Any CPU | |
| 45 | + {6A30751E-124D-4B54-922F-CA3223684935}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 46 | + {6A30751E-124D-4B54-922F-CA3223684935}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 47 | + {6A30751E-124D-4B54-922F-CA3223684935}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 48 | + {6A30751E-124D-4B54-922F-CA3223684935}.Debug|x64.Build.0 = Debug|Any CPU | |
| 49 | + {6A30751E-124D-4B54-922F-CA3223684935}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 50 | + {6A30751E-124D-4B54-922F-CA3223684935}.Debug|x86.Build.0 = Debug|Any CPU | |
| 51 | + {6A30751E-124D-4B54-922F-CA3223684935}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 52 | + {6A30751E-124D-4B54-922F-CA3223684935}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 53 | + {6A30751E-124D-4B54-922F-CA3223684935}.Release|x64.ActiveCfg = Release|Any CPU | |
| 54 | + {6A30751E-124D-4B54-922F-CA3223684935}.Release|x64.Build.0 = Release|Any CPU | |
| 55 | + {6A30751E-124D-4B54-922F-CA3223684935}.Release|x86.ActiveCfg = Release|Any CPU | |
| 56 | + {6A30751E-124D-4B54-922F-CA3223684935}.Release|x86.Build.0 = Release|Any CPU | |
| 57 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 58 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 59 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 60 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Debug|x64.Build.0 = Debug|Any CPU | |
| 61 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 62 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Debug|x86.Build.0 = Debug|Any CPU | |
| 63 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 64 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 65 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Release|x64.ActiveCfg = Release|Any CPU | |
| 66 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Release|x64.Build.0 = Release|Any CPU | |
| 67 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Release|x86.ActiveCfg = Release|Any CPU | |
| 68 | + {D526C29F-840F-4649-BB62-37F68967B0EA}.Release|x86.Build.0 = Release|Any CPU | |
| 69 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 70 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 71 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 72 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Debug|x64.Build.0 = Debug|Any CPU | |
| 73 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 74 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Debug|x86.Build.0 = Debug|Any CPU | |
| 75 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 76 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 77 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Release|x64.ActiveCfg = Release|Any CPU | |
| 78 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Release|x64.Build.0 = Release|Any CPU | |
| 79 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Release|x86.ActiveCfg = Release|Any CPU | |
| 80 | + {1914A773-410A-48C2-BB38-9D291C8BD7D4}.Release|x86.Build.0 = Release|Any CPU | |
| 81 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 82 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 83 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 84 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Debug|x64.Build.0 = Debug|Any CPU | |
| 85 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 86 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Debug|x86.Build.0 = Debug|Any CPU | |
| 87 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 88 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 89 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Release|x64.ActiveCfg = Release|Any CPU | |
| 90 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Release|x64.Build.0 = Release|Any CPU | |
| 91 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Release|x86.ActiveCfg = Release|Any CPU | |
| 92 | + {E599E802-F555-4D80-A02A-2BD436E58098}.Release|x86.Build.0 = Release|Any CPU | |
| 93 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 94 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 95 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 96 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Debug|x64.Build.0 = Debug|Any CPU | |
| 97 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 98 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Debug|x86.Build.0 = Debug|Any CPU | |
| 99 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 100 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 101 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Release|x64.ActiveCfg = Release|Any CPU | |
| 102 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Release|x64.Build.0 = Release|Any CPU | |
| 103 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Release|x86.ActiveCfg = Release|Any CPU | |
| 104 | + {0749DCF3-8CF3-4AF6-AA90-80DA6FD8F62B}.Release|x86.Build.0 = Release|Any CPU | |
| 105 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 106 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 107 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 108 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Debug|x64.Build.0 = Debug|Any CPU | |
| 109 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 110 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Debug|x86.Build.0 = Debug|Any CPU | |
| 111 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 112 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 113 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Release|x64.ActiveCfg = Release|Any CPU | |
| 114 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Release|x64.Build.0 = Release|Any CPU | |
| 115 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Release|x86.ActiveCfg = Release|Any CPU | |
| 116 | + {CE60AF61-9B45-48F0-BD98-B7B4EA8E0A27}.Release|x86.Build.0 = Release|Any CPU | |
| 117 | + {5844D206-3794-4A30-9636-53942E4101F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 118 | + {5844D206-3794-4A30-9636-53942E4101F2}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 119 | + {5844D206-3794-4A30-9636-53942E4101F2}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 120 | + {5844D206-3794-4A30-9636-53942E4101F2}.Debug|x64.Build.0 = Debug|Any CPU | |
| 121 | + {5844D206-3794-4A30-9636-53942E4101F2}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 122 | + {5844D206-3794-4A30-9636-53942E4101F2}.Debug|x86.Build.0 = Debug|Any CPU | |
| 123 | + {5844D206-3794-4A30-9636-53942E4101F2}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 124 | + {5844D206-3794-4A30-9636-53942E4101F2}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 125 | + {5844D206-3794-4A30-9636-53942E4101F2}.Release|x64.ActiveCfg = Release|Any CPU | |
| 126 | + {5844D206-3794-4A30-9636-53942E4101F2}.Release|x64.Build.0 = Release|Any CPU | |
| 127 | + {5844D206-3794-4A30-9636-53942E4101F2}.Release|x86.ActiveCfg = Release|Any CPU | |
| 128 | + {5844D206-3794-4A30-9636-53942E4101F2}.Release|x86.Build.0 = Release|Any CPU | |
| 129 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 130 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 131 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 132 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Debug|x64.Build.0 = Debug|Any CPU | |
| 133 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 134 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Debug|x86.Build.0 = Debug|Any CPU | |
| 135 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 136 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 137 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Release|x64.ActiveCfg = Release|Any CPU | |
| 138 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Release|x64.Build.0 = Release|Any CPU | |
| 139 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Release|x86.ActiveCfg = Release|Any CPU | |
| 140 | + {D810C105-D212-4A26-9F20-9031F7E3BE4A}.Release|x86.Build.0 = Release|Any CPU | |
| 141 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Debug|Any CPU.ActiveCfg = Debug|Any CPU | |
| 142 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Debug|Any CPU.Build.0 = Debug|Any CPU | |
| 143 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Debug|x64.ActiveCfg = Debug|Any CPU | |
| 144 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Debug|x64.Build.0 = Debug|Any CPU | |
| 145 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Debug|x86.ActiveCfg = Debug|Any CPU | |
| 146 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Debug|x86.Build.0 = Debug|Any CPU | |
| 147 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Release|Any CPU.ActiveCfg = Release|Any CPU | |
| 148 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Release|Any CPU.Build.0 = Release|Any CPU | |
| 149 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Release|x64.ActiveCfg = Release|Any CPU | |
| 150 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Release|x64.Build.0 = Release|Any CPU | |
| 151 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Release|x86.ActiveCfg = Release|Any CPU | |
| 152 | + {75A8984F-7052-4929-A26F-8C430DBCC212}.Release|x86.Build.0 = Release|Any CPU | |
| 153 | + EndGlobalSection | |
| 154 | + GlobalSection(SolutionProperties) = preSolution | |
| 155 | + HideSolutionNode = FALSE | |
| 156 | + EndGlobalSection | |
| 157 | +EndGlobal |
added SplitApp/WebApp/ApiControllers/BudgetCategoriesController.cs +131 −0
| @@ -0,0 +1,131 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.DTO.Mappers; | |
| 5 | +using App.DTO.v1; | |
| 6 | +using Asp.Versioning; | |
| 7 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using System.Net; | |
| 11 | +using System.Security.Claims; | |
| 12 | + | |
| 13 | +namespace WebApp.ApiControllers; | |
| 14 | + | |
| 15 | +[ApiVersion("1.0")] | |
| 16 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 17 | +[ApiController] | |
| 18 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 19 | +public class BudgetCategoriesController : ControllerBase | |
| 20 | +{ | |
| 21 | + private readonly IBudgetCategoryService _budgetCategoryService; | |
| 22 | + private readonly ITripService _tripService; | |
| 23 | + | |
| 24 | + public BudgetCategoriesController(IBudgetCategoryService budgetCategoryService, ITripService tripService) | |
| 25 | + { | |
| 26 | + _budgetCategoryService = budgetCategoryService; | |
| 27 | + _tripService = tripService; | |
| 28 | + } | |
| 29 | + | |
| 30 | + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); | |
| 31 | + | |
| 32 | + // GET: api/v1/budgetcategories/trip/{tripId} | |
| 33 | + [HttpGet("trip/{tripId:guid}")] | |
| 34 | + [Produces("application/json")] | |
| 35 | + [ProducesResponseType<List<BudgetCategoryDto>>((int)HttpStatusCode.OK)] | |
| 36 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 37 | + public async Task<ActionResult<List<BudgetCategoryDto>>> GetTripBudgetCategories(Guid tripId) | |
| 38 | + { | |
| 39 | + var userId = GetUserId(); | |
| 40 | + | |
| 41 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 42 | + | |
| 43 | + var categories = await _budgetCategoryService.GetByTripIdAsync(tripId, userId); | |
| 44 | + | |
| 45 | + return Ok(categories.Select(BudgetCategoryMapper.MapToDto).ToList()); | |
| 46 | + } | |
| 47 | + | |
| 48 | + // POST: api/v1/budgetcategories | |
| 49 | + [HttpPost] | |
| 50 | + [Produces("application/json")] | |
| 51 | + [Consumes("application/json")] | |
| 52 | + [ProducesResponseType<BudgetCategoryDto>((int)HttpStatusCode.Created)] | |
| 53 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 54 | + public async Task<ActionResult<BudgetCategoryDto>> CreateBudgetCategory([FromBody] BudgetCategoryCreateDto dto) | |
| 55 | + { | |
| 56 | + var userId = GetUserId(); | |
| 57 | + | |
| 58 | + var category = new BudgetCategoryBllDto | |
| 59 | + { | |
| 60 | + TripId = dto.TripId, | |
| 61 | + Name = dto.Name, | |
| 62 | + IconName = dto.IconName, | |
| 63 | + PlannedAmount = dto.PlannedAmount, | |
| 64 | + DisplayOrder = dto.DisplayOrder | |
| 65 | + }; | |
| 66 | + | |
| 67 | + var (created, errorCode) = await _budgetCategoryService.CreateAsync(category, userId); | |
| 68 | + if (created == null) | |
| 69 | + { | |
| 70 | + if (errorCode == "forbidden") return Forbid(); | |
| 71 | + return NotFound(); | |
| 72 | + } | |
| 73 | + | |
| 74 | + return CreatedAtAction(null, new { id = created.Id }, BudgetCategoryMapper.MapToDto(created)); | |
| 75 | + } | |
| 76 | + | |
| 77 | + // PUT: api/v1/budgetcategories/{id} | |
| 78 | + [HttpPut("{id:guid}")] | |
| 79 | + [Consumes("application/json")] | |
| 80 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 81 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 82 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 83 | + public async Task<IActionResult> UpdateBudgetCategory(Guid id, [FromBody] BudgetCategoryCreateDto dto) | |
| 84 | + { | |
| 85 | + var userId = GetUserId(); | |
| 86 | + | |
| 87 | + var incoming = new BudgetCategoryBllDto | |
| 88 | + { | |
| 89 | + Name = dto.Name, | |
| 90 | + IconName = dto.IconName, | |
| 91 | + PlannedAmount = dto.PlannedAmount, | |
| 92 | + DisplayOrder = dto.DisplayOrder | |
| 93 | + }; | |
| 94 | + | |
| 95 | + var (ok, errorCode) = await _budgetCategoryService.UpdateAsync(id, incoming, userId); | |
| 96 | + if (!ok) | |
| 97 | + { | |
| 98 | + return errorCode switch | |
| 99 | + { | |
| 100 | + "notfound" => NotFound(), | |
| 101 | + "forbidden" => Forbid(), | |
| 102 | + _ => NotFound() | |
| 103 | + }; | |
| 104 | + } | |
| 105 | + | |
| 106 | + return NoContent(); | |
| 107 | + } | |
| 108 | + | |
| 109 | + // DELETE: api/v1/budgetcategories/{id} | |
| 110 | + [HttpDelete("{id:guid}")] | |
| 111 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 112 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 113 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 114 | + public async Task<IActionResult> DeleteBudgetCategory(Guid id) | |
| 115 | + { | |
| 116 | + var userId = GetUserId(); | |
| 117 | + | |
| 118 | + var (ok, errorCode) = await _budgetCategoryService.DeleteAsync(id, userId); | |
| 119 | + if (!ok) | |
| 120 | + { | |
| 121 | + return errorCode switch | |
| 122 | + { | |
| 123 | + "notfound" => NotFound(), | |
| 124 | + "forbidden" => Forbid(), | |
| 125 | + _ => NotFound() | |
| 126 | + }; | |
| 127 | + } | |
| 128 | + | |
| 129 | + return NoContent(); | |
| 130 | + } | |
| 131 | +} |
added SplitApp/WebApp/ApiControllers/CurrenciesController.cs +35 −0
| @@ -0,0 +1,35 @@ | ||
| 1 | +using App.BLL.Services; | |
| 2 | +using App.DTO.Mappers; | |
| 3 | +using App.DTO.v1; | |
| 4 | +using Asp.Versioning; | |
| 5 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 6 | +using Microsoft.AspNetCore.Authorization; | |
| 7 | +using Microsoft.AspNetCore.Mvc; | |
| 8 | +using System.Net; | |
| 9 | + | |
| 10 | +namespace WebApp.ApiControllers; | |
| 11 | + | |
| 12 | +[ApiVersion("1.0")] | |
| 13 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 14 | +[ApiController] | |
| 15 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 16 | +public class CurrenciesController : ControllerBase | |
| 17 | +{ | |
| 18 | + private readonly ITripService _tripService; | |
| 19 | + | |
| 20 | + public CurrenciesController(ITripService tripService) | |
| 21 | + { | |
| 22 | + _tripService = tripService; | |
| 23 | + } | |
| 24 | + | |
| 25 | + // GET: api/v1/currencies | |
| 26 | + [HttpGet] | |
| 27 | + [AllowAnonymous] | |
| 28 | + [Produces("application/json")] | |
| 29 | + [ProducesResponseType<List<CurrencyDto>>((int)HttpStatusCode.OK)] | |
| 30 | + public async Task<ActionResult<List<CurrencyDto>>> GetCurrencies() | |
| 31 | + { | |
| 32 | + var currencies = await _tripService.GetAllCurrenciesAsync(); | |
| 33 | + return Ok(currencies.Select(CurrencyMapper.MapToDto).ToList()); | |
| 34 | + } | |
| 35 | +} |
added SplitApp/WebApp/ApiControllers/ExpensesController.cs +187 −0
| @@ -0,0 +1,187 @@ | ||
| 1 | +using App.BLL.Helpers; | |
| 2 | +using App.BLL.DTO; | |
| 3 | +using App.BLL.Services; | |
| 4 | +using App.Domain; | |
| 5 | +using App.DTO.Mappers; | |
| 6 | +using App.DTO.v1; | |
| 7 | +using Asp.Versioning; | |
| 8 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 9 | +using Microsoft.AspNetCore.Authorization; | |
| 10 | +using Microsoft.AspNetCore.Mvc; | |
| 11 | +using System.Net; | |
| 12 | +using System.Security.Claims; | |
| 13 | + | |
| 14 | +namespace WebApp.ApiControllers; | |
| 15 | + | |
| 16 | +[ApiVersion("1.0")] | |
| 17 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 18 | +[ApiController] | |
| 19 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 20 | +public class ExpensesController : ControllerBase | |
| 21 | +{ | |
| 22 | + private readonly IExpenseService _expenseService; | |
| 23 | + private readonly ITripService _tripService; | |
| 24 | + | |
| 25 | + public ExpensesController(IExpenseService expenseService, ITripService tripService) | |
| 26 | + { | |
| 27 | + _expenseService = expenseService; | |
| 28 | + _tripService = tripService; | |
| 29 | + } | |
| 30 | + | |
| 31 | + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); | |
| 32 | + | |
| 33 | + // GET: api/v1/expenses/trip/{tripId} | |
| 34 | + [HttpGet("trip/{tripId:guid}")] | |
| 35 | + [Produces("application/json")] | |
| 36 | + [ProducesResponseType<List<ExpenseDto>>((int)HttpStatusCode.OK)] | |
| 37 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 38 | + public async Task<ActionResult<List<ExpenseDto>>> GetTripExpenses(Guid tripId) | |
| 39 | + { | |
| 40 | + var userId = GetUserId(); | |
| 41 | + | |
| 42 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 43 | + | |
| 44 | + var trip = await _tripService.GetRawByIdAsync(tripId); | |
| 45 | + var tripDefaultCode = trip?.DefaultCurrency?.Code; | |
| 46 | + | |
| 47 | + var expenses = await _expenseService.GetByTripIdAsync(tripId, userId); | |
| 48 | + | |
| 49 | + return Ok(expenses.Select(e => WithTripConversion(ExpenseMapper.MapToDto(e), e.Currency?.Code, tripDefaultCode)).ToList()); | |
| 50 | + } | |
| 51 | + | |
| 52 | + private static ExpenseDto WithTripConversion(ExpenseDto dto, string? fromCode, string? tripDefaultCode) | |
| 53 | + { | |
| 54 | + if (fromCode != null && tripDefaultCode != null) | |
| 55 | + dto.AmountInTripCurrency = CurrencyConverter.Convert(dto.Amount, fromCode, tripDefaultCode); | |
| 56 | + return dto; | |
| 57 | + } | |
| 58 | + | |
| 59 | + // POST: api/v1/expenses | |
| 60 | + [HttpPost] | |
| 61 | + [Produces("application/json")] | |
| 62 | + [Consumes("application/json")] | |
| 63 | + [ProducesResponseType<ExpenseDto>((int)HttpStatusCode.Created)] | |
| 64 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 65 | + public async Task<ActionResult<ExpenseDto>> CreateExpense([FromBody] ExpenseCreateDto dto) | |
| 66 | + { | |
| 67 | + var userId = GetUserId(); | |
| 68 | + | |
| 69 | + if (!await _tripService.IsParticipantAsync(dto.TripId, userId)) return Forbid(); | |
| 70 | + | |
| 71 | + var payerId = dto.PaidByUserId ?? userId; | |
| 72 | + if (payerId != userId && !await _tripService.IsParticipantAsync(dto.TripId, payerId)) | |
| 73 | + return BadRequest("Selected payer is not a participant of this trip."); | |
| 74 | + | |
| 75 | + var trip = await _tripService.GetRawByIdAsync(dto.TripId); | |
| 76 | + if (trip == null) return NotFound(); | |
| 77 | + if (trip.Status != ETripStatus.Active) | |
| 78 | + return BadRequest("Cannot add expenses to a settled trip."); | |
| 79 | + | |
| 80 | + var expense = new ExpenseBllDto | |
| 81 | + { | |
| 82 | + TripId = dto.TripId, | |
| 83 | + PaidByUserId = payerId, | |
| 84 | + BudgetCategoryId = dto.BudgetCategoryId, | |
| 85 | + CurrencyId = dto.CurrencyId, | |
| 86 | + Amount = dto.Amount, | |
| 87 | + Description = dto.Description, | |
| 88 | + ExpenseDate = dto.ExpenseDate, | |
| 89 | + SplitMethod = Enum.Parse<ESplitMethod>(dto.SplitMethod) | |
| 90 | + }; | |
| 91 | + | |
| 92 | + var participants = dto.Splits?.Select(s => s.UserId).ToArray() ?? Array.Empty<Guid>(); | |
| 93 | + var amounts = dto.Splits?.Select(s => s.Amount).ToArray() ?? Array.Empty<decimal>(); | |
| 94 | + var percentages = dto.Splits?.Select(s => s.Percentage ?? 0m).ToArray() ?? Array.Empty<decimal>(); | |
| 95 | + | |
| 96 | + var created = await _expenseService.CreateExpenseWithSplitsAsync(expense, participants, amounts, percentages); | |
| 97 | + | |
| 98 | + var reloaded = await _expenseService.GetByIdWithDetailsAsync(created.Id, userId); | |
| 99 | + | |
| 100 | + var tripDefaultCode = trip.DefaultCurrency?.Code; | |
| 101 | + return CreatedAtAction(nameof(GetExpense), new { id = reloaded!.Id }, | |
| 102 | + WithTripConversion(ExpenseMapper.MapToDto(reloaded), reloaded.Currency?.Code, tripDefaultCode)); | |
| 103 | + } | |
| 104 | + | |
| 105 | + // GET: api/v1/expenses/{id} | |
| 106 | + [HttpGet("{id:guid}")] | |
| 107 | + [Produces("application/json")] | |
| 108 | + [ProducesResponseType<ExpenseDto>((int)HttpStatusCode.OK)] | |
| 109 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 110 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 111 | + public async Task<ActionResult<ExpenseDto>> GetExpense(Guid id) | |
| 112 | + { | |
| 113 | + var userId = GetUserId(); | |
| 114 | + | |
| 115 | + var raw = await _expenseService.GetRawByIdAsync(id); | |
| 116 | + if (raw == null) return NotFound(); | |
| 117 | + | |
| 118 | + var expense = await _expenseService.GetByIdWithDetailsAsync(id, userId); | |
| 119 | + if (expense == null) return Forbid(); | |
| 120 | + | |
| 121 | + var trip = await _tripService.GetRawByIdAsync(expense.TripId); | |
| 122 | + var tripDefaultCode = trip?.DefaultCurrency?.Code; | |
| 123 | + | |
| 124 | + return Ok(WithTripConversion(ExpenseMapper.MapToDto(expense), expense.Currency?.Code, tripDefaultCode)); | |
| 125 | + } | |
| 126 | + | |
| 127 | + // PUT: api/v1/expenses/{id} | |
| 128 | + [HttpPut("{id:guid}")] | |
| 129 | + [Consumes("application/json")] | |
| 130 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 131 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 132 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 133 | + public async Task<IActionResult> UpdateExpense(Guid id, [FromBody] ExpenseCreateDto dto) | |
| 134 | + { | |
| 135 | + var userId = GetUserId(); | |
| 136 | + | |
| 137 | + var splits = dto.Splits?.Select(s => (s.UserId, s.Amount, s.Percentage)).ToList() | |
| 138 | + ?? new List<(Guid UserId, decimal Amount, decimal? Percentage)>(); | |
| 139 | + | |
| 140 | + var (ok, errorCode) = await _expenseService.UpdateExpenseWithSplitsFromDtoAsync( | |
| 141 | + id, | |
| 142 | + dto.Amount, | |
| 143 | + dto.Description, | |
| 144 | + dto.ExpenseDate, | |
| 145 | + Enum.Parse<ESplitMethod>(dto.SplitMethod), | |
| 146 | + dto.BudgetCategoryId, | |
| 147 | + dto.CurrencyId, | |
| 148 | + splits, | |
| 149 | + userId); | |
| 150 | + | |
| 151 | + if (!ok) | |
| 152 | + { | |
| 153 | + return errorCode switch | |
| 154 | + { | |
| 155 | + "notfound" => NotFound(), | |
| 156 | + "forbidden" => Forbid(), | |
| 157 | + "badstatus" => BadRequest("Cannot edit expenses on a settled trip."), | |
| 158 | + _ => NotFound() | |
| 159 | + }; | |
| 160 | + } | |
| 161 | + | |
| 162 | + return NoContent(); | |
| 163 | + } | |
| 164 | + | |
| 165 | + // DELETE: api/v1/expenses/{id} | |
| 166 | + [HttpDelete("{id:guid}")] | |
| 167 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 168 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 169 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 170 | + public async Task<IActionResult> DeleteExpense(Guid id) | |
| 171 | + { | |
| 172 | + var userId = GetUserId(); | |
| 173 | + | |
| 174 | + var expense = await _expenseService.GetRawByIdAsync(id); | |
| 175 | + if (expense == null) return NotFound(); | |
| 176 | + | |
| 177 | + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid(); | |
| 178 | + | |
| 179 | + var trip = await _tripService.GetRawByIdAsync(expense.TripId); | |
| 180 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 181 | + return BadRequest("Cannot delete expenses on a settled trip."); | |
| 182 | + | |
| 183 | + await _expenseService.DeleteExpenseWithSplitsAsync(id); | |
| 184 | + | |
| 185 | + return NoContent(); | |
| 186 | + } | |
| 187 | +} |
added SplitApp/WebApp/ApiControllers/Identity/AccountController.cs +145 −0
| @@ -0,0 +1,145 @@ | ||
| 1 | +using System.Net; | |
| 2 | +using System.Security.Claims; | |
| 3 | +using App.BLL.Services.Identity; | |
| 4 | +using App.DTO.v1; | |
| 5 | +using App.DTO.v1.Identity; | |
| 6 | +using Asp.Versioning; | |
| 7 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | + | |
| 11 | +namespace WebApp.ApiControllers.Identity; | |
| 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/WebApp/ApiControllers/InvitationsController.cs +137 −0
| @@ -0,0 +1,137 @@ | ||
| 1 | +using App.BLL.Services; | |
| 2 | +using App.DTO.Mappers; | |
| 3 | +using App.DTO.v1; | |
| 4 | +using Asp.Versioning; | |
| 5 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 6 | +using Microsoft.AspNetCore.Authorization; | |
| 7 | +using Microsoft.AspNetCore.Mvc; | |
| 8 | +using System.Net; | |
| 9 | +using System.Security.Claims; | |
| 10 | + | |
| 11 | +namespace WebApp.ApiControllers; | |
| 12 | + | |
| 13 | +[ApiVersion("1.0")] | |
| 14 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 15 | +[ApiController] | |
| 16 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 17 | +public class InvitationsController : ControllerBase | |
| 18 | +{ | |
| 19 | + private readonly IInvitationService _invitationService; | |
| 20 | + | |
| 21 | + public InvitationsController(IInvitationService invitationService) | |
| 22 | + { | |
| 23 | + _invitationService = invitationService; | |
| 24 | + } | |
| 25 | + | |
| 26 | + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); | |
| 27 | + | |
| 28 | + // POST: api/v1/invitations | |
| 29 | + [HttpPost] | |
| 30 | + [Produces("application/json")] | |
| 31 | + [Consumes("application/json")] | |
| 32 | + [ProducesResponseType<InvitationDto>((int)HttpStatusCode.Created)] | |
| 33 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 34 | + public async Task<ActionResult<InvitationDto>> CreateInvitation([FromBody] InvitationCreateDto dto) | |
| 35 | + { | |
| 36 | + var userId = GetUserId(); | |
| 37 | + | |
| 38 | + var (invitation, errorCode) = await _invitationService.CreateInvitationGuardedAsync(dto.TripId, userId); | |
| 39 | + if (invitation == null) | |
| 40 | + { | |
| 41 | + if (errorCode == "forbidden") return Forbid(); | |
| 42 | + return NotFound(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + // Reload with navigation properties | |
| 46 | + var reloaded = await _invitationService.GetByTokenAsync(invitation.Token); | |
| 47 | + | |
| 48 | + return CreatedAtAction(nameof(GetInvitation), new { token = reloaded!.Token }, InvitationMapper.MapToDto(reloaded)); | |
| 49 | + } | |
| 50 | + | |
| 51 | + // GET: api/v1/invitations/{token} | |
| 52 | + [HttpGet("{token}")] | |
| 53 | + [AllowAnonymous] | |
| 54 | + [Produces("application/json")] | |
| 55 | + [ProducesResponseType<InvitationDto>((int)HttpStatusCode.OK)] | |
| 56 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 57 | + public async Task<ActionResult<InvitationDto>> GetInvitation(string token) | |
| 58 | + { | |
| 59 | + var invitation = await _invitationService.GetByTokenAsync(token); | |
| 60 | + if (invitation == null) return NotFound(); | |
| 61 | + | |
| 62 | + return Ok(InvitationMapper.MapToDto(invitation)); | |
| 63 | + } | |
| 64 | + | |
| 65 | + // POST: api/v1/invitations/{token}/accept | |
| 66 | + [HttpPost("{token}/accept")] | |
| 67 | + [Produces("application/json")] | |
| 68 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 69 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 70 | + [ProducesResponseType((int)HttpStatusCode.BadRequest)] | |
| 71 | + public async Task<IActionResult> AcceptInvitation(string token) | |
| 72 | + { | |
| 73 | + var userId = GetUserId(); | |
| 74 | + | |
| 75 | + var (ok, errorCode) = await _invitationService.AcceptInvitationGuardedAsync(token, userId); | |
| 76 | + if (!ok) | |
| 77 | + { | |
| 78 | + return errorCode switch | |
| 79 | + { | |
| 80 | + "notfound" => NotFound(), | |
| 81 | + "not-pending" => BadRequest("Invitation is no longer pending."), | |
| 82 | + "expired" => BadRequest("Invitation has expired."), | |
| 83 | + "already-participant" => BadRequest("You are already a participant in this trip."), | |
| 84 | + "failed" => BadRequest("Could not accept invitation."), | |
| 85 | + _ => BadRequest("Could not accept invitation.") | |
| 86 | + }; | |
| 87 | + } | |
| 88 | + | |
| 89 | + return Ok(); | |
| 90 | + } | |
| 91 | + | |
| 92 | + // POST: api/v1/invitations/{token}/decline | |
| 93 | + [HttpPost("{token}/decline")] | |
| 94 | + [Produces("application/json")] | |
| 95 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 96 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 97 | + [ProducesResponseType((int)HttpStatusCode.BadRequest)] | |
| 98 | + public async Task<IActionResult> DeclineInvitation(string token) | |
| 99 | + { | |
| 100 | + var (ok, errorCode) = await _invitationService.DeclineInvitationAsync(token); | |
| 101 | + if (!ok) | |
| 102 | + { | |
| 103 | + return errorCode switch | |
| 104 | + { | |
| 105 | + "notfound" => NotFound(), | |
| 106 | + "not-pending" => BadRequest("Invitation is no longer pending."), | |
| 107 | + _ => BadRequest() | |
| 108 | + }; | |
| 109 | + } | |
| 110 | + | |
| 111 | + return Ok(); | |
| 112 | + } | |
| 113 | + | |
| 114 | + // POST: api/v1/invitations/{token}/revoke | |
| 115 | + [HttpPost("{token}/revoke")] | |
| 116 | + [Produces("application/json")] | |
| 117 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 118 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 119 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 120 | + public async Task<IActionResult> RevokeInvitation(string token) | |
| 121 | + { | |
| 122 | + var userId = GetUserId(); | |
| 123 | + | |
| 124 | + var (ok, errorCode) = await _invitationService.RevokeInvitationByTokenAsync(token, userId); | |
| 125 | + if (!ok) | |
| 126 | + { | |
| 127 | + return errorCode switch | |
| 128 | + { | |
| 129 | + "notfound" => NotFound(), | |
| 130 | + "forbidden" => Forbid(), | |
| 131 | + _ => NotFound() | |
| 132 | + }; | |
| 133 | + } | |
| 134 | + | |
| 135 | + return Ok(); | |
| 136 | + } | |
| 137 | +} |
added SplitApp/WebApp/ApiControllers/PollsController.cs +181 −0
| @@ -0,0 +1,181 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.DTO.Mappers; | |
| 5 | +using App.DTO.v1; | |
| 6 | +using Asp.Versioning; | |
| 7 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using System.Net; | |
| 11 | +using System.Security.Claims; | |
| 12 | + | |
| 13 | +namespace WebApp.ApiControllers; | |
| 14 | + | |
| 15 | +[ApiVersion("1.0")] | |
| 16 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 17 | +[ApiController] | |
| 18 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 19 | +public class PollsController : ControllerBase | |
| 20 | +{ | |
| 21 | + private readonly IPollService _pollService; | |
| 22 | + private readonly ITripService _tripService; | |
| 23 | + | |
| 24 | + public PollsController(IPollService pollService, ITripService tripService) | |
| 25 | + { | |
| 26 | + _pollService = pollService; | |
| 27 | + _tripService = tripService; | |
| 28 | + } | |
| 29 | + | |
| 30 | + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); | |
| 31 | + | |
| 32 | + public class VoteRequest | |
| 33 | + { | |
| 34 | + public Guid OptionId { get; set; } | |
| 35 | + } | |
| 36 | + | |
| 37 | + // GET: api/v1/polls/trip/{tripId} | |
| 38 | + [HttpGet("trip/{tripId:guid}")] | |
| 39 | + [Produces("application/json")] | |
| 40 | + [ProducesResponseType<List<PollDto>>((int)HttpStatusCode.OK)] | |
| 41 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 42 | + public async Task<ActionResult<List<PollDto>>> GetTripPolls(Guid tripId) | |
| 43 | + { | |
| 44 | + var userId = GetUserId(); | |
| 45 | + | |
| 46 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 47 | + | |
| 48 | + var polls = await _pollService.GetByTripIdAsync(tripId, userId); | |
| 49 | + | |
| 50 | + return Ok(polls.Select(p => PollMapper.MapToDto(p, userId)).ToList()); | |
| 51 | + } | |
| 52 | + | |
| 53 | + // POST: api/v1/polls | |
| 54 | + [HttpPost] | |
| 55 | + [Produces("application/json")] | |
| 56 | + [Consumes("application/json")] | |
| 57 | + [ProducesResponseType<PollDto>((int)HttpStatusCode.Created)] | |
| 58 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 59 | + public async Task<ActionResult<PollDto>> CreatePoll([FromBody] PollCreateDto dto) | |
| 60 | + { | |
| 61 | + var userId = GetUserId(); | |
| 62 | + | |
| 63 | + var poll = new TripPollBllDto | |
| 64 | + { | |
| 65 | + TripId = dto.TripId, | |
| 66 | + CreatedByUserId = userId, | |
| 67 | + Question = dto.Question, | |
| 68 | + AllowMultipleVotes = dto.AllowMultipleVotes, | |
| 69 | + IsAnonymous = dto.IsAnonymous | |
| 70 | + }; | |
| 71 | + | |
| 72 | + var (created, errorCode) = await _pollService.CreatePollGuardedAsync(poll, dto.Options, userId); | |
| 73 | + if (created == null) | |
| 74 | + { | |
| 75 | + if (errorCode == "forbidden") return Forbid(); | |
| 76 | + return NotFound(); | |
| 77 | + } | |
| 78 | + | |
| 79 | + // Reload with navigation properties | |
| 80 | + var reloaded = await _pollService.GetByIdWithDetailsAsync(created.Id, userId); | |
| 81 | + | |
| 82 | + return CreatedAtAction(nameof(GetPoll), new { id = reloaded!.Id }, PollMapper.MapToDto(reloaded, userId)); | |
| 83 | + } | |
| 84 | + | |
| 85 | + // GET: api/v1/polls/{id} | |
| 86 | + [HttpGet("{id:guid}")] | |
| 87 | + [Produces("application/json")] | |
| 88 | + [ProducesResponseType<PollDto>((int)HttpStatusCode.OK)] | |
| 89 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 90 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 91 | + public async Task<ActionResult<PollDto>> GetPoll(Guid id) | |
| 92 | + { | |
| 93 | + var userId = GetUserId(); | |
| 94 | + | |
| 95 | + var poll = await _pollService.GetByIdWithDetailsAsync(id, userId); | |
| 96 | + if (poll == null) | |
| 97 | + { | |
| 98 | + // distinguish not-found vs forbidden | |
| 99 | + // No helper exposes a raw getter through service; attempt via GetByIdAsync(id, Guid.Empty) returns null for both, | |
| 100 | + // so fall back to NotFound (treat both as NotFound is acceptable for API IDOR-hardening). | |
| 101 | + return NotFound(); | |
| 102 | + } | |
| 103 | + | |
| 104 | + return Ok(PollMapper.MapToDto(poll, userId)); | |
| 105 | + } | |
| 106 | + | |
| 107 | + // POST: api/v1/polls/{id}/vote | |
| 108 | + [HttpPost("{id:guid}/vote")] | |
| 109 | + [Consumes("application/json")] | |
| 110 | + [Produces("application/json")] | |
| 111 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 112 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 113 | + [ProducesResponseType((int)HttpStatusCode.BadRequest)] | |
| 114 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 115 | + public async Task<IActionResult> CastVote(Guid id, [FromBody] VoteRequest request) | |
| 116 | + { | |
| 117 | + var userId = GetUserId(); | |
| 118 | + | |
| 119 | + var (ok, errorCode) = await _pollService.CastVoteAsync(id, request.OptionId, userId); | |
| 120 | + if (!ok) | |
| 121 | + { | |
| 122 | + return errorCode switch | |
| 123 | + { | |
| 124 | + "notfound" => NotFound(), | |
| 125 | + "closed" => BadRequest("Poll is closed."), | |
| 126 | + "forbidden" => Forbid(), | |
| 127 | + "invalid-option" => BadRequest("Invalid option."), | |
| 128 | + _ => NotFound() | |
| 129 | + }; | |
| 130 | + } | |
| 131 | + | |
| 132 | + return Ok(); | |
| 133 | + } | |
| 134 | + | |
| 135 | + // POST: api/v1/polls/{id}/close | |
| 136 | + [HttpPost("{id:guid}/close")] | |
| 137 | + [Produces("application/json")] | |
| 138 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 139 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 140 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 141 | + public async Task<IActionResult> ClosePoll(Guid id) | |
| 142 | + { | |
| 143 | + var userId = GetUserId(); | |
| 144 | + | |
| 145 | + var (ok, errorCode) = await _pollService.ClosePollAsync(id, userId, organizerAllowed: true); | |
| 146 | + if (!ok) | |
| 147 | + { | |
| 148 | + return errorCode switch | |
| 149 | + { | |
| 150 | + "notfound" => NotFound(), | |
| 151 | + "forbidden" => Forbid(), | |
| 152 | + _ => NotFound() | |
| 153 | + }; | |
| 154 | + } | |
| 155 | + | |
| 156 | + return Ok(); | |
| 157 | + } | |
| 158 | + | |
| 159 | + // DELETE: api/v1/polls/{id} | |
| 160 | + [HttpDelete("{id:guid}")] | |
| 161 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 162 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 163 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 164 | + public async Task<IActionResult> DeletePoll(Guid id) | |
| 165 | + { | |
| 166 | + var userId = GetUserId(); | |
| 167 | + | |
| 168 | + var (ok, errorCode) = await _pollService.DeletePollGuardedAsync(id, userId); | |
| 169 | + if (!ok) | |
| 170 | + { | |
| 171 | + return errorCode switch | |
| 172 | + { | |
| 173 | + "notfound" => NotFound(), | |
| 174 | + "forbidden" => Forbid(), | |
| 175 | + _ => NotFound() | |
| 176 | + }; | |
| 177 | + } | |
| 178 | + | |
| 179 | + return NoContent(); | |
| 180 | + } | |
| 181 | +} |
added SplitApp/WebApp/ApiControllers/SettlementsController.cs +153 −0
| @@ -0,0 +1,153 @@ | ||
| 1 | +using App.BLL.Services; | |
| 2 | +using App.DTO.Mappers; | |
| 3 | +using App.DTO.v1; | |
| 4 | +using Asp.Versioning; | |
| 5 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 6 | +using Microsoft.AspNetCore.Authorization; | |
| 7 | +using Microsoft.AspNetCore.Mvc; | |
| 8 | +using System.Net; | |
| 9 | +using System.Security.Claims; | |
| 10 | + | |
| 11 | +namespace WebApp.ApiControllers; | |
| 12 | + | |
| 13 | +[ApiVersion("1.0")] | |
| 14 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 15 | +[ApiController] | |
| 16 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 17 | +public class SettlementsController : ControllerBase | |
| 18 | +{ | |
| 19 | + private readonly ISettlementService _settlementService; | |
| 20 | + private readonly ITripService _tripService; | |
| 21 | + | |
| 22 | + public SettlementsController(ISettlementService settlementService, ITripService tripService) | |
| 23 | + { | |
| 24 | + _settlementService = settlementService; | |
| 25 | + _tripService = tripService; | |
| 26 | + } | |
| 27 | + | |
| 28 | + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); | |
| 29 | + | |
| 30 | + // GET: api/v1/settlements/trip/{tripId} | |
| 31 | + [HttpGet("trip/{tripId:guid}")] | |
| 32 | + [Produces("application/json")] | |
| 33 | + [ProducesResponseType<SettlementPlanDto>((int)HttpStatusCode.OK)] | |
| 34 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 35 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 36 | + public async Task<ActionResult<SettlementPlanDto>> GetLatestPlan(Guid tripId) | |
| 37 | + { | |
| 38 | + var userId = GetUserId(); | |
| 39 | + | |
| 40 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 41 | + | |
| 42 | + var plan = await _settlementService.GetLatestPlanRawAsync(tripId); | |
| 43 | + if (plan == null) return NotFound(); | |
| 44 | + | |
| 45 | + return Ok(SettlementMapper.MapToDto(plan)); | |
| 46 | + } | |
| 47 | + | |
| 48 | + // GET: api/v1/settlements/trip/{tripId}/summary | |
| 49 | + [HttpGet("trip/{tripId:guid}/summary")] | |
| 50 | + [Produces("application/json")] | |
| 51 | + [ProducesResponseType<SettlementSummaryDto>((int)HttpStatusCode.OK)] | |
| 52 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 53 | + public async Task<ActionResult<SettlementSummaryDto>> GetSettlementSummary(Guid tripId) | |
| 54 | + { | |
| 55 | + var userId = GetUserId(); | |
| 56 | + | |
| 57 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 58 | + | |
| 59 | + // Calculate balances through service | |
| 60 | + var balanceEntries = await _settlementService.CalculateBalancesAsync(tripId); | |
| 61 | + var balances = balanceEntries.Select(b => new BalanceDto | |
| 62 | + { | |
| 63 | + UserId = b.UserId, | |
| 64 | + UserName = b.UserName, | |
| 65 | + Balance = b.NetBalance | |
| 66 | + }).ToList(); | |
| 67 | + | |
| 68 | + // Get latest plan | |
| 69 | + var plan = await _settlementService.GetLatestPlanRawAsync(tripId); | |
| 70 | + | |
| 71 | + return Ok(new SettlementSummaryDto | |
| 72 | + { | |
| 73 | + Balances = balances, | |
| 74 | + LatestPlan = plan != null ? SettlementMapper.MapToDto(plan) : null | |
| 75 | + }); | |
| 76 | + } | |
| 77 | + | |
| 78 | + // GET: api/v1/settlements/trip/{tripId}/balances | |
| 79 | + [HttpGet("trip/{tripId:guid}/balances")] | |
| 80 | + [Produces("application/json")] | |
| 81 | + [ProducesResponseType<List<BalanceDto>>((int)HttpStatusCode.OK)] | |
| 82 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 83 | + public async Task<ActionResult<List<BalanceDto>>> GetBalances(Guid tripId) | |
| 84 | + { | |
| 85 | + var userId = GetUserId(); | |
| 86 | + | |
| 87 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 88 | + | |
| 89 | + var balanceEntries = await _settlementService.CalculateBalancesAsync(tripId); | |
| 90 | + var result = balanceEntries.Select(b => new BalanceDto | |
| 91 | + { | |
| 92 | + UserId = b.UserId, | |
| 93 | + UserName = b.UserName, | |
| 94 | + Balance = b.NetBalance | |
| 95 | + }).ToList(); | |
| 96 | + | |
| 97 | + return Ok(result); | |
| 98 | + } | |
| 99 | + | |
| 100 | + // POST: api/v1/settlements/payments/{paymentId}/mark-paid | |
| 101 | + [HttpPost("payments/{paymentId:guid}/mark-paid")] | |
| 102 | + [Produces("application/json")] | |
| 103 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 104 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 105 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 106 | + public async Task<IActionResult> MarkPaid(Guid paymentId) | |
| 107 | + { | |
| 108 | + var userId = GetUserId(); | |
| 109 | + | |
| 110 | + var payment = await _settlementService.GetPaymentByIdAsync(paymentId); | |
| 111 | + if (payment == null) return NotFound(); | |
| 112 | + | |
| 113 | + var (ok, errorCode) = await _settlementService.MarkPaidGuardedAsync(paymentId, userId); | |
| 114 | + if (!ok) | |
| 115 | + { | |
| 116 | + return errorCode switch | |
| 117 | + { | |
| 118 | + "forbidden" => Forbid(), | |
| 119 | + "notfound" => NotFound(), | |
| 120 | + _ => NotFound() | |
| 121 | + }; | |
| 122 | + } | |
| 123 | + | |
| 124 | + return Ok(); | |
| 125 | + } | |
| 126 | + | |
| 127 | + // POST: api/v1/settlements/payments/{paymentId}/confirm | |
| 128 | + [HttpPost("payments/{paymentId:guid}/confirm")] | |
| 129 | + [Produces("application/json")] | |
| 130 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 131 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 132 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 133 | + public async Task<IActionResult> ConfirmPayment(Guid paymentId) | |
| 134 | + { | |
| 135 | + var userId = GetUserId(); | |
| 136 | + | |
| 137 | + var payment = await _settlementService.GetPaymentByIdAsync(paymentId); | |
| 138 | + if (payment == null) return NotFound(); | |
| 139 | + | |
| 140 | + var (ok, errorCode) = await _settlementService.ConfirmPaymentGuardedAsync(paymentId, userId); | |
| 141 | + if (!ok) | |
| 142 | + { | |
| 143 | + return errorCode switch | |
| 144 | + { | |
| 145 | + "forbidden" => Forbid(), | |
| 146 | + "notfound" => NotFound(), | |
| 147 | + _ => NotFound() | |
| 148 | + }; | |
| 149 | + } | |
| 150 | + | |
| 151 | + return Ok(); | |
| 152 | + } | |
| 153 | +} |
added SplitApp/WebApp/ApiControllers/SplitPresetsController.cs +144 −0
| @@ -0,0 +1,144 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.DTO.Mappers; | |
| 5 | +using App.DTO.v1; | |
| 6 | +using Asp.Versioning; | |
| 7 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using System.Net; | |
| 11 | +using System.Security.Claims; | |
| 12 | + | |
| 13 | +namespace WebApp.ApiControllers; | |
| 14 | + | |
| 15 | +[ApiVersion("1.0")] | |
| 16 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 17 | +[ApiController] | |
| 18 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 19 | +public class SplitPresetsController : ControllerBase | |
| 20 | +{ | |
| 21 | + private readonly ISplitPresetService _splitPresetService; | |
| 22 | + private readonly ITripService _tripService; | |
| 23 | + | |
| 24 | + public SplitPresetsController(ISplitPresetService splitPresetService, ITripService tripService) | |
| 25 | + { | |
| 26 | + _splitPresetService = splitPresetService; | |
| 27 | + _tripService = tripService; | |
| 28 | + } | |
| 29 | + | |
| 30 | + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); | |
| 31 | + | |
| 32 | + // GET: api/v1/splitpresets/trip/{tripId} | |
| 33 | + [HttpGet("trip/{tripId:guid}")] | |
| 34 | + [Produces("application/json")] | |
| 35 | + [ProducesResponseType<List<SplitPresetDto>>((int)HttpStatusCode.OK)] | |
| 36 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 37 | + public async Task<ActionResult<List<SplitPresetDto>>> GetTripPresets(Guid tripId) | |
| 38 | + { | |
| 39 | + var userId = GetUserId(); | |
| 40 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 41 | + | |
| 42 | + var presets = await _splitPresetService.GetByTripIdAsync(tripId, userId); | |
| 43 | + | |
| 44 | + return Ok(presets.Select(SplitPresetMapper.MapToDto).ToList()); | |
| 45 | + } | |
| 46 | + | |
| 47 | + // GET: api/v1/splitpresets/{id} | |
| 48 | + [HttpGet("{id:guid}")] | |
| 49 | + [Produces("application/json")] | |
| 50 | + [ProducesResponseType<SplitPresetDto>((int)HttpStatusCode.OK)] | |
| 51 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 52 | + public async Task<ActionResult<SplitPresetDto>> GetPreset(Guid id) | |
| 53 | + { | |
| 54 | + var userId = GetUserId(); | |
| 55 | + | |
| 56 | + var preset = await _splitPresetService.GetByIdAsync(id, userId); | |
| 57 | + if (preset == null) return NotFound(); | |
| 58 | + | |
| 59 | + return Ok(SplitPresetMapper.MapToDto(preset)); | |
| 60 | + } | |
| 61 | + | |
| 62 | + // POST: api/v1/splitpresets | |
| 63 | + [HttpPost] | |
| 64 | + [Produces("application/json")] | |
| 65 | + [Consumes("application/json")] | |
| 66 | + [ProducesResponseType<SplitPresetDto>((int)HttpStatusCode.Created)] | |
| 67 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 68 | + public async Task<ActionResult<SplitPresetDto>> CreatePreset([FromBody] SplitPresetCreateDto dto) | |
| 69 | + { | |
| 70 | + var userId = GetUserId(); | |
| 71 | + | |
| 72 | + var preset = new SplitPresetBllDto | |
| 73 | + { | |
| 74 | + TripId = dto.TripId, | |
| 75 | + Name = dto.Name, | |
| 76 | + SplitMethod = Enum.Parse<ESplitMethod>(dto.SplitMethod), | |
| 77 | + CreatedById = userId, | |
| 78 | + }; | |
| 79 | + | |
| 80 | + var members = dto.Members? | |
| 81 | + .Select(m => (m.UserId, m.ShareWeight, m.Percentage)) | |
| 82 | + .ToList() ?? new List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)>(); | |
| 83 | + | |
| 84 | + var (created, errorCode) = await _splitPresetService.CreateAsync(preset, members, userId); | |
| 85 | + if (created == null) | |
| 86 | + { | |
| 87 | + if (errorCode == "forbidden") return Forbid(); | |
| 88 | + return NotFound(); | |
| 89 | + } | |
| 90 | + | |
| 91 | + return CreatedAtAction(nameof(GetPreset), new { id = created.Id }, SplitPresetMapper.MapToDto(created)); | |
| 92 | + } | |
| 93 | + | |
| 94 | + // PUT: api/v1/splitpresets/{id} | |
| 95 | + [HttpPut("{id:guid}")] | |
| 96 | + [Consumes("application/json")] | |
| 97 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 98 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 99 | + public async Task<IActionResult> UpdatePreset(Guid id, [FromBody] SplitPresetCreateDto dto) | |
| 100 | + { | |
| 101 | + var userId = GetUserId(); | |
| 102 | + | |
| 103 | + var members = dto.Members? | |
| 104 | + .Select(m => (m.UserId, m.ShareWeight, m.Percentage)) | |
| 105 | + .ToList() ?? new List<(Guid UserId, decimal? ShareWeight, decimal? Percentage)>(); | |
| 106 | + | |
| 107 | + var (ok, errorCode) = await _splitPresetService.UpdateAsync( | |
| 108 | + id, dto.Name, Enum.Parse<ESplitMethod>(dto.SplitMethod), members, userId); | |
| 109 | + | |
| 110 | + if (!ok) | |
| 111 | + { | |
| 112 | + return errorCode switch | |
| 113 | + { | |
| 114 | + "notfound" => NotFound(), | |
| 115 | + "forbidden" => Forbid(), | |
| 116 | + _ => NotFound() | |
| 117 | + }; | |
| 118 | + } | |
| 119 | + | |
| 120 | + return NoContent(); | |
| 121 | + } | |
| 122 | + | |
| 123 | + // DELETE: api/v1/splitpresets/{id} | |
| 124 | + [HttpDelete("{id:guid}")] | |
| 125 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 126 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 127 | + public async Task<IActionResult> DeletePreset(Guid id) | |
| 128 | + { | |
| 129 | + var userId = GetUserId(); | |
| 130 | + | |
| 131 | + var (ok, errorCode) = await _splitPresetService.DeleteAsync(id, userId); | |
| 132 | + if (!ok) | |
| 133 | + { | |
| 134 | + return errorCode switch | |
| 135 | + { | |
| 136 | + "notfound" => NotFound(), | |
| 137 | + "forbidden" => Forbid(), | |
| 138 | + _ => NotFound() | |
| 139 | + }; | |
| 140 | + } | |
| 141 | + | |
| 142 | + return NoContent(); | |
| 143 | + } | |
| 144 | +} |
added SplitApp/WebApp/ApiControllers/TripsController.cs +243 −0
| @@ -0,0 +1,243 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.DTO.Mappers; | |
| 5 | +using App.DTO.v1; | |
| 6 | +using Asp.Versioning; | |
| 7 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using System.Net; | |
| 11 | +using System.Security.Claims; | |
| 12 | + | |
| 13 | +namespace WebApp.ApiControllers; | |
| 14 | + | |
| 15 | +[ApiVersion("1.0")] | |
| 16 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 17 | +[ApiController] | |
| 18 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 19 | +public class TripsController : ControllerBase | |
| 20 | +{ | |
| 21 | + private readonly ITripService _tripService; | |
| 22 | + | |
| 23 | + public TripsController(ITripService tripService) | |
| 24 | + { | |
| 25 | + _tripService = tripService; | |
| 26 | + } | |
| 27 | + | |
| 28 | + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); | |
| 29 | + | |
| 30 | + // GET: api/v1/trips | |
| 31 | + [HttpGet] | |
| 32 | + [Produces("application/json")] | |
| 33 | + [ProducesResponseType<List<TripDto>>((int)HttpStatusCode.OK)] | |
| 34 | + public async Task<ActionResult<List<TripDto>>> GetTrips() | |
| 35 | + { | |
| 36 | + var userId = GetUserId(); | |
| 37 | + | |
| 38 | + var trips = await _tripService.GetUserTripsAsync(userId); | |
| 39 | + | |
| 40 | + return Ok(trips.Select(t => TripMapper.MapToDto(t, includeParticipants: true)).ToList()); | |
| 41 | + } | |
| 42 | + | |
| 43 | + // POST: api/v1/trips | |
| 44 | + [HttpPost] | |
| 45 | + [Produces("application/json")] | |
| 46 | + [Consumes("application/json")] | |
| 47 | + [ProducesResponseType<TripDto>((int)HttpStatusCode.Created)] | |
| 48 | + public async Task<ActionResult<TripDto>> CreateTrip([FromBody] TripCreateDto dto) | |
| 49 | + { | |
| 50 | + var userId = GetUserId(); | |
| 51 | + | |
| 52 | + var trip = new TripBllDto | |
| 53 | + { | |
| 54 | + Name = dto.Name, | |
| 55 | + Description = dto.Description, | |
| 56 | + Destination = dto.Destination, | |
| 57 | + StartDate = dto.StartDate, | |
| 58 | + EndDate = dto.EndDate, | |
| 59 | + DefaultCurrencyId = dto.DefaultCurrencyId, | |
| 60 | + }; | |
| 61 | + | |
| 62 | + var created = await _tripService.CreateTripAsync(trip, userId); | |
| 63 | + | |
| 64 | + // Reload with navigation properties | |
| 65 | + var reloaded = await _tripService.GetByIdWithDetailsAsync(created.Id, userId); | |
| 66 | + | |
| 67 | + return CreatedAtAction(nameof(GetTrip), new { id = reloaded!.Id }, TripMapper.MapToDto(reloaded)); | |
| 68 | + } | |
| 69 | + | |
| 70 | + // GET: api/v1/trips/{id} | |
| 71 | + [HttpGet("{id:guid}")] | |
| 72 | + [Produces("application/json")] | |
| 73 | + [ProducesResponseType<TripDto>((int)HttpStatusCode.OK)] | |
| 74 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 75 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 76 | + public async Task<ActionResult<TripDto>> GetTrip(Guid id) | |
| 77 | + { | |
| 78 | + var userId = GetUserId(); | |
| 79 | + | |
| 80 | + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId); | |
| 81 | + if (trip == null) | |
| 82 | + { | |
| 83 | + // distinguish "not participant" vs "doesn't exist" | |
| 84 | + if (!await _tripService.IsParticipantAsync(id, userId)) return Forbid(); | |
| 85 | + return NotFound(); | |
| 86 | + } | |
| 87 | + | |
| 88 | + return Ok(TripMapper.MapToDto(trip, includeParticipants: true)); | |
| 89 | + } | |
| 90 | + | |
| 91 | + // PUT: api/v1/trips/{id} | |
| 92 | + [HttpPut("{id:guid}")] | |
| 93 | + [Consumes("application/json")] | |
| 94 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 95 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 96 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 97 | + public async Task<IActionResult> UpdateTrip(Guid id, [FromBody] TripUpdateDto dto) | |
| 98 | + { | |
| 99 | + if (id != dto.Id) return BadRequest(); | |
| 100 | + | |
| 101 | + var userId = GetUserId(); | |
| 102 | + | |
| 103 | + var trip = new TripBllDto | |
| 104 | + { | |
| 105 | + Id = id, | |
| 106 | + Name = dto.Name, | |
| 107 | + Description = dto.Description, | |
| 108 | + Destination = dto.Destination, | |
| 109 | + StartDate = dto.StartDate, | |
| 110 | + EndDate = dto.EndDate, | |
| 111 | + DefaultCurrencyId = dto.DefaultCurrencyId, | |
| 112 | + }; | |
| 113 | + | |
| 114 | + // Preserve existing status if not provided | |
| 115 | + var existing = await _tripService.GetRawByIdAsync(id); | |
| 116 | + if (existing == null) | |
| 117 | + { | |
| 118 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 119 | + return NotFound(); | |
| 120 | + } | |
| 121 | + trip.Status = existing.Status; | |
| 122 | + if (!string.IsNullOrEmpty(dto.Status) && Enum.TryParse<ETripStatus>(dto.Status, out var status)) | |
| 123 | + { | |
| 124 | + trip.Status = status; | |
| 125 | + } | |
| 126 | + | |
| 127 | + var updated = await _tripService.UpdateAsync(trip, userId); | |
| 128 | + if (updated == null) | |
| 129 | + { | |
| 130 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 131 | + return NotFound(); | |
| 132 | + } | |
| 133 | + | |
| 134 | + return NoContent(); | |
| 135 | + } | |
| 136 | + | |
| 137 | + // DELETE: api/v1/trips/{id} | |
| 138 | + [HttpDelete("{id:guid}")] | |
| 139 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 140 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 141 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 142 | + public async Task<IActionResult> DeleteTrip(Guid id) | |
| 143 | + { | |
| 144 | + var userId = GetUserId(); | |
| 145 | + | |
| 146 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 147 | + | |
| 148 | + var success = await _tripService.DeleteAsync(id, userId); | |
| 149 | + if (!success) return NotFound(); | |
| 150 | + | |
| 151 | + return NoContent(); | |
| 152 | + } | |
| 153 | + | |
| 154 | + // GET: api/v1/trips/{tripId}/participants | |
| 155 | + [HttpGet("{tripId:guid}/participants")] | |
| 156 | + [Produces("application/json")] | |
| 157 | + [ProducesResponseType<List<TripParticipantDto>>((int)HttpStatusCode.OK)] | |
| 158 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 159 | + public async Task<ActionResult<List<TripParticipantDto>>> GetParticipants(Guid tripId) | |
| 160 | + { | |
| 161 | + var userId = GetUserId(); | |
| 162 | + | |
| 163 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 164 | + | |
| 165 | + var participants = await _tripService.GetParticipantsAsync(tripId, userId); | |
| 166 | + | |
| 167 | + return Ok(participants.Select(TripMapper.MapParticipantToDto).ToList()); | |
| 168 | + } | |
| 169 | + | |
| 170 | + // POST: api/v1/trips/{id}/finalize | |
| 171 | + [HttpPost("{id:guid}/finalize")] | |
| 172 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 173 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 174 | + [ProducesResponseType((int)HttpStatusCode.BadRequest)] | |
| 175 | + public async Task<IActionResult> FinalizeTrip(Guid id) | |
| 176 | + { | |
| 177 | + var userId = GetUserId(); | |
| 178 | + | |
| 179 | + var (ok, errorCode) = await _tripService.FinalizeTripAsync(id, userId); | |
| 180 | + if (!ok) | |
| 181 | + { | |
| 182 | + return errorCode switch | |
| 183 | + { | |
| 184 | + "forbidden" => Forbid(), | |
| 185 | + "notfound" => NotFound(), | |
| 186 | + "badstatus" => BadRequest("Trip must be active to finalize."), | |
| 187 | + _ => NotFound() | |
| 188 | + }; | |
| 189 | + } | |
| 190 | + | |
| 191 | + return Ok(); | |
| 192 | + } | |
| 193 | + | |
| 194 | + // POST: api/v1/trips/{id}/reopen | |
| 195 | + [HttpPost("{id:guid}/reopen")] | |
| 196 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 197 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 198 | + [ProducesResponseType((int)HttpStatusCode.BadRequest)] | |
| 199 | + public async Task<IActionResult> ReopenTrip(Guid id) | |
| 200 | + { | |
| 201 | + var userId = GetUserId(); | |
| 202 | + | |
| 203 | + var (ok, errorCode) = await _tripService.ReopenTripAsync(id, userId); | |
| 204 | + if (!ok) | |
| 205 | + { | |
| 206 | + return errorCode switch | |
| 207 | + { | |
| 208 | + "forbidden" => Forbid(), | |
| 209 | + "notfound" => NotFound(), | |
| 210 | + "badstatus" => BadRequest("Trip must be settled to reopen."), | |
| 211 | + "payments-confirmed" => BadRequest("Cannot reopen \u2014 some payments are already confirmed."), | |
| 212 | + _ => NotFound() | |
| 213 | + }; | |
| 214 | + } | |
| 215 | + | |
| 216 | + return Ok(); | |
| 217 | + } | |
| 218 | + | |
| 219 | + // DELETE: api/v1/trips/{tripId}/participants/{userId} | |
| 220 | + [HttpDelete("{tripId:guid}/participants/{participantUserId:guid}")] | |
| 221 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 222 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 223 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 224 | + public async Task<IActionResult> RemoveParticipant(Guid tripId, Guid participantUserId) | |
| 225 | + { | |
| 226 | + var userId = GetUserId(); | |
| 227 | + | |
| 228 | + var (ok, errorCode) = await _tripService.RemoveParticipantAsync(tripId, participantUserId, userId); | |
| 229 | + if (!ok) | |
| 230 | + { | |
| 231 | + return errorCode switch | |
| 232 | + { | |
| 233 | + "forbidden" => Forbid(), | |
| 234 | + "notfound" => NotFound(), | |
| 235 | + "organizer" => BadRequest("Cannot remove the organizer"), | |
| 236 | + "self" => BadRequest("Cannot remove yourself"), | |
| 237 | + _ => NotFound() | |
| 238 | + }; | |
| 239 | + } | |
| 240 | + | |
| 241 | + return NoContent(); | |
| 242 | + } | |
| 243 | +} |
added SplitApp/WebApp/ApiControllers/WishlistController.cs +188 −0
| @@ -0,0 +1,188 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.DTO.Mappers; | |
| 5 | +using App.DTO.v1; | |
| 6 | +using Asp.Versioning; | |
| 7 | +using Microsoft.AspNetCore.Authentication.JwtBearer; | |
| 8 | +using Microsoft.AspNetCore.Authorization; | |
| 9 | +using Microsoft.AspNetCore.Mvc; | |
| 10 | +using System.Net; | |
| 11 | +using System.Security.Claims; | |
| 12 | + | |
| 13 | +namespace WebApp.ApiControllers; | |
| 14 | + | |
| 15 | +[ApiVersion("1.0")] | |
| 16 | +[Route("api/v{version:apiVersion}/[controller]")] | |
| 17 | +[ApiController] | |
| 18 | +[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] | |
| 19 | +public class WishlistController : ControllerBase | |
| 20 | +{ | |
| 21 | + private readonly IWishlistService _wishlistService; | |
| 22 | + private readonly ITripService _tripService; | |
| 23 | + | |
| 24 | + public WishlistController(IWishlistService wishlistService, ITripService tripService) | |
| 25 | + { | |
| 26 | + _wishlistService = wishlistService; | |
| 27 | + _tripService = tripService; | |
| 28 | + } | |
| 29 | + | |
| 30 | + private Guid GetUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); | |
| 31 | + | |
| 32 | + // GET: api/v1/wishlist/trip/{tripId} | |
| 33 | + [HttpGet("trip/{tripId:guid}")] | |
| 34 | + [Produces("application/json")] | |
| 35 | + [ProducesResponseType<List<WishlistItemDto>>((int)HttpStatusCode.OK)] | |
| 36 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 37 | + public async Task<ActionResult<List<WishlistItemDto>>> GetTripWishlistItems(Guid tripId) | |
| 38 | + { | |
| 39 | + var userId = GetUserId(); | |
| 40 | + | |
| 41 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 42 | + | |
| 43 | + var items = await _wishlistService.GetByTripIdAsync(tripId, userId); | |
| 44 | + | |
| 45 | + return Ok(items.Select(i => WishlistMapper.MapToDto(i, userId)).ToList()); | |
| 46 | + } | |
| 47 | + | |
| 48 | + // POST: api/v1/wishlist | |
| 49 | + [HttpPost] | |
| 50 | + [Produces("application/json")] | |
| 51 | + [Consumes("application/json")] | |
| 52 | + [ProducesResponseType<WishlistItemDto>((int)HttpStatusCode.Created)] | |
| 53 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 54 | + public async Task<ActionResult<WishlistItemDto>> CreateWishlistItem([FromBody] WishlistItemCreateDto dto) | |
| 55 | + { | |
| 56 | + var userId = GetUserId(); | |
| 57 | + | |
| 58 | + var item = new TripWishlistItemBllDto | |
| 59 | + { | |
| 60 | + TripId = dto.TripId, | |
| 61 | + AddedByUserId = userId, | |
| 62 | + Title = dto.Title, | |
| 63 | + Description = dto.Description, | |
| 64 | + Category = Enum.Parse<EWishlistCategory>(dto.Category), | |
| 65 | + Priority = Enum.Parse<EWishlistPriority>(dto.Priority), | |
| 66 | + EstimatedCost = dto.EstimatedCost, | |
| 67 | + Url = dto.Url, | |
| 68 | + Location = dto.Location, | |
| 69 | + IsCompleted = false, | |
| 70 | + DisplayOrder = 0 | |
| 71 | + }; | |
| 72 | + | |
| 73 | + var (created, errorCode) = await _wishlistService.CreateAsync(item, userId); | |
| 74 | + if (created == null) | |
| 75 | + { | |
| 76 | + if (errorCode == "forbidden") return Forbid(); | |
| 77 | + return NotFound(); | |
| 78 | + } | |
| 79 | + | |
| 80 | + return CreatedAtAction(null, new { id = created.Id }, WishlistMapper.MapToDto(created, userId)); | |
| 81 | + } | |
| 82 | + | |
| 83 | + // PUT: api/v1/wishlist/{id} | |
| 84 | + [HttpPut("{id:guid}")] | |
| 85 | + [Consumes("application/json")] | |
| 86 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 87 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 88 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 89 | + public async Task<IActionResult> UpdateWishlistItem(Guid id, [FromBody] WishlistItemCreateDto dto) | |
| 90 | + { | |
| 91 | + var userId = GetUserId(); | |
| 92 | + | |
| 93 | + var incoming = new TripWishlistItemBllDto | |
| 94 | + { | |
| 95 | + Title = dto.Title, | |
| 96 | + Description = dto.Description, | |
| 97 | + Category = Enum.Parse<EWishlistCategory>(dto.Category), | |
| 98 | + Priority = Enum.Parse<EWishlistPriority>(dto.Priority), | |
| 99 | + EstimatedCost = dto.EstimatedCost, | |
| 100 | + Url = dto.Url, | |
| 101 | + Location = dto.Location | |
| 102 | + }; | |
| 103 | + | |
| 104 | + var (ok, errorCode) = await _wishlistService.UpdateAsync(id, incoming, userId); | |
| 105 | + if (!ok) | |
| 106 | + { | |
| 107 | + return errorCode switch | |
| 108 | + { | |
| 109 | + "notfound" => NotFound(), | |
| 110 | + "forbidden" => Forbid(), | |
| 111 | + _ => NotFound() | |
| 112 | + }; | |
| 113 | + } | |
| 114 | + | |
| 115 | + return NoContent(); | |
| 116 | + } | |
| 117 | + | |
| 118 | + // DELETE: api/v1/wishlist/{id} | |
| 119 | + [HttpDelete("{id:guid}")] | |
| 120 | + [ProducesResponseType((int)HttpStatusCode.NoContent)] | |
| 121 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 122 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 123 | + public async Task<IActionResult> DeleteWishlistItem(Guid id) | |
| 124 | + { | |
| 125 | + var userId = GetUserId(); | |
| 126 | + | |
| 127 | + var (ok, errorCode) = await _wishlistService.DeleteAsync(id, userId); | |
| 128 | + if (!ok) | |
| 129 | + { | |
| 130 | + return errorCode switch | |
| 131 | + { | |
| 132 | + "notfound" => NotFound(), | |
| 133 | + "forbidden" => Forbid(), | |
| 134 | + _ => NotFound() | |
| 135 | + }; | |
| 136 | + } | |
| 137 | + | |
| 138 | + return NoContent(); | |
| 139 | + } | |
| 140 | + | |
| 141 | + // POST: api/v1/wishlist/{id}/vote | |
| 142 | + [HttpPost("{id:guid}/vote")] | |
| 143 | + [Produces("application/json")] | |
| 144 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 145 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 146 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 147 | + public async Task<IActionResult> ToggleVote(Guid id) | |
| 148 | + { | |
| 149 | + var userId = GetUserId(); | |
| 150 | + | |
| 151 | + var (ok, errorCode) = await _wishlistService.ToggleVoteAsync(id, userId); | |
| 152 | + if (!ok) | |
| 153 | + { | |
| 154 | + return errorCode switch | |
| 155 | + { | |
| 156 | + "notfound" => NotFound(), | |
| 157 | + "forbidden" => Forbid(), | |
| 158 | + _ => NotFound() | |
| 159 | + }; | |
| 160 | + } | |
| 161 | + | |
| 162 | + return Ok(); | |
| 163 | + } | |
| 164 | + | |
| 165 | + // POST: api/v1/wishlist/{id}/complete | |
| 166 | + [HttpPost("{id:guid}/complete")] | |
| 167 | + [Produces("application/json")] | |
| 168 | + [ProducesResponseType((int)HttpStatusCode.OK)] | |
| 169 | + [ProducesResponseType((int)HttpStatusCode.NotFound)] | |
| 170 | + [ProducesResponseType((int)HttpStatusCode.Forbidden)] | |
| 171 | + public async Task<IActionResult> MarkCompleted(Guid id) | |
| 172 | + { | |
| 173 | + var userId = GetUserId(); | |
| 174 | + | |
| 175 | + var (ok, errorCode) = await _wishlistService.ToggleCompleteAsync(id, userId); | |
| 176 | + if (!ok) | |
| 177 | + { | |
| 178 | + return errorCode switch | |
| 179 | + { | |
| 180 | + "notfound" => NotFound(), | |
| 181 | + "forbidden" => Forbid(), | |
| 182 | + _ => NotFound() | |
| 183 | + }; | |
| 184 | + } | |
| 185 | + | |
| 186 | + return Ok(); | |
| 187 | + } | |
| 188 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +143 −0
| @@ -0,0 +1,143 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 7 | +using Microsoft.EntityFrameworkCore; | |
| 8 | +using Microsoft.Extensions.Localization; | |
| 9 | +using WebApp.Areas.Admin.Models; | |
| 10 | + | |
| 11 | +namespace WebApp.Areas.Admin.Controllers | |
| 12 | +{ | |
| 13 | + [Area("Admin")] | |
| 14 | + [Authorize(Roles = "admin")] | |
| 15 | + public class BudgetCategoriesController : Controller | |
| 16 | + { | |
| 17 | + private readonly IBudgetCategoryAdminService _service; | |
| 18 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 19 | + | |
| 20 | + public BudgetCategoriesController(IBudgetCategoryAdminService service, | |
| 21 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 22 | + { | |
| 23 | + _service = service; | |
| 24 | + _l = l; | |
| 25 | + } | |
| 26 | + | |
| 27 | + public async Task<IActionResult> Index(Guid? tripId, string? search) | |
| 28 | + { | |
| 29 | + var vm = new AdminBudgetCategoryIndexViewModel | |
| 30 | + { | |
| 31 | + Title = _l["Budget Categories"].Value, | |
| 32 | + Items = await _service.GetAllAsync(tripId, search), | |
| 33 | + Trips = await _service.GetAllTripsAsync(), | |
| 34 | + CurrentTripId = tripId, | |
| 35 | + CurrentSearch = search | |
| 36 | + }; | |
| 37 | + return View(vm); | |
| 38 | + } | |
| 39 | + | |
| 40 | + public async Task<IActionResult> Details(Guid? id) | |
| 41 | + { | |
| 42 | + if (id == null) return NotFound(); | |
| 43 | + var entity = await _service.GetByIdAsync(id.Value); | |
| 44 | + if (entity == null) return NotFound(); | |
| 45 | + | |
| 46 | + return View(new AdminDetailsViewModel<BudgetCategoryBllDto> | |
| 47 | + { | |
| 48 | + Title = _l["Budget Category details"].Value, | |
| 49 | + Item = entity | |
| 50 | + }); | |
| 51 | + } | |
| 52 | + | |
| 53 | + public async Task<IActionResult> Create() | |
| 54 | + { | |
| 55 | + var vm = new AdminBudgetCategoryFormViewModel | |
| 56 | + { | |
| 57 | + Title = _l["New budget category"].Value, | |
| 58 | + TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name") | |
| 59 | + }; | |
| 60 | + return View(vm); | |
| 61 | + } | |
| 62 | + | |
| 63 | + [HttpPost] | |
| 64 | + [ValidateAntiForgeryToken] | |
| 65 | + public async Task<IActionResult> Create(AdminBudgetCategoryFormViewModel vm, string? nameEn, string? nameEt) | |
| 66 | + { | |
| 67 | + ModelState.Remove("BudgetCategory.Name"); | |
| 68 | + | |
| 69 | + if (ModelState.IsValid) | |
| 70 | + { | |
| 71 | + await _service.CreateAsync(vm.BudgetCategory, nameEn, nameEt); | |
| 72 | + return RedirectToAction(nameof(Index)); | |
| 73 | + } | |
| 74 | + | |
| 75 | + vm.Title = _l["New budget category"].Value; | |
| 76 | + vm.TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", vm.BudgetCategory.TripId); | |
| 77 | + return View(vm); | |
| 78 | + } | |
| 79 | + | |
| 80 | + public async Task<IActionResult> Edit(Guid? id) | |
| 81 | + { | |
| 82 | + if (id == null) return NotFound(); | |
| 83 | + var entity = await _service.GetByIdAsync(id.Value); | |
| 84 | + if (entity == null) return NotFound(); | |
| 85 | + | |
| 86 | + var vm = new AdminBudgetCategoryFormViewModel | |
| 87 | + { | |
| 88 | + Title = _l["Edit budget category"].Value, | |
| 89 | + BudgetCategory = entity, | |
| 90 | + TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", entity.TripId) | |
| 91 | + }; | |
| 92 | + return View(vm); | |
| 93 | + } | |
| 94 | + | |
| 95 | + [HttpPost] | |
| 96 | + [ValidateAntiForgeryToken] | |
| 97 | + public async Task<IActionResult> Edit(Guid id, AdminBudgetCategoryFormViewModel vm, string? nameEn, string? nameEt) | |
| 98 | + { | |
| 99 | + if (id != vm.BudgetCategory.Id) return NotFound(); | |
| 100 | + | |
| 101 | + ModelState.Remove("BudgetCategory.Name"); | |
| 102 | + | |
| 103 | + if (ModelState.IsValid) | |
| 104 | + { | |
| 105 | + try | |
| 106 | + { | |
| 107 | + await _service.UpdateAsync(vm.BudgetCategory, nameEn, nameEt); | |
| 108 | + } | |
| 109 | + catch (DbUpdateConcurrencyException) | |
| 110 | + { | |
| 111 | + if (!await _service.ExistsAsync(vm.BudgetCategory.Id)) return NotFound(); | |
| 112 | + throw; | |
| 113 | + } | |
| 114 | + return RedirectToAction(nameof(Index)); | |
| 115 | + } | |
| 116 | + | |
| 117 | + vm.Title = _l["Edit budget category"].Value; | |
| 118 | + vm.TripList = new SelectList(await _service.GetAllTripsAsync(), "Id", "Name", vm.BudgetCategory.TripId); | |
| 119 | + return View(vm); | |
| 120 | + } | |
| 121 | + | |
| 122 | + public async Task<IActionResult> Delete(Guid? id) | |
| 123 | + { | |
| 124 | + if (id == null) return NotFound(); | |
| 125 | + var entity = await _service.GetByIdAsync(id.Value); | |
| 126 | + if (entity == null) return NotFound(); | |
| 127 | + | |
| 128 | + return View(new AdminDeleteViewModel<BudgetCategoryBllDto> | |
| 129 | + { | |
| 130 | + Title = _l["Delete budget category"].Value, | |
| 131 | + Item = entity | |
| 132 | + }); | |
| 133 | + } | |
| 134 | + | |
| 135 | + [HttpPost, ActionName("Delete")] | |
| 136 | + [ValidateAntiForgeryToken] | |
| 137 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 138 | + { | |
| 139 | + await _service.DeleteAsync(id); | |
| 140 | + return RedirectToAction(nameof(Index)); | |
| 141 | + } | |
| 142 | + } | |
| 143 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/CurrenciesController.cs +130 −0
| @@ -0,0 +1,130 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.EntityFrameworkCore; | |
| 7 | +using Microsoft.Extensions.Localization; | |
| 8 | +using WebApp.Areas.Admin.Models; | |
| 9 | + | |
| 10 | +namespace WebApp.Areas.Admin.Controllers | |
| 11 | +{ | |
| 12 | + [Area("Admin")] | |
| 13 | + [Authorize(Roles = "admin")] | |
| 14 | + public class CurrenciesController : Controller | |
| 15 | + { | |
| 16 | + private readonly ICurrencyAdminService _service; | |
| 17 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 18 | + | |
| 19 | + public CurrenciesController(ICurrencyAdminService service, | |
| 20 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 21 | + { | |
| 22 | + _service = service; | |
| 23 | + _l = l; | |
| 24 | + } | |
| 25 | + | |
| 26 | + public async Task<IActionResult> Index(string? search) | |
| 27 | + { | |
| 28 | + return View(new AdminCurrencyIndexViewModel | |
| 29 | + { | |
| 30 | + Title = _l["Currencies"].Value, | |
| 31 | + Items = await _service.GetAllAsync(search), | |
| 32 | + CurrentSearch = search | |
| 33 | + }); | |
| 34 | + } | |
| 35 | + | |
| 36 | + public async Task<IActionResult> Details(Guid? id) | |
| 37 | + { | |
| 38 | + if (id == null) return NotFound(); | |
| 39 | + var currency = await _service.GetByIdAsync(id.Value); | |
| 40 | + if (currency == null) return NotFound(); | |
| 41 | + | |
| 42 | + return View(new AdminDetailsViewModel<CurrencyBllDto> | |
| 43 | + { | |
| 44 | + Title = _l["Currency details"].Value, | |
| 45 | + Item = currency | |
| 46 | + }); | |
| 47 | + } | |
| 48 | + | |
| 49 | + public IActionResult Create() | |
| 50 | + { | |
| 51 | + return View(new AdminCurrencyFormViewModel { Title = _l["New currency"].Value }); | |
| 52 | + } | |
| 53 | + | |
| 54 | + [HttpPost] | |
| 55 | + [ValidateAntiForgeryToken] | |
| 56 | + public async Task<IActionResult> Create(AdminCurrencyFormViewModel vm, string? nameEn, string? nameEt) | |
| 57 | + { | |
| 58 | + ModelState.Remove("Currency.Name"); | |
| 59 | + | |
| 60 | + if (ModelState.IsValid) | |
| 61 | + { | |
| 62 | + await _service.CreateAsync(vm.Currency, nameEn, nameEt); | |
| 63 | + return RedirectToAction(nameof(Index)); | |
| 64 | + } | |
| 65 | + | |
| 66 | + vm.Title = _l["New currency"].Value; | |
| 67 | + return View(vm); | |
| 68 | + } | |
| 69 | + | |
| 70 | + public async Task<IActionResult> Edit(Guid? id) | |
| 71 | + { | |
| 72 | + if (id == null) return NotFound(); | |
| 73 | + var currency = await _service.GetByIdAsync(id.Value); | |
| 74 | + if (currency == null) return NotFound(); | |
| 75 | + | |
| 76 | + return View(new AdminCurrencyFormViewModel | |
| 77 | + { | |
| 78 | + Title = _l["Edit currency"].Value, | |
| 79 | + Currency = currency | |
| 80 | + }); | |
| 81 | + } | |
| 82 | + | |
| 83 | + [HttpPost] | |
| 84 | + [ValidateAntiForgeryToken] | |
| 85 | + public async Task<IActionResult> Edit(Guid id, AdminCurrencyFormViewModel vm, string? nameEn, string? nameEt) | |
| 86 | + { | |
| 87 | + if (id != vm.Currency.Id) return NotFound(); | |
| 88 | + | |
| 89 | + ModelState.Remove("Currency.Name"); | |
| 90 | + | |
| 91 | + if (ModelState.IsValid) | |
| 92 | + { | |
| 93 | + try | |
| 94 | + { | |
| 95 | + await _service.UpdateAsync(vm.Currency, nameEn, nameEt); | |
| 96 | + } | |
| 97 | + catch (DbUpdateConcurrencyException) | |
| 98 | + { | |
| 99 | + if (!await _service.ExistsAsync(vm.Currency.Id)) return NotFound(); | |
| 100 | + throw; | |
| 101 | + } | |
| 102 | + return RedirectToAction(nameof(Index)); | |
| 103 | + } | |
| 104 | + | |
| 105 | + vm.Title = _l["Edit currency"].Value; | |
| 106 | + return View(vm); | |
| 107 | + } | |
| 108 | + | |
| 109 | + public async Task<IActionResult> Delete(Guid? id) | |
| 110 | + { | |
| 111 | + if (id == null) return NotFound(); | |
| 112 | + var currency = await _service.GetByIdAsync(id.Value); | |
| 113 | + if (currency == null) return NotFound(); | |
| 114 | + | |
| 115 | + return View(new AdminDeleteViewModel<CurrencyBllDto> | |
| 116 | + { | |
| 117 | + Title = _l["Delete currency"].Value, | |
| 118 | + Item = currency | |
| 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 | + } | |
| 130 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/DashboardController.cs +88 −0
| @@ -0,0 +1,88 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using Microsoft.AspNetCore.Authorization; | |
| 4 | +using Microsoft.AspNetCore.Mvc; | |
| 5 | +using Microsoft.Extensions.Localization; | |
| 6 | +using WebApp.Areas.Admin.Models; | |
| 7 | + | |
| 8 | +namespace 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/WebApp/Areas/Admin/Controllers/ExpensesController.cs +145 −0
| @@ -0,0 +1,145 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 7 | +using Microsoft.EntityFrameworkCore; | |
| 8 | +using Microsoft.Extensions.Localization; | |
| 9 | +using WebApp.Areas.Admin.Models; | |
| 10 | + | |
| 11 | +namespace WebApp.Areas.Admin.Controllers | |
| 12 | +{ | |
| 13 | + [Area("Admin")] | |
| 14 | + [Authorize(Roles = "admin")] | |
| 15 | + public class ExpensesController : Controller | |
| 16 | + { | |
| 17 | + private readonly IExpenseAdminService _service; | |
| 18 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 19 | + | |
| 20 | + public ExpensesController(IExpenseAdminService service, IStringLocalizer<App.Resources.Views.Shared> l) | |
| 21 | + { | |
| 22 | + _service = service; | |
| 23 | + _l = l; | |
| 24 | + } | |
| 25 | + | |
| 26 | + private async Task PopulateSelectListsAsync(AdminExpenseFormViewModel vm) | |
| 27 | + { | |
| 28 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Expense.TripId); | |
| 29 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Expense.PaidByUserId); | |
| 30 | + vm.CurrencyList = new SelectList(await _service.GetCurrenciesAsync(), "Id", "Code", vm.Expense.CurrencyId); | |
| 31 | + vm.BudgetCategoryList = new SelectList(await _service.GetBudgetCategoriesAsync(), "Id", "Name", vm.Expense.BudgetCategoryId); | |
| 32 | + } | |
| 33 | + | |
| 34 | + public async Task<IActionResult> Index(Guid? tripId, string? search) | |
| 35 | + { | |
| 36 | + var expenses = await _service.GetAllAsync(tripId, search); | |
| 37 | + var trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(); | |
| 38 | + | |
| 39 | + return View(new AdminExpenseIndexViewModel | |
| 40 | + { | |
| 41 | + Title = _l["Expenses"].Value, | |
| 42 | + Items = expenses.OrderByDescending(e => e.ExpenseDate).ToList(), | |
| 43 | + Trips = trips, | |
| 44 | + CurrentTripId = tripId, | |
| 45 | + CurrentSearch = search | |
| 46 | + }); | |
| 47 | + } | |
| 48 | + | |
| 49 | + public async Task<IActionResult> Details(Guid? id) | |
| 50 | + { | |
| 51 | + if (id == null) return NotFound(); | |
| 52 | + var expense = await _service.GetByIdAsync(id.Value); | |
| 53 | + if (expense == null) return NotFound(); | |
| 54 | + | |
| 55 | + return View(new AdminDetailsViewModel<ExpenseBllDto> | |
| 56 | + { | |
| 57 | + Title = _l["Expense details"].Value, | |
| 58 | + Item = expense | |
| 59 | + }); | |
| 60 | + } | |
| 61 | + | |
| 62 | + public async Task<IActionResult> Create() | |
| 63 | + { | |
| 64 | + var vm = new AdminExpenseFormViewModel { Title = _l["New expense"].Value }; | |
| 65 | + await PopulateSelectListsAsync(vm); | |
| 66 | + return View(vm); | |
| 67 | + } | |
| 68 | + | |
| 69 | + [HttpPost] | |
| 70 | + [ValidateAntiForgeryToken] | |
| 71 | + public async Task<IActionResult> Create(AdminExpenseFormViewModel vm) | |
| 72 | + { | |
| 73 | + if (ModelState.IsValid) | |
| 74 | + { | |
| 75 | + await _service.CreateAsync(vm.Expense); | |
| 76 | + return RedirectToAction(nameof(Index)); | |
| 77 | + } | |
| 78 | + | |
| 79 | + vm.Title = _l["New expense"].Value; | |
| 80 | + await PopulateSelectListsAsync(vm); | |
| 81 | + return View(vm); | |
| 82 | + } | |
| 83 | + | |
| 84 | + public async Task<IActionResult> Edit(Guid? id) | |
| 85 | + { | |
| 86 | + if (id == null) return NotFound(); | |
| 87 | + var expense = await _service.GetByIdAsync(id.Value); | |
| 88 | + if (expense == null) return NotFound(); | |
| 89 | + | |
| 90 | + var vm = new AdminExpenseFormViewModel | |
| 91 | + { | |
| 92 | + Title = _l["Edit expense"].Value, | |
| 93 | + Expense = expense | |
| 94 | + }; | |
| 95 | + await PopulateSelectListsAsync(vm); | |
| 96 | + return View(vm); | |
| 97 | + } | |
| 98 | + | |
| 99 | + [HttpPost] | |
| 100 | + [ValidateAntiForgeryToken] | |
| 101 | + public async Task<IActionResult> Edit(Guid id, AdminExpenseFormViewModel vm) | |
| 102 | + { | |
| 103 | + if (id != vm.Expense.Id) return NotFound(); | |
| 104 | + | |
| 105 | + if (ModelState.IsValid) | |
| 106 | + { | |
| 107 | + try | |
| 108 | + { | |
| 109 | + await _service.UpdateAsync(vm.Expense); | |
| 110 | + } | |
| 111 | + catch (DbUpdateConcurrencyException) | |
| 112 | + { | |
| 113 | + if (!await _service.ExistsAsync(vm.Expense.Id)) return NotFound(); | |
| 114 | + throw; | |
| 115 | + } | |
| 116 | + return RedirectToAction(nameof(Index)); | |
| 117 | + } | |
| 118 | + | |
| 119 | + vm.Title = _l["Edit expense"].Value; | |
| 120 | + await PopulateSelectListsAsync(vm); | |
| 121 | + return View(vm); | |
| 122 | + } | |
| 123 | + | |
| 124 | + public async Task<IActionResult> Delete(Guid? id) | |
| 125 | + { | |
| 126 | + if (id == null) return NotFound(); | |
| 127 | + var expense = await _service.GetByIdAsync(id.Value); | |
| 128 | + if (expense == null) return NotFound(); | |
| 129 | + | |
| 130 | + return View(new AdminDeleteViewModel<ExpenseBllDto> | |
| 131 | + { | |
| 132 | + Title = _l["Delete expense"].Value, | |
| 133 | + Item = expense | |
| 134 | + }); | |
| 135 | + } | |
| 136 | + | |
| 137 | + [HttpPost, ActionName("Delete")] | |
| 138 | + [ValidateAntiForgeryToken] | |
| 139 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 140 | + { | |
| 141 | + await _service.DeleteAsync(id); | |
| 142 | + return RedirectToAction(nameof(Index)); | |
| 143 | + } | |
| 144 | + } | |
| 145 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/InvitationsController.cs +154 −0
| @@ -0,0 +1,154 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.Extensions.Localization; | |
| 7 | +using WebApp.Areas.Admin.Models; | |
| 8 | + | |
| 9 | +namespace WebApp.Areas.Admin.Controllers | |
| 10 | +{ | |
| 11 | + [Area("Admin")] | |
| 12 | + [Authorize(Roles = "admin")] | |
| 13 | + public class InvitationsController : Controller | |
| 14 | + { | |
| 15 | + private readonly IInvitationAdminService _service; | |
| 16 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 17 | + | |
| 18 | + public InvitationsController(IInvitationAdminService service, | |
| 19 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 20 | + { | |
| 21 | + _service = service; | |
| 22 | + _l = l; | |
| 23 | + } | |
| 24 | + | |
| 25 | + // GET: Admin/Invitations | |
| 26 | + public async Task<IActionResult> Index(string? search) | |
| 27 | + { | |
| 28 | + var invitations = await _service.GetAllAsync(search); | |
| 29 | + | |
| 30 | + var vm = new AdminInvitationIndexViewModel | |
| 31 | + { | |
| 32 | + Title = _l["Invitations"].Value, | |
| 33 | + Items = invitations.OrderByDescending(i => i.Id).ToList(), | |
| 34 | + CurrentSearch = search | |
| 35 | + }; | |
| 36 | + return View(vm); | |
| 37 | + } | |
| 38 | + | |
| 39 | + // GET: Admin/Invitations/Details/5 | |
| 40 | + public async Task<IActionResult> Details(Guid? id) | |
| 41 | + { | |
| 42 | + if (id == null) | |
| 43 | + { | |
| 44 | + return NotFound(); | |
| 45 | + } | |
| 46 | + | |
| 47 | + var tripInvitation = await _service.GetByIdAsync(id.Value); | |
| 48 | + if (tripInvitation == null) | |
| 49 | + { | |
| 50 | + return NotFound(); | |
| 51 | + } | |
| 52 | + | |
| 53 | + return View(new AdminDetailsViewModel<TripInvitationBllDto> | |
| 54 | + { | |
| 55 | + Title = _l["Invitation details"].Value, | |
| 56 | + Item = tripInvitation | |
| 57 | + }); | |
| 58 | + } | |
| 59 | + | |
| 60 | + public async Task<IActionResult> Create() | |
| 61 | + { | |
| 62 | + return View(new AdminInvitationFormViewModel | |
| 63 | + { | |
| 64 | + Title = _l["New invitation"].Value, | |
| 65 | + Invitation = new TripInvitationBllDto | |
| 66 | + { | |
| 67 | + ExpiresAt = DateTime.UtcNow.AddDays(7), | |
| 68 | + Status = EInvitationStatus.Pending | |
| 69 | + }, | |
| 70 | + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 71 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 72 | + }); | |
| 73 | + } | |
| 74 | + | |
| 75 | + [HttpPost] | |
| 76 | + [ValidateAntiForgeryToken] | |
| 77 | + public async Task<IActionResult> Create(AdminInvitationFormViewModel vm) | |
| 78 | + { | |
| 79 | + if (ModelState.IsValid) | |
| 80 | + { | |
| 81 | + await _service.CreateAsync(vm.Invitation); | |
| 82 | + return RedirectToAction(nameof(Index)); | |
| 83 | + } | |
| 84 | + | |
| 85 | + vm.Title = _l["New invitation"].Value; | |
| 86 | + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Invitation.TripId); | |
| 87 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Invitation.InvitedByUserId); | |
| 88 | + return View(vm); | |
| 89 | + } | |
| 90 | + | |
| 91 | + public async Task<IActionResult> Edit(Guid? id) | |
| 92 | + { | |
| 93 | + if (id == null) return NotFound(); | |
| 94 | + var invitation = await _service.GetByIdAsync(id.Value); | |
| 95 | + if (invitation == null) return NotFound(); | |
| 96 | + | |
| 97 | + return View(new AdminInvitationFormViewModel | |
| 98 | + { | |
| 99 | + Title = _l["Edit invitation"].Value, | |
| 100 | + Invitation = invitation, | |
| 101 | + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", invitation.TripId), | |
| 102 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", invitation.InvitedByUserId) | |
| 103 | + }); | |
| 104 | + } | |
| 105 | + | |
| 106 | + [HttpPost] | |
| 107 | + [ValidateAntiForgeryToken] | |
| 108 | + public async Task<IActionResult> Edit(Guid id, AdminInvitationFormViewModel vm) | |
| 109 | + { | |
| 110 | + if (id != vm.Invitation.Id) return NotFound(); | |
| 111 | + | |
| 112 | + if (ModelState.IsValid) | |
| 113 | + { | |
| 114 | + await _service.UpdateAsync(vm.Invitation); | |
| 115 | + return RedirectToAction(nameof(Index)); | |
| 116 | + } | |
| 117 | + | |
| 118 | + vm.Title = _l["Edit invitation"].Value; | |
| 119 | + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.Invitation.TripId); | |
| 120 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Invitation.InvitedByUserId); | |
| 121 | + return View(vm); | |
| 122 | + } | |
| 123 | + | |
| 124 | + // GET: Admin/Invitations/Delete/5 | |
| 125 | + public async Task<IActionResult> Delete(Guid? id) | |
| 126 | + { | |
| 127 | + if (id == null) | |
| 128 | + { | |
| 129 | + return NotFound(); | |
| 130 | + } | |
| 131 | + | |
| 132 | + var tripInvitation = await _service.GetByIdAsync(id.Value); | |
| 133 | + if (tripInvitation == null) | |
| 134 | + { | |
| 135 | + return NotFound(); | |
| 136 | + } | |
| 137 | + | |
| 138 | + return View(new AdminDeleteViewModel<TripInvitationBllDto> | |
| 139 | + { | |
| 140 | + Title = _l["Delete invitation"].Value, | |
| 141 | + Item = tripInvitation | |
| 142 | + }); | |
| 143 | + } | |
| 144 | + | |
| 145 | + // POST: Admin/Invitations/Delete/5 | |
| 146 | + [HttpPost, ActionName("Delete")] | |
| 147 | + [ValidateAntiForgeryToken] | |
| 148 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 149 | + { | |
| 150 | + await _service.DeleteAsync(id); | |
| 151 | + return RedirectToAction(nameof(Index)); | |
| 152 | + } | |
| 153 | + } | |
| 154 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/PollsController.cs +125 −0
| @@ -0,0 +1,125 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 7 | +using Microsoft.Extensions.Localization; | |
| 8 | +using WebApp.Areas.Admin.Models; | |
| 9 | + | |
| 10 | +namespace WebApp.Areas.Admin.Controllers; | |
| 11 | + | |
| 12 | +[Area("Admin")] | |
| 13 | +[Authorize(Roles = "admin")] | |
| 14 | +public class PollsController : Controller | |
| 15 | +{ | |
| 16 | + private readonly IPollAdminService _service; | |
| 17 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 18 | + | |
| 19 | + public PollsController(IPollAdminService service, IStringLocalizer<App.Resources.Views.Shared> l) | |
| 20 | + { | |
| 21 | + _service = service; | |
| 22 | + _l = l; | |
| 23 | + } | |
| 24 | + | |
| 25 | + public async Task<IActionResult> Index(string? search) | |
| 26 | + { | |
| 27 | + var polls = await _service.GetAllAsync(search); | |
| 28 | + | |
| 29 | + var vm = new AdminPollIndexViewModel | |
| 30 | + { | |
| 31 | + Title = _l["Polls"].Value, | |
| 32 | + Items = polls.OrderByDescending(p => p.Id).ToList(), | |
| 33 | + CurrentSearch = search | |
| 34 | + }; | |
| 35 | + return View(vm); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task<IActionResult> Details(Guid id) | |
| 39 | + { | |
| 40 | + var poll = await _service.GetByIdAsync(id); | |
| 41 | + if (poll == null) return NotFound(); | |
| 42 | + | |
| 43 | + return View(new AdminDetailsViewModel<TripPollBllDto> | |
| 44 | + { | |
| 45 | + Title = _l["Poll details"].Value, | |
| 46 | + Item = poll | |
| 47 | + }); | |
| 48 | + } | |
| 49 | + | |
| 50 | + public async Task<IActionResult> Create() | |
| 51 | + { | |
| 52 | + var vm = new AdminPollFormViewModel | |
| 53 | + { | |
| 54 | + Title = _l["New poll"].Value, | |
| 55 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 56 | + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name") | |
| 57 | + }; | |
| 58 | + return View(vm); | |
| 59 | + } | |
| 60 | + | |
| 61 | + [HttpPost] | |
| 62 | + [ValidateAntiForgeryToken] | |
| 63 | + public async Task<IActionResult> Create(AdminPollFormViewModel vm) | |
| 64 | + { | |
| 65 | + if (ModelState.IsValid) | |
| 66 | + { | |
| 67 | + await _service.CreateAsync(vm.Poll); | |
| 68 | + return RedirectToAction(nameof(Index)); | |
| 69 | + } | |
| 70 | + vm.Title = _l["New poll"].Value; | |
| 71 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"); | |
| 72 | + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name"); | |
| 73 | + return View(vm); | |
| 74 | + } | |
| 75 | + | |
| 76 | + public async Task<IActionResult> Edit(Guid id) | |
| 77 | + { | |
| 78 | + var poll = await _service.GetByIdAsync(id); | |
| 79 | + if (poll == null) return NotFound(); | |
| 80 | + var vm = new AdminPollFormViewModel | |
| 81 | + { | |
| 82 | + Title = _l["Edit poll"].Value, | |
| 83 | + Poll = poll, | |
| 84 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 85 | + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name") | |
| 86 | + }; | |
| 87 | + return View(vm); | |
| 88 | + } | |
| 89 | + | |
| 90 | + [HttpPost] | |
| 91 | + [ValidateAntiForgeryToken] | |
| 92 | + public async Task<IActionResult> Edit(Guid id, AdminPollFormViewModel vm) | |
| 93 | + { | |
| 94 | + if (id != vm.Poll.Id) return NotFound(); | |
| 95 | + if (ModelState.IsValid) | |
| 96 | + { | |
| 97 | + await _service.UpdateAsync(vm.Poll); | |
| 98 | + return RedirectToAction(nameof(Index)); | |
| 99 | + } | |
| 100 | + vm.Title = _l["Edit poll"].Value; | |
| 101 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"); | |
| 102 | + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name"); | |
| 103 | + return View(vm); | |
| 104 | + } | |
| 105 | + | |
| 106 | + public async Task<IActionResult> Delete(Guid id) | |
| 107 | + { | |
| 108 | + var poll = await _service.GetByIdAsync(id); | |
| 109 | + if (poll == null) return NotFound(); | |
| 110 | + | |
| 111 | + return View(new AdminDeleteViewModel<TripPollBllDto> | |
| 112 | + { | |
| 113 | + Title = _l["Delete poll"].Value, | |
| 114 | + Item = poll | |
| 115 | + }); | |
| 116 | + } | |
| 117 | + | |
| 118 | + [HttpPost, ActionName("Delete")] | |
| 119 | + [ValidateAntiForgeryToken] | |
| 120 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 121 | + { | |
| 122 | + await _service.DeleteAsync(id); | |
| 123 | + return RedirectToAction(nameof(Index)); | |
| 124 | + } | |
| 125 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +149 −0
| @@ -0,0 +1,149 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.Extensions.Localization; | |
| 7 | +using WebApp.Areas.Admin.Models; | |
| 8 | + | |
| 9 | +namespace WebApp.Areas.Admin.Controllers | |
| 10 | +{ | |
| 11 | + [Area("Admin")] | |
| 12 | + [Authorize(Roles = "admin")] | |
| 13 | + public class SettlementPaymentsController : Controller | |
| 14 | + { | |
| 15 | + private readonly ISettlementPaymentAdminService _service; | |
| 16 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 17 | + | |
| 18 | + public SettlementPaymentsController(ISettlementPaymentAdminService service, | |
| 19 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 20 | + { | |
| 21 | + _service = service; | |
| 22 | + _l = l; | |
| 23 | + } | |
| 24 | + | |
| 25 | + // GET: Admin/SettlementPayments | |
| 26 | + public async Task<IActionResult> Index(string? search) | |
| 27 | + { | |
| 28 | + var payments = await _service.GetAllAsync(search); | |
| 29 | + | |
| 30 | + var vm = new AdminSettlementPaymentIndexViewModel | |
| 31 | + { | |
| 32 | + Title = _l["Settlement payments"].Value, | |
| 33 | + Items = payments.OrderByDescending(s => s.Id).ToList(), | |
| 34 | + CurrentSearch = search | |
| 35 | + }; | |
| 36 | + return View(vm); | |
| 37 | + } | |
| 38 | + | |
| 39 | + // GET: Admin/SettlementPayments/Details/5 | |
| 40 | + public async Task<IActionResult> Details(Guid? id) | |
| 41 | + { | |
| 42 | + if (id == null) | |
| 43 | + { | |
| 44 | + return NotFound(); | |
| 45 | + } | |
| 46 | + | |
| 47 | + var settlementPayment = await _service.GetByIdAsync(id.Value); | |
| 48 | + if (settlementPayment == null) | |
| 49 | + { | |
| 50 | + return NotFound(); | |
| 51 | + } | |
| 52 | + | |
| 53 | + return View(new AdminDetailsViewModel<SettlementPaymentBllDto> | |
| 54 | + { | |
| 55 | + Title = _l["Settlement payment details"].Value, | |
| 56 | + Item = settlementPayment | |
| 57 | + }); | |
| 58 | + } | |
| 59 | + | |
| 60 | + public async Task<IActionResult> Create() | |
| 61 | + { | |
| 62 | + return View(new AdminSettlementPaymentFormViewModel | |
| 63 | + { | |
| 64 | + Title = _l["New settlement payment"].Value, | |
| 65 | + SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id"), | |
| 66 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 67 | + }); | |
| 68 | + } | |
| 69 | + | |
| 70 | + [HttpPost] | |
| 71 | + [ValidateAntiForgeryToken] | |
| 72 | + public async Task<IActionResult> Create(AdminSettlementPaymentFormViewModel vm) | |
| 73 | + { | |
| 74 | + if (ModelState.IsValid) | |
| 75 | + { | |
| 76 | + await _service.CreateAsync(vm.Payment); | |
| 77 | + return RedirectToAction(nameof(Index)); | |
| 78 | + } | |
| 79 | + | |
| 80 | + vm.Title = _l["New settlement payment"].Value; | |
| 81 | + vm.SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", vm.Payment.SettlementPlanId); | |
| 82 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Payment.FromUserId); | |
| 83 | + return View(vm); | |
| 84 | + } | |
| 85 | + | |
| 86 | + public async Task<IActionResult> Edit(Guid? id) | |
| 87 | + { | |
| 88 | + if (id == null) return NotFound(); | |
| 89 | + var payment = await _service.GetByIdAsync(id.Value); | |
| 90 | + if (payment == null) return NotFound(); | |
| 91 | + | |
| 92 | + return View(new AdminSettlementPaymentFormViewModel | |
| 93 | + { | |
| 94 | + Title = _l["Edit settlement payment"].Value, | |
| 95 | + Payment = payment, | |
| 96 | + SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", payment.SettlementPlanId), | |
| 97 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", payment.FromUserId) | |
| 98 | + }); | |
| 99 | + } | |
| 100 | + | |
| 101 | + [HttpPost] | |
| 102 | + [ValidateAntiForgeryToken] | |
| 103 | + public async Task<IActionResult> Edit(Guid id, AdminSettlementPaymentFormViewModel vm) | |
| 104 | + { | |
| 105 | + if (id != vm.Payment.Id) return NotFound(); | |
| 106 | + | |
| 107 | + if (ModelState.IsValid) | |
| 108 | + { | |
| 109 | + await _service.UpdateAsync(vm.Payment); | |
| 110 | + return RedirectToAction(nameof(Index)); | |
| 111 | + } | |
| 112 | + | |
| 113 | + vm.Title = _l["Edit settlement payment"].Value; | |
| 114 | + vm.SettlementPlanList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetSettlementPlansAsync(), "Id", "Id", vm.Payment.SettlementPlanId); | |
| 115 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.Payment.FromUserId); | |
| 116 | + return View(vm); | |
| 117 | + } | |
| 118 | + | |
| 119 | + // GET: Admin/SettlementPayments/Delete/5 | |
| 120 | + public async Task<IActionResult> Delete(Guid? id) | |
| 121 | + { | |
| 122 | + if (id == null) | |
| 123 | + { | |
| 124 | + return NotFound(); | |
| 125 | + } | |
| 126 | + | |
| 127 | + var settlementPayment = await _service.GetByIdAsync(id.Value); | |
| 128 | + if (settlementPayment == null) | |
| 129 | + { | |
| 130 | + return NotFound(); | |
| 131 | + } | |
| 132 | + | |
| 133 | + return View(new AdminDeleteViewModel<SettlementPaymentBllDto> | |
| 134 | + { | |
| 135 | + Title = _l["Delete settlement payment"].Value, | |
| 136 | + Item = settlementPayment | |
| 137 | + }); | |
| 138 | + } | |
| 139 | + | |
| 140 | + // POST: Admin/SettlementPayments/Delete/5 | |
| 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/WebApp/Areas/Admin/Controllers/SettlementPlansController.cs +183 −0
| @@ -0,0 +1,183 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 7 | +using Microsoft.EntityFrameworkCore; | |
| 8 | +using Microsoft.Extensions.Localization; | |
| 9 | +using WebApp.Areas.Admin.Models; | |
| 10 | + | |
| 11 | +namespace WebApp.Areas.Admin.Controllers | |
| 12 | +{ | |
| 13 | + [Area("Admin")] | |
| 14 | + [Authorize(Roles = "admin")] | |
| 15 | + public class SettlementPlansController : Controller | |
| 16 | + { | |
| 17 | + private readonly ISettlementPlanAdminService _service; | |
| 18 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 19 | + | |
| 20 | + public SettlementPlansController(ISettlementPlanAdminService service, | |
| 21 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 22 | + { | |
| 23 | + _service = service; | |
| 24 | + _l = l; | |
| 25 | + } | |
| 26 | + | |
| 27 | + // GET: Admin/SettlementPlans | |
| 28 | + public async Task<IActionResult> Index(Guid? tripId) | |
| 29 | + { | |
| 30 | + var plans = await _service.GetAllAsync(tripId); | |
| 31 | + | |
| 32 | + var vm = new AdminSettlementPlanIndexViewModel | |
| 33 | + { | |
| 34 | + Title = _l["Settlement plans"].Value, | |
| 35 | + Items = plans.OrderByDescending(s => s.Id).ToList(), | |
| 36 | + Trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(), | |
| 37 | + CurrentTripId = tripId | |
| 38 | + }; | |
| 39 | + return View(vm); | |
| 40 | + } | |
| 41 | + | |
| 42 | + // GET: Admin/SettlementPlans/Details/5 | |
| 43 | + public async Task<IActionResult> Details(Guid? id) | |
| 44 | + { | |
| 45 | + if (id == null) | |
| 46 | + { | |
| 47 | + return NotFound(); | |
| 48 | + } | |
| 49 | + | |
| 50 | + var settlementPlan = await _service.GetByIdAsync(id.Value); | |
| 51 | + if (settlementPlan == null) | |
| 52 | + { | |
| 53 | + return NotFound(); | |
| 54 | + } | |
| 55 | + | |
| 56 | + return View(new AdminDetailsViewModel<SettlementPlanBllDto> | |
| 57 | + { | |
| 58 | + Title = _l["Settlement plan details"].Value, | |
| 59 | + Item = settlementPlan | |
| 60 | + }); | |
| 61 | + } | |
| 62 | + | |
| 63 | + // GET: Admin/SettlementPlans/Create | |
| 64 | + public async Task<IActionResult> Create() | |
| 65 | + { | |
| 66 | + var vm = new AdminSettlementPlanFormViewModel | |
| 67 | + { | |
| 68 | + Title = _l["New settlement plan"].Value, | |
| 69 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 70 | + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 71 | + }; | |
| 72 | + return View(vm); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // POST: Admin/SettlementPlans/Create | |
| 76 | + [HttpPost] | |
| 77 | + [ValidateAntiForgeryToken] | |
| 78 | + public async Task<IActionResult> Create(AdminSettlementPlanFormViewModel vm) | |
| 79 | + { | |
| 80 | + if (ModelState.IsValid) | |
| 81 | + { | |
| 82 | + await _service.CreateAsync(vm.SettlementPlan); | |
| 83 | + return RedirectToAction(nameof(Index)); | |
| 84 | + } | |
| 85 | + | |
| 86 | + vm.Title = _l["New settlement plan"].Value; | |
| 87 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SettlementPlan.TripId); | |
| 88 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SettlementPlan.CreatedByUserId); | |
| 89 | + return View(vm); | |
| 90 | + } | |
| 91 | + | |
| 92 | + // GET: Admin/SettlementPlans/Edit/5 | |
| 93 | + public async Task<IActionResult> Edit(Guid? id) | |
| 94 | + { | |
| 95 | + if (id == null) | |
| 96 | + { | |
| 97 | + return NotFound(); | |
| 98 | + } | |
| 99 | + | |
| 100 | + var settlementPlan = await _service.GetByIdAsync(id.Value); | |
| 101 | + if (settlementPlan == null) | |
| 102 | + { | |
| 103 | + return NotFound(); | |
| 104 | + } | |
| 105 | + | |
| 106 | + var vm = new AdminSettlementPlanFormViewModel | |
| 107 | + { | |
| 108 | + Title = _l["Edit settlement plan"].Value, | |
| 109 | + SettlementPlan = settlementPlan, | |
| 110 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", settlementPlan.TripId), | |
| 111 | + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", settlementPlan.CreatedByUserId) | |
| 112 | + }; | |
| 113 | + return View(vm); | |
| 114 | + } | |
| 115 | + | |
| 116 | + // POST: Admin/SettlementPlans/Edit/5 | |
| 117 | + [HttpPost] | |
| 118 | + [ValidateAntiForgeryToken] | |
| 119 | + public async Task<IActionResult> Edit(Guid id, AdminSettlementPlanFormViewModel vm) | |
| 120 | + { | |
| 121 | + if (id != vm.SettlementPlan.Id) | |
| 122 | + { | |
| 123 | + return NotFound(); | |
| 124 | + } | |
| 125 | + | |
| 126 | + if (ModelState.IsValid) | |
| 127 | + { | |
| 128 | + try | |
| 129 | + { | |
| 130 | + await _service.UpdateAsync(vm.SettlementPlan); | |
| 131 | + } | |
| 132 | + catch (DbUpdateConcurrencyException) | |
| 133 | + { | |
| 134 | + if (!await _service.ExistsAsync(vm.SettlementPlan.Id)) | |
| 135 | + { | |
| 136 | + return NotFound(); | |
| 137 | + } | |
| 138 | + else | |
| 139 | + { | |
| 140 | + throw; | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + return RedirectToAction(nameof(Index)); | |
| 145 | + } | |
| 146 | + | |
| 147 | + vm.Title = _l["Edit settlement plan"].Value; | |
| 148 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SettlementPlan.TripId); | |
| 149 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SettlementPlan.CreatedByUserId); | |
| 150 | + return View(vm); | |
| 151 | + } | |
| 152 | + | |
| 153 | + // GET: Admin/SettlementPlans/Delete/5 | |
| 154 | + public async Task<IActionResult> Delete(Guid? id) | |
| 155 | + { | |
| 156 | + if (id == null) | |
| 157 | + { | |
| 158 | + return NotFound(); | |
| 159 | + } | |
| 160 | + | |
| 161 | + var settlementPlan = await _service.GetByIdAsync(id.Value); | |
| 162 | + if (settlementPlan == null) | |
| 163 | + { | |
| 164 | + return NotFound(); | |
| 165 | + } | |
| 166 | + | |
| 167 | + return View(new AdminDeleteViewModel<SettlementPlanBllDto> | |
| 168 | + { | |
| 169 | + Title = _l["Delete settlement plan"].Value, | |
| 170 | + Item = settlementPlan | |
| 171 | + }); | |
| 172 | + } | |
| 173 | + | |
| 174 | + // POST: Admin/SettlementPlans/Delete/5 | |
| 175 | + [HttpPost, ActionName("Delete")] | |
| 176 | + [ValidateAntiForgeryToken] | |
| 177 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 178 | + { | |
| 179 | + await _service.DeleteAsync(id); | |
| 180 | + return RedirectToAction(nameof(Index)); | |
| 181 | + } | |
| 182 | + } | |
| 183 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/SplitPresetsController.cs +111 −0
| @@ -0,0 +1,111 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.Extensions.Localization; | |
| 7 | +using WebApp.Areas.Admin.Models; | |
| 8 | + | |
| 9 | +namespace WebApp.Areas.Admin.Controllers; | |
| 10 | + | |
| 11 | +[Area("Admin")] | |
| 12 | +[Authorize(Roles = "admin")] | |
| 13 | +public class SplitPresetsController : Controller | |
| 14 | +{ | |
| 15 | + private readonly ISplitPresetAdminService _service; | |
| 16 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 17 | + | |
| 18 | + public SplitPresetsController(ISplitPresetAdminService service, | |
| 19 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 20 | + { | |
| 21 | + _service = service; | |
| 22 | + _l = l; | |
| 23 | + } | |
| 24 | + | |
| 25 | + public async Task<IActionResult> Index(string? search) | |
| 26 | + { | |
| 27 | + var presets = await _service.GetAllAsync(search); | |
| 28 | + | |
| 29 | + var vm = new AdminSplitPresetIndexViewModel | |
| 30 | + { | |
| 31 | + Title = _l["Split presets"].Value, | |
| 32 | + Items = presets.OrderByDescending(s => s.Id).ToList(), | |
| 33 | + CurrentSearch = search | |
| 34 | + }; | |
| 35 | + return View(vm); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task<IActionResult> Details(Guid? id) | |
| 39 | + { | |
| 40 | + if (id == null) | |
| 41 | + { | |
| 42 | + return NotFound(); | |
| 43 | + } | |
| 44 | + | |
| 45 | + var splitPreset = await _service.GetByIdAsync(id.Value); | |
| 46 | + if (splitPreset == null) | |
| 47 | + { | |
| 48 | + return NotFound(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + return View(new AdminDetailsViewModel<SplitPresetBllDto> | |
| 52 | + { | |
| 53 | + Title = _l["Split preset details"].Value, | |
| 54 | + Item = splitPreset | |
| 55 | + }); | |
| 56 | + } | |
| 57 | + | |
| 58 | + public async Task<IActionResult> Create() | |
| 59 | + { | |
| 60 | + return View(new AdminSplitPresetFormViewModel | |
| 61 | + { | |
| 62 | + Title = _l["New split preset"].Value, | |
| 63 | + TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 64 | + UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 65 | + }); | |
| 66 | + } | |
| 67 | + | |
| 68 | + [HttpPost] | |
| 69 | + [ValidateAntiForgeryToken] | |
| 70 | + public async Task<IActionResult> Create(AdminSplitPresetFormViewModel vm) | |
| 71 | + { | |
| 72 | + if (ModelState.IsValid) | |
| 73 | + { | |
| 74 | + await _service.CreateAsync(vm.SplitPreset); | |
| 75 | + return RedirectToAction(nameof(Index)); | |
| 76 | + } | |
| 77 | + | |
| 78 | + vm.Title = _l["New split preset"].Value; | |
| 79 | + vm.TripList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.SplitPreset.TripId); | |
| 80 | + vm.UserList = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.SplitPreset.CreatedById); | |
| 81 | + return View(vm); | |
| 82 | + } | |
| 83 | + | |
| 84 | + public async Task<IActionResult> Delete(Guid? id) | |
| 85 | + { | |
| 86 | + if (id == null) | |
| 87 | + { | |
| 88 | + return NotFound(); | |
| 89 | + } | |
| 90 | + | |
| 91 | + var splitPreset = await _service.GetByIdAsync(id.Value); | |
| 92 | + if (splitPreset == null) | |
| 93 | + { | |
| 94 | + return NotFound(); | |
| 95 | + } | |
| 96 | + | |
| 97 | + return View(new AdminDeleteViewModel<SplitPresetBllDto> | |
| 98 | + { | |
| 99 | + Title = _l["Delete split preset"].Value, | |
| 100 | + Item = splitPreset | |
| 101 | + }); | |
| 102 | + } | |
| 103 | + | |
| 104 | + [HttpPost, ActionName("Delete")] | |
| 105 | + [ValidateAntiForgeryToken] | |
| 106 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 107 | + { | |
| 108 | + await _service.DeleteAsync(id); | |
| 109 | + return RedirectToAction(nameof(Index)); | |
| 110 | + } | |
| 111 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +184 −0
| @@ -0,0 +1,184 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 7 | +using Microsoft.EntityFrameworkCore; | |
| 8 | +using Microsoft.Extensions.Localization; | |
| 9 | +using WebApp.Areas.Admin.Models; | |
| 10 | + | |
| 11 | +namespace WebApp.Areas.Admin.Controllers | |
| 12 | +{ | |
| 13 | + [Area("Admin")] | |
| 14 | + [Authorize(Roles = "admin")] | |
| 15 | + public class TripParticipantsController : Controller | |
| 16 | + { | |
| 17 | + private readonly ITripParticipantAdminService _service; | |
| 18 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 19 | + | |
| 20 | + public TripParticipantsController(ITripParticipantAdminService service, | |
| 21 | + IStringLocalizer<App.Resources.Views.Shared> l) | |
| 22 | + { | |
| 23 | + _service = service; | |
| 24 | + _l = l; | |
| 25 | + } | |
| 26 | + | |
| 27 | + // GET: Admin/TripParticipants | |
| 28 | + public async Task<IActionResult> Index(Guid? tripId, string? search) | |
| 29 | + { | |
| 30 | + var participants = await _service.GetAllAsync(tripId, search); | |
| 31 | + | |
| 32 | + var vm = new AdminTripParticipantIndexViewModel | |
| 33 | + { | |
| 34 | + Title = _l["Trip participants"].Value, | |
| 35 | + Items = participants.OrderByDescending(tp => tp.JoinedAt).ToList(), | |
| 36 | + Trips = (await _service.GetTripsAsync()).OrderBy(t => t.Name).ToList(), | |
| 37 | + CurrentTripId = tripId, | |
| 38 | + CurrentSearch = search | |
| 39 | + }; | |
| 40 | + return View(vm); | |
| 41 | + } | |
| 42 | + | |
| 43 | + // GET: Admin/TripParticipants/Details/5 | |
| 44 | + public async Task<IActionResult> Details(Guid? id) | |
| 45 | + { | |
| 46 | + if (id == null) | |
| 47 | + { | |
| 48 | + return NotFound(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + var tripParticipant = await _service.GetByIdAsync(id.Value); | |
| 52 | + if (tripParticipant == null) | |
| 53 | + { | |
| 54 | + return NotFound(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + return View(new AdminDetailsViewModel<TripParticipantBllDto> | |
| 58 | + { | |
| 59 | + Title = _l["Trip participant details"].Value, | |
| 60 | + Item = tripParticipant | |
| 61 | + }); | |
| 62 | + } | |
| 63 | + | |
| 64 | + // GET: Admin/TripParticipants/Create | |
| 65 | + public async Task<IActionResult> Create() | |
| 66 | + { | |
| 67 | + var vm = new AdminTripParticipantFormViewModel | |
| 68 | + { | |
| 69 | + Title = _l["New trip participant"].Value, | |
| 70 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 71 | + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email") | |
| 72 | + }; | |
| 73 | + return View(vm); | |
| 74 | + } | |
| 75 | + | |
| 76 | + // POST: Admin/TripParticipants/Create | |
| 77 | + [HttpPost] | |
| 78 | + [ValidateAntiForgeryToken] | |
| 79 | + public async Task<IActionResult> Create(AdminTripParticipantFormViewModel vm) | |
| 80 | + { | |
| 81 | + if (ModelState.IsValid) | |
| 82 | + { | |
| 83 | + await _service.CreateAsync(vm.TripParticipant); | |
| 84 | + return RedirectToAction(nameof(Index)); | |
| 85 | + } | |
| 86 | + | |
| 87 | + vm.Title = _l["New trip participant"].Value; | |
| 88 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.TripParticipant.TripId); | |
| 89 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.TripParticipant.UserId); | |
| 90 | + return View(vm); | |
| 91 | + } | |
| 92 | + | |
| 93 | + // GET: Admin/TripParticipants/Edit/5 | |
| 94 | + public async Task<IActionResult> Edit(Guid? id) | |
| 95 | + { | |
| 96 | + if (id == null) | |
| 97 | + { | |
| 98 | + return NotFound(); | |
| 99 | + } | |
| 100 | + | |
| 101 | + var tripParticipant = await _service.GetByIdAsync(id.Value); | |
| 102 | + if (tripParticipant == null) | |
| 103 | + { | |
| 104 | + return NotFound(); | |
| 105 | + } | |
| 106 | + | |
| 107 | + var vm = new AdminTripParticipantFormViewModel | |
| 108 | + { | |
| 109 | + Title = _l["Edit trip participant"].Value, | |
| 110 | + TripParticipant = tripParticipant, | |
| 111 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", tripParticipant.TripId), | |
| 112 | + UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", tripParticipant.UserId) | |
| 113 | + }; | |
| 114 | + return View(vm); | |
| 115 | + } | |
| 116 | + | |
| 117 | + // POST: Admin/TripParticipants/Edit/5 | |
| 118 | + [HttpPost] | |
| 119 | + [ValidateAntiForgeryToken] | |
| 120 | + public async Task<IActionResult> Edit(Guid id, AdminTripParticipantFormViewModel vm) | |
| 121 | + { | |
| 122 | + if (id != vm.TripParticipant.Id) | |
| 123 | + { | |
| 124 | + return NotFound(); | |
| 125 | + } | |
| 126 | + | |
| 127 | + if (ModelState.IsValid) | |
| 128 | + { | |
| 129 | + try | |
| 130 | + { | |
| 131 | + await _service.UpdateAsync(vm.TripParticipant); | |
| 132 | + } | |
| 133 | + catch (DbUpdateConcurrencyException) | |
| 134 | + { | |
| 135 | + if (!await _service.ExistsAsync(vm.TripParticipant.Id)) | |
| 136 | + { | |
| 137 | + return NotFound(); | |
| 138 | + } | |
| 139 | + else | |
| 140 | + { | |
| 141 | + throw; | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + return RedirectToAction(nameof(Index)); | |
| 146 | + } | |
| 147 | + | |
| 148 | + vm.Title = _l["Edit trip participant"].Value; | |
| 149 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name", vm.TripParticipant.TripId); | |
| 150 | + vm.UserList = new SelectList(await _service.GetUsersAsync(), "Id", "Email", vm.TripParticipant.UserId); | |
| 151 | + return View(vm); | |
| 152 | + } | |
| 153 | + | |
| 154 | + // GET: Admin/TripParticipants/Delete/5 | |
| 155 | + public async Task<IActionResult> Delete(Guid? id) | |
| 156 | + { | |
| 157 | + if (id == null) | |
| 158 | + { | |
| 159 | + return NotFound(); | |
| 160 | + } | |
| 161 | + | |
| 162 | + var tripParticipant = await _service.GetByIdAsync(id.Value); | |
| 163 | + if (tripParticipant == null) | |
| 164 | + { | |
| 165 | + return NotFound(); | |
| 166 | + } | |
| 167 | + | |
| 168 | + return View(new AdminDeleteViewModel<TripParticipantBllDto> | |
| 169 | + { | |
| 170 | + Title = _l["Delete trip participant"].Value, | |
| 171 | + Item = tripParticipant | |
| 172 | + }); | |
| 173 | + } | |
| 174 | + | |
| 175 | + // POST: Admin/TripParticipants/Delete/5 | |
| 176 | + [HttpPost, ActionName("Delete")] | |
| 177 | + [ValidateAntiForgeryToken] | |
| 178 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 179 | + { | |
| 180 | + await _service.DeleteAsync(id); | |
| 181 | + return RedirectToAction(nameof(Index)); | |
| 182 | + } | |
| 183 | + } | |
| 184 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/TripsController.cs +136 −0
| @@ -0,0 +1,136 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 7 | +using Microsoft.EntityFrameworkCore; | |
| 8 | +using Microsoft.Extensions.Localization; | |
| 9 | +using WebApp.Areas.Admin.Models; | |
| 10 | + | |
| 11 | +namespace WebApp.Areas.Admin.Controllers | |
| 12 | +{ | |
| 13 | + [Area("Admin")] | |
| 14 | + [Authorize(Roles = "admin")] | |
| 15 | + public class TripsController : Controller | |
| 16 | + { | |
| 17 | + private readonly ITripAdminService _service; | |
| 18 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 19 | + | |
| 20 | + public TripsController(ITripAdminService service, IStringLocalizer<App.Resources.Views.Shared> l) | |
| 21 | + { | |
| 22 | + _service = service; | |
| 23 | + _l = l; | |
| 24 | + } | |
| 25 | + | |
| 26 | + public async Task<IActionResult> Index(string? search) | |
| 27 | + { | |
| 28 | + var trips = await _service.GetAllAsync(search); | |
| 29 | + | |
| 30 | + return View(new AdminTripIndexViewModel | |
| 31 | + { | |
| 32 | + Title = _l["Trips"].Value, | |
| 33 | + Items = trips.OrderByDescending(t => t.CreatedAt).ToList(), | |
| 34 | + CurrentSearch = search | |
| 35 | + }); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task<IActionResult> Details(Guid? id) | |
| 39 | + { | |
| 40 | + if (id == null) return NotFound(); | |
| 41 | + var trip = await _service.GetByIdAsync(id.Value); | |
| 42 | + if (trip == null) return NotFound(); | |
| 43 | + | |
| 44 | + return View(new AdminDetailsViewModel<TripBllDto> | |
| 45 | + { | |
| 46 | + Title = _l["Trip details"].Value, | |
| 47 | + Item = trip | |
| 48 | + }); | |
| 49 | + } | |
| 50 | + | |
| 51 | + public async Task<IActionResult> Create() | |
| 52 | + { | |
| 53 | + return View(new AdminTripFormViewModel | |
| 54 | + { | |
| 55 | + Title = _l["New trip"].Value, | |
| 56 | + CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code") | |
| 57 | + }); | |
| 58 | + } | |
| 59 | + | |
| 60 | + [HttpPost] | |
| 61 | + [ValidateAntiForgeryToken] | |
| 62 | + public async Task<IActionResult> Create(AdminTripFormViewModel vm) | |
| 63 | + { | |
| 64 | + if (ModelState.IsValid) | |
| 65 | + { | |
| 66 | + await _service.CreateAsync(vm.Trip); | |
| 67 | + return RedirectToAction(nameof(Index)); | |
| 68 | + } | |
| 69 | + | |
| 70 | + vm.Title = _l["New trip"].Value; | |
| 71 | + vm.CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", vm.Trip.DefaultCurrencyId); | |
| 72 | + return View(vm); | |
| 73 | + } | |
| 74 | + | |
| 75 | + public async Task<IActionResult> Edit(Guid? id) | |
| 76 | + { | |
| 77 | + if (id == null) return NotFound(); | |
| 78 | + var trip = await _service.GetByIdAsync(id.Value); | |
| 79 | + if (trip == null) return NotFound(); | |
| 80 | + | |
| 81 | + return View(new AdminTripFormViewModel | |
| 82 | + { | |
| 83 | + Title = _l["Edit trip"].Value, | |
| 84 | + Trip = trip, | |
| 85 | + CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", trip.DefaultCurrencyId) | |
| 86 | + }); | |
| 87 | + } | |
| 88 | + | |
| 89 | + [HttpPost] | |
| 90 | + [ValidateAntiForgeryToken] | |
| 91 | + public async Task<IActionResult> Edit(Guid id, AdminTripFormViewModel vm) | |
| 92 | + { | |
| 93 | + if (id != vm.Trip.Id) return NotFound(); | |
| 94 | + | |
| 95 | + if (ModelState.IsValid) | |
| 96 | + { | |
| 97 | + try | |
| 98 | + { | |
| 99 | + await _service.UpdateAsync(vm.Trip); | |
| 100 | + } | |
| 101 | + catch (DbUpdateConcurrencyException) | |
| 102 | + { | |
| 103 | + if (!await _service.ExistsAsync(vm.Trip.Id)) return NotFound(); | |
| 104 | + throw; | |
| 105 | + } | |
| 106 | + | |
| 107 | + return RedirectToAction(nameof(Index)); | |
| 108 | + } | |
| 109 | + | |
| 110 | + vm.Title = _l["Edit trip"].Value; | |
| 111 | + vm.CurrencyList = new SelectList(await _service.GetAllCurrenciesAsync(), "Id", "Code", vm.Trip.DefaultCurrencyId); | |
| 112 | + return View(vm); | |
| 113 | + } | |
| 114 | + | |
| 115 | + public async Task<IActionResult> Delete(Guid? id) | |
| 116 | + { | |
| 117 | + if (id == null) return NotFound(); | |
| 118 | + var trip = await _service.GetByIdAsync(id.Value); | |
| 119 | + if (trip == null) return NotFound(); | |
| 120 | + | |
| 121 | + return View(new AdminDeleteViewModel<TripBllDto> | |
| 122 | + { | |
| 123 | + Title = _l["Delete trip"].Value, | |
| 124 | + Item = trip | |
| 125 | + }); | |
| 126 | + } | |
| 127 | + | |
| 128 | + [HttpPost, ActionName("Delete")] | |
| 129 | + [ValidateAntiForgeryToken] | |
| 130 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 131 | + { | |
| 132 | + await _service.DeleteAsync(id); | |
| 133 | + return RedirectToAction(nameof(Index)); | |
| 134 | + } | |
| 135 | + } | |
| 136 | +} |
added SplitApp/WebApp/Areas/Admin/Controllers/UsersController.cs +197 −0
| @@ -0,0 +1,197 @@ | ||
| 1 | +using App.Domain.Identity; | |
| 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 WebApp.Areas.Admin.Models; | |
| 8 | + | |
| 9 | +namespace 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/WebApp/Areas/Admin/Controllers/WishlistController.cs +125 −0
| @@ -0,0 +1,125 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services.Admin; | |
| 3 | +using App.Domain; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Mvc; | |
| 6 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 7 | +using Microsoft.Extensions.Localization; | |
| 8 | +using WebApp.Areas.Admin.Models; | |
| 9 | + | |
| 10 | +namespace WebApp.Areas.Admin.Controllers; | |
| 11 | + | |
| 12 | +[Area("Admin")] | |
| 13 | +[Authorize(Roles = "admin")] | |
| 14 | +public class WishlistController : Controller | |
| 15 | +{ | |
| 16 | + private readonly IWishlistAdminService _service; | |
| 17 | + private readonly IStringLocalizer<App.Resources.Views.Shared> _l; | |
| 18 | + | |
| 19 | + public WishlistController(IWishlistAdminService service, IStringLocalizer<App.Resources.Views.Shared> l) | |
| 20 | + { | |
| 21 | + _service = service; | |
| 22 | + _l = l; | |
| 23 | + } | |
| 24 | + | |
| 25 | + public async Task<IActionResult> Index(string? search) | |
| 26 | + { | |
| 27 | + var items = await _service.GetAllAsync(search); | |
| 28 | + | |
| 29 | + var vm = new AdminWishlistIndexViewModel | |
| 30 | + { | |
| 31 | + Title = _l["Wishlist"].Value, | |
| 32 | + Items = items.OrderByDescending(w => w.Id).ToList(), | |
| 33 | + CurrentSearch = search | |
| 34 | + }; | |
| 35 | + return View(vm); | |
| 36 | + } | |
| 37 | + | |
| 38 | + public async Task<IActionResult> Details(Guid id) | |
| 39 | + { | |
| 40 | + var item = await _service.GetByIdAsync(id); | |
| 41 | + if (item == null) return NotFound(); | |
| 42 | + | |
| 43 | + return View(new AdminDetailsViewModel<TripWishlistItemBllDto> | |
| 44 | + { | |
| 45 | + Title = _l["Wishlist item details"].Value, | |
| 46 | + Item = item | |
| 47 | + }); | |
| 48 | + } | |
| 49 | + | |
| 50 | + public async Task<IActionResult> Create() | |
| 51 | + { | |
| 52 | + var vm = new AdminWishlistFormViewModel | |
| 53 | + { | |
| 54 | + Title = _l["New wishlist item"].Value, | |
| 55 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 56 | + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name") | |
| 57 | + }; | |
| 58 | + return View(vm); | |
| 59 | + } | |
| 60 | + | |
| 61 | + [HttpPost] | |
| 62 | + [ValidateAntiForgeryToken] | |
| 63 | + public async Task<IActionResult> Create(AdminWishlistFormViewModel vm) | |
| 64 | + { | |
| 65 | + if (ModelState.IsValid) | |
| 66 | + { | |
| 67 | + await _service.CreateAsync(vm.Item); | |
| 68 | + return RedirectToAction(nameof(Index)); | |
| 69 | + } | |
| 70 | + vm.Title = _l["New wishlist item"].Value; | |
| 71 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"); | |
| 72 | + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name"); | |
| 73 | + return View(vm); | |
| 74 | + } | |
| 75 | + | |
| 76 | + public async Task<IActionResult> Edit(Guid id) | |
| 77 | + { | |
| 78 | + var item = await _service.GetByIdAsync(id); | |
| 79 | + if (item == null) return NotFound(); | |
| 80 | + var vm = new AdminWishlistFormViewModel | |
| 81 | + { | |
| 82 | + Title = _l["Edit wishlist item"].Value, | |
| 83 | + Item = item, | |
| 84 | + TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"), | |
| 85 | + UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name") | |
| 86 | + }; | |
| 87 | + return View(vm); | |
| 88 | + } | |
| 89 | + | |
| 90 | + [HttpPost] | |
| 91 | + [ValidateAntiForgeryToken] | |
| 92 | + public async Task<IActionResult> Edit(Guid id, AdminWishlistFormViewModel vm) | |
| 93 | + { | |
| 94 | + if (id != vm.Item.Id) return NotFound(); | |
| 95 | + if (ModelState.IsValid) | |
| 96 | + { | |
| 97 | + await _service.UpdateAsync(vm.Item); | |
| 98 | + return RedirectToAction(nameof(Index)); | |
| 99 | + } | |
| 100 | + vm.Title = _l["Edit wishlist item"].Value; | |
| 101 | + vm.TripList = new SelectList(await _service.GetTripsAsync(), "Id", "Name"); | |
| 102 | + vm.UserList = new SelectList((await _service.GetUsersAsync()).Select(u => new { u.Id, Name = u.FirstName + " " + u.LastName }).ToList(), "Id", "Name"); | |
| 103 | + return View(vm); | |
| 104 | + } | |
| 105 | + | |
| 106 | + public async Task<IActionResult> Delete(Guid id) | |
| 107 | + { | |
| 108 | + var item = await _service.GetByIdAsync(id); | |
| 109 | + if (item == null) return NotFound(); | |
| 110 | + | |
| 111 | + return View(new AdminDeleteViewModel<TripWishlistItemBllDto> | |
| 112 | + { | |
| 113 | + Title = _l["Delete wishlist item"].Value, | |
| 114 | + Item = item | |
| 115 | + }); | |
| 116 | + } | |
| 117 | + | |
| 118 | + [HttpPost, ActionName("Delete")] | |
| 119 | + [ValidateAntiForgeryToken] | |
| 120 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 121 | + { | |
| 122 | + await _service.DeleteAsync(id); | |
| 123 | + return RedirectToAction(nameof(Index)); | |
| 124 | + } | |
| 125 | +} |
added SplitApp/WebApp/Areas/Admin/Models/AdminViewModels.cs +287 −0
| @@ -0,0 +1,287 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 3 | + | |
| 4 | +namespace 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/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/WebApp/Areas/Admin/Views/BudgetCategories/Delete.cshtml +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<BudgetCategoryBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"]</h1> | |
| 5 | + | |
| 6 | +<h3>@Localizer["AreYouSure"]</h3> | |
| 7 | +<div> | |
| 8 | + <h4>@Localizer["BudgetCategory"]</h4> | |
| 9 | + <hr /> | |
| 10 | + <dl class="row"> | |
| 11 | + <dt class="col-sm-2">@Localizer["Name"]</dt> | |
| 12 | + <dd class="col-sm-10">@Model.Item.Name</dd> | |
| 13 | + | |
| 14 | + <dt class="col-sm-2">@Localizer["Trip"]</dt> | |
| 15 | + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-2">@Localizer["PlannedAmount"]</dt> | |
| 18 | + <dd class="col-sm-10">@Model.Item.PlannedAmount</dd> | |
| 19 | + </dl> | |
| 20 | + | |
| 21 | + <form asp-action="Delete"> | |
| 22 | + <input type="hidden" asp-for="Item.Id" /> | |
| 23 | + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> | | |
| 24 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 25 | + </form> | |
| 26 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Details.cshtml +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<BudgetCategoryBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Details"]</h1> | |
| 5 | + | |
| 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["IconName"]</dt> | |
| 17 | + <dd class="col-sm-10">@Model.Item.IconName</dd> | |
| 18 | + | |
| 19 | + <dt class="col-sm-2">@Localizer["PlannedAmount"]</dt> | |
| 20 | + <dd class="col-sm-10">@Model.Item.PlannedAmount</dd> | |
| 21 | + | |
| 22 | + <dt class="col-sm-2">@Localizer["DisplayOrder"]</dt> | |
| 23 | + <dd class="col-sm-10">@Model.Item.DisplayOrder</dd> | |
| 24 | + </dl> | |
| 25 | +</div> | |
| 26 | +<div> | |
| 27 | + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> | | |
| 28 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 29 | +</div> |
added 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/WebApp/Areas/Admin/Views/BudgetCategories/Index.cshtml +78 −0
| @@ -0,0 +1,78 @@ | ||
| 1 | +@model AdminBudgetCategoryIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-tags me-2"></i>@Localizer["BudgetCategories"]</h1> | |
| 7 | + <p class="lead">@Localizer["Manage budget categories for trips"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"> | |
| 10 | + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"] | |
| 11 | + </a> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="admin-card mb-3"> | |
| 15 | + <div class="admin-card-body"> | |
| 16 | + <form method="get" class="row g-2 align-items-end"> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <select name="tripId" class="form-select"> | |
| 19 | + <option value="">— @Localizer["All"] —</option> | |
| 20 | + @foreach (var t in Model.Trips) | |
| 21 | + { | |
| 22 | + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option> | |
| 23 | + } | |
| 24 | + </select> | |
| 25 | + </div> | |
| 26 | + <div class="col-auto"> | |
| 27 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 28 | + </div> | |
| 29 | + <div class="col-auto"> | |
| 30 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 31 | + </div> | |
| 32 | + </form> | |
| 33 | + </div> | |
| 34 | +</div> | |
| 35 | + | |
| 36 | +<div class="admin-card"> | |
| 37 | + <div class="admin-card-body p-0"> | |
| 38 | + @if (Model.Items.Any()) | |
| 39 | + { | |
| 40 | + <table class="table admin-table mb-0"> | |
| 41 | + <thead> | |
| 42 | + <tr> | |
| 43 | + <th>@Localizer["Name"]</th> | |
| 44 | + <th>@Localizer["Trip"]</th> | |
| 45 | + <th class="text-end">@Localizer["PlannedAmount"]</th> | |
| 46 | + <th>@Localizer["DisplayOrder"]</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.Name</td> | |
| 55 | + <td>@item.Trip?.Name</td> | |
| 56 | + <td class="text-end fw-bold">@item.PlannedAmount</td> | |
| 57 | + <td>@item.DisplayOrder</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/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/WebApp/Areas/Admin/Views/Currencies/Delete.cshtml +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<CurrencyBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"]</h1> | |
| 5 | + | |
| 6 | +<h3>@Localizer["AreYouSure"]</h3> | |
| 7 | +<div> | |
| 8 | + <h4>@Localizer["Currency"]</h4> | |
| 9 | + <hr /> | |
| 10 | + <dl class="row"> | |
| 11 | + <dt class="col-sm-2">@Localizer["Code"]</dt> | |
| 12 | + <dd class="col-sm-10">@Model.Item.Code</dd> | |
| 13 | + | |
| 14 | + <dt class="col-sm-2">@Localizer["Name"]</dt> | |
| 15 | + <dd class="col-sm-10">@Model.Item.Name</dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-2">@Localizer["Symbol"]</dt> | |
| 18 | + <dd class="col-sm-10">@Model.Item.Symbol</dd> | |
| 19 | + </dl> | |
| 20 | + | |
| 21 | + <form asp-action="Delete"> | |
| 22 | + <input type="hidden" asp-for="Item.Id" /> | |
| 23 | + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> | | |
| 24 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 25 | + </form> | |
| 26 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/Currencies/Details.cshtml +23 −0
| @@ -0,0 +1,23 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<CurrencyBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Details"]</h1> | |
| 5 | + | |
| 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 | +</div> | |
| 20 | +<div> | |
| 21 | + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> | | |
| 22 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 23 | +</div> |
added 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/WebApp/Areas/Admin/Views/Currencies/Index.cshtml +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +@model AdminCurrencyIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-currency-exchange me-2"></i>@Localizer["Currencies"]</h1> | |
| 7 | + <p class="lead">@Localizer["Manage supported currencies"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"> | |
| 10 | + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"] | |
| 11 | + </a> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="admin-card mb-3"> | |
| 15 | + <div class="admin-card-body"> | |
| 16 | + <form method="get" class="row g-2 align-items-end"> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 19 | + </div> | |
| 20 | + <div class="col-auto"> | |
| 21 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 22 | + </div> | |
| 23 | + </form> | |
| 24 | + </div> | |
| 25 | +</div> | |
| 26 | + | |
| 27 | +<div class="admin-card"> | |
| 28 | + <div class="admin-card-body p-0"> | |
| 29 | + @if (Model.Items.Any()) | |
| 30 | + { | |
| 31 | + <table class="table admin-table mb-0"> | |
| 32 | + <thead> | |
| 33 | + <tr> | |
| 34 | + <th>@Localizer["Code"]</th> | |
| 35 | + <th>@Localizer["Name"]</th> | |
| 36 | + <th>@Localizer["Symbol"]</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><strong>@item.Code</strong></td> | |
| 45 | + <td>@item.Name</td> | |
| 46 | + <td>@item.Symbol</td> | |
| 47 | + <td class="text-end"> | |
| 48 | + <div class="admin-action-group"> | |
| 49 | + <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> | |
| 50 | + <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> | |
| 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/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/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<App.Domain.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/WebApp/Areas/Admin/Views/Expenses/Delete.cshtml +32 −0
| @@ -0,0 +1,32 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<ExpenseBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"]</h1> | |
| 5 | + | |
| 6 | +<h3>@Localizer["AreYouSure"]</h3> | |
| 7 | +<div> | |
| 8 | + <h4>@Localizer["Expense"]</h4> | |
| 9 | + <hr /> | |
| 10 | + <dl class="row"> | |
| 11 | + <dt class="col-sm-2">@Localizer["Description"]</dt> | |
| 12 | + <dd class="col-sm-10">@Model.Item.Description</dd> | |
| 13 | + | |
| 14 | + <dt class="col-sm-2">@Localizer["Amount"]</dt> | |
| 15 | + <dd class="col-sm-10">@Model.Item.Amount</dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-2">@Localizer["ExpenseDate"]</dt> | |
| 18 | + <dd class="col-sm-10">@Model.Item.ExpenseDate.ToString("d")</dd> | |
| 19 | + | |
| 20 | + <dt class="col-sm-2">@Localizer["Trip"]</dt> | |
| 21 | + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd> | |
| 22 | + | |
| 23 | + <dt class="col-sm-2">@Localizer["PaidByUser"]</dt> | |
| 24 | + <dd class="col-sm-10">@Model.Item.PaidByUser?.Email</dd> | |
| 25 | + </dl> | |
| 26 | + | |
| 27 | + <form asp-action="Delete"> | |
| 28 | + <input type="hidden" asp-for="Item.Id" /> | |
| 29 | + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> | | |
| 30 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 31 | + </form> | |
| 32 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/Expenses/Details.cshtml +38 −0
| @@ -0,0 +1,38 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<ExpenseBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Details"]</h1> | |
| 5 | + | |
| 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["SplitMethod"]</dt> | |
| 20 | + <dd class="col-sm-10">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.SplitMethod)</dd> | |
| 21 | + | |
| 22 | + <dt class="col-sm-2">@Localizer["Trip"]</dt> | |
| 23 | + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd> | |
| 24 | + | |
| 25 | + <dt class="col-sm-2">@Localizer["PaidByUser"]</dt> | |
| 26 | + <dd class="col-sm-10">@Model.Item.PaidByUser?.Email</dd> | |
| 27 | + | |
| 28 | + <dt class="col-sm-2">@Localizer["Currency"]</dt> | |
| 29 | + <dd class="col-sm-10">@Model.Item.Currency?.Code</dd> | |
| 30 | + | |
| 31 | + <dt class="col-sm-2">@Localizer["BudgetCategory"]</dt> | |
| 32 | + <dd class="col-sm-10">@Model.Item.BudgetCategory?.Name</dd> | |
| 33 | + </dl> | |
| 34 | +</div> | |
| 35 | +<div> | |
| 36 | + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> | | |
| 37 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 38 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/Expenses/Index.cshtml +82 −0
| @@ -0,0 +1,82 @@ | ||
| 1 | +@model AdminExpenseIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-cash-coin me-2"></i>@Localizer["Expenses"]</h1> | |
| 7 | + <p class="lead">@Localizer["Manage all expenses across trips"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"> | |
| 10 | + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"] | |
| 11 | + </a> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="admin-card mb-3"> | |
| 15 | + <div class="admin-card-body"> | |
| 16 | + <form method="get" class="row g-2 align-items-end"> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <select name="tripId" class="form-select"> | |
| 19 | + <option value="">— @Localizer["All"] —</option> | |
| 20 | + @foreach (var t in Model.Trips) | |
| 21 | + { | |
| 22 | + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option> | |
| 23 | + } | |
| 24 | + </select> | |
| 25 | + </div> | |
| 26 | + <div class="col-auto"> | |
| 27 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 28 | + </div> | |
| 29 | + <div class="col-auto"> | |
| 30 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 31 | + </div> | |
| 32 | + </form> | |
| 33 | + </div> | |
| 34 | +</div> | |
| 35 | + | |
| 36 | +<div class="admin-card"> | |
| 37 | + <div class="admin-card-body p-0"> | |
| 38 | + @if (Model.Items.Any()) | |
| 39 | + { | |
| 40 | + <table class="table admin-table mb-0"> | |
| 41 | + <thead> | |
| 42 | + <tr> | |
| 43 | + <th>@Localizer["Description"]</th> | |
| 44 | + <th>@Localizer["Trip"]</th> | |
| 45 | + <th>@Localizer["PaidByUser"]</th> | |
| 46 | + <th>@Localizer["SplitMethod"]</th> | |
| 47 | + <th class="text-end">@Localizer["Amount"]</th> | |
| 48 | + <th>@Localizer["ExpenseDate"]</th> | |
| 49 | + <th class="text-end">@Localizer["Actions"]</th> | |
| 50 | + </tr> | |
| 51 | + </thead> | |
| 52 | + <tbody> | |
| 53 | + @foreach (var item in Model.Items) | |
| 54 | + { | |
| 55 | + <tr> | |
| 56 | + <td>@item.Description</td> | |
| 57 | + <td>@item.Trip?.Name</td> | |
| 58 | + <td>@item.PaidByUser?.Email</td> | |
| 59 | + <td>@WebApp.Helpers.EnumHelper.GetDisplayName(item.SplitMethod)</td> | |
| 60 | + <td class="text-end fw-bold">@item.Amount</td> | |
| 61 | + <td>@item.ExpenseDate.ToString("d")</td> | |
| 62 | + <td class="text-end"> | |
| 63 | + <div class="admin-action-group"> | |
| 64 | + <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> | |
| 65 | + <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> | |
| 66 | + <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> | |
| 67 | + </div> | |
| 68 | + </td> | |
| 69 | + </tr> | |
| 70 | + } | |
| 71 | + </tbody> | |
| 72 | + </table> | |
| 73 | + } | |
| 74 | + else | |
| 75 | + { | |
| 76 | + <div class="admin-empty"> | |
| 77 | + <i class="bi bi-inbox"></i> | |
| 78 | + <div>@Localizer["No items yet"]</div> | |
| 79 | + </div> | |
| 80 | + } | |
| 81 | + </div> | |
| 82 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/Invitations/Delete.cshtml +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<TripInvitationBllDto> | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-envelope-x text-danger"></i> @Localizer["Delete invitation"]</h1> | |
| 7 | + </div> | |
| 8 | + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a> | |
| 9 | +</div> | |
| 10 | + | |
| 11 | +<div class="admin-card border-danger"> | |
| 12 | + <div class="admin-card-body"> | |
| 13 | + <div class="alert alert-danger"> | |
| 14 | + <i class="bi bi-exclamation-triangle me-2"></i> | |
| 15 | + @Localizer["Are you sure you want to delete this invitation?"] | |
| 16 | + </div> | |
| 17 | + | |
| 18 | + <dl class="row"> | |
| 19 | + <dt class="col-sm-3">@Localizer["Trip"]</dt> | |
| 20 | + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd> | |
| 21 | + | |
| 22 | + <dt class="col-sm-3">@Localizer["InvitedBy"]</dt> | |
| 23 | + <dd class="col-sm-9">@Model.Item.InvitedByUser?.Email</dd> | |
| 24 | + | |
| 25 | + <dt class="col-sm-3">@Localizer["Token"]</dt> | |
| 26 | + <dd class="col-sm-9"><code>@Model.Item.Token</code></dd> | |
| 27 | + | |
| 28 | + <dt class="col-sm-3">@Localizer["Status"]</dt> | |
| 29 | + <dd class="col-sm-9">@Model.Item.Status</dd> | |
| 30 | + </dl> | |
| 31 | + | |
| 32 | + <form asp-action="Delete"> | |
| 33 | + <input type="hidden" name="id" value="@Model.Item.Id" /> | |
| 34 | + <div class="d-flex gap-2"> | |
| 35 | + <button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</button> | |
| 36 | + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a> | |
| 37 | + </div> | |
| 38 | + </form> | |
| 39 | + </div> | |
| 40 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/Invitations/Details.cshtml +41 −0
| @@ -0,0 +1,41 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<TripInvitationBllDto> | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-envelope-paper"></i> @Localizer["Invitation details"]</h1> | |
| 7 | + </div> | |
| 8 | + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a> | |
| 9 | +</div> | |
| 10 | + | |
| 11 | +<div class="admin-card"> | |
| 12 | + <div class="admin-card-body"> | |
| 13 | + <dl class="row"> | |
| 14 | + <dt class="col-sm-3">@Localizer["Trip"]</dt> | |
| 15 | + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-3">@Localizer["InvitedBy"]</dt> | |
| 18 | + <dd class="col-sm-9">@Model.Item.InvitedByUser?.Email</dd> | |
| 19 | + | |
| 20 | + <dt class="col-sm-3">@Localizer["Token"]</dt> | |
| 21 | + <dd class="col-sm-9"><code>@Model.Item.Token</code></dd> | |
| 22 | + | |
| 23 | + <dt class="col-sm-3">@Localizer["Status"]</dt> | |
| 24 | + <dd class="col-sm-9"><span class="badge bg-secondary">@Model.Item.Status</span></dd> | |
| 25 | + | |
| 26 | + <dt class="col-sm-3">@Localizer["ExpiresAt"]</dt> | |
| 27 | + <dd class="col-sm-9">@Model.Item.ExpiresAt.ToString("yyyy-MM-dd HH:mm")</dd> | |
| 28 | + | |
| 29 | + @if (Model.Item.RespondedAt.HasValue) | |
| 30 | + { | |
| 31 | + <dt class="col-sm-3">@Localizer["RespondedAt"]</dt> | |
| 32 | + <dd class="col-sm-9">@Model.Item.RespondedAt.Value.ToString("yyyy-MM-dd HH:mm")</dd> | |
| 33 | + } | |
| 34 | + </dl> | |
| 35 | + | |
| 36 | + <div class="d-flex gap-2"> | |
| 37 | + <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> | |
| 38 | + <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> | |
| 39 | + </div> | |
| 40 | + </div> | |
| 41 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/Invitations/Index.cshtml +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +@model AdminInvitationIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-envelope me-2"></i>@Localizer["Invitations"]</h1> | |
| 7 | + <p class="lead">@Localizer["Review invitation tokens and statuses"]</p> | |
| 8 | + </div> | |
| 9 | +</div> | |
| 10 | + | |
| 11 | +<div class="admin-card mb-3"> | |
| 12 | + <div class="admin-card-body"> | |
| 13 | + <form method="get" class="row g-2 align-items-end"> | |
| 14 | + <div class="col-auto"> | |
| 15 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 16 | + </div> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 19 | + </div> | |
| 20 | + </form> | |
| 21 | + </div> | |
| 22 | +</div> | |
| 23 | + | |
| 24 | +<div class="admin-card"> | |
| 25 | + <div class="admin-card-body p-0"> | |
| 26 | + @if (Model.Items.Any()) | |
| 27 | + { | |
| 28 | + <table class="table admin-table mb-0"> | |
| 29 | + <thead> | |
| 30 | + <tr> | |
| 31 | + <th>@Localizer["Token"]</th> | |
| 32 | + <th>@Localizer["Trip"]</th> | |
| 33 | + <th>@Localizer["InvitedBy"]</th> | |
| 34 | + <th>@Localizer["ExpiresAt"]</th> | |
| 35 | + <th>@Localizer["Status"]</th> | |
| 36 | + <th class="text-end">@Localizer["Actions"]</th> | |
| 37 | + </tr> | |
| 38 | + </thead> | |
| 39 | + <tbody> | |
| 40 | + @foreach (var item in Model.Items) | |
| 41 | + { | |
| 42 | + <tr> | |
| 43 | + <td><code>@(item.Token.Length > 16 ? item.Token[..16] + "..." : item.Token)</code></td> | |
| 44 | + <td>@item.Trip?.Name</td> | |
| 45 | + <td>@item.InvitedByUser?.Email</td> | |
| 46 | + <td>@item.ExpiresAt.ToString("g")</td> | |
| 47 | + <td><span class="badge status-@item.Status.ToString().ToLower()">@WebApp.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/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/WebApp/Areas/Admin/Views/Polls/Delete.cshtml +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<TripPollBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"] @Localizer["Poll"]</h1> | |
| 5 | + | |
| 6 | +<div class="alert alert-danger"> | |
| 7 | + @Localizer["AreYouSure"] - "<strong>@Model.Item.Question</strong>" (@Model.Item.Trip?.Name)? | |
| 8 | +</div> | |
| 9 | + | |
| 10 | +<form asp-action="Delete" method="post"> | |
| 11 | + <input type="hidden" asp-for="Item.Id" /> | |
| 12 | + <button type="submit" class="btn btn-danger">@Localizer["Delete"]</button> | |
| 13 | + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a> | |
| 14 | +</form> |
added SplitApp/WebApp/Areas/Admin/Views/Polls/Details.cshtml +38 −0
| @@ -0,0 +1,38 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<TripPollBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Poll"] @Localizer["Details"]</h1> | |
| 5 | + | |
| 6 | +<div class="card shadow-sm"> | |
| 7 | + <div class="card-body"> | |
| 8 | + <h5>@Model.Item.Question</h5> | |
| 9 | + <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> | |
| 10 | + <p>@Localizer["Multi-Vote"]: @(Model.Item.AllowMultipleVotes ? Localizer["Yes"] : Localizer["No"]) | @Localizer["Anonymous"]: @(Model.Item.IsAnonymous ? Localizer["Yes"] : Localizer["No"])</p> | |
| 11 | + @if (Model.Item.ClosedAt.HasValue) | |
| 12 | + { | |
| 13 | + <p class="text-danger">@Localizer["Closed at"]: @Model.Item.ClosedAt.Value.ToString("g")</p> | |
| 14 | + } | |
| 15 | + | |
| 16 | + @if (Model.Item.Options != null) | |
| 17 | + { | |
| 18 | + <h6 class="mt-3">@Localizer["Options"]</h6> | |
| 19 | + <table class="table"> | |
| 20 | + <thead> | |
| 21 | + <tr><th>@Localizer["Text"]</th><th>@Localizer["Votes"]</th></tr> | |
| 22 | + </thead> | |
| 23 | + <tbody> | |
| 24 | + @foreach (var option in Model.Item.Options.OrderBy(o => o.DisplayOrder)) | |
| 25 | + { | |
| 26 | + <tr> | |
| 27 | + <td>@option.Text</td> | |
| 28 | + <td>@option.VoteCount</td> | |
| 29 | + </tr> | |
| 30 | + } | |
| 31 | + </tbody> | |
| 32 | + </table> | |
| 33 | + } | |
| 34 | + </div> | |
| 35 | +</div> | |
| 36 | + | |
| 37 | +<a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary mt-3">@Localizer["Edit"]</a> | |
| 38 | +<a asp-action="Index" class="btn btn-outline-secondary mt-3">@Localizer["Back to List"]</a> |
added 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/WebApp/Areas/Admin/Views/Polls/Index.cshtml +75 −0
| @@ -0,0 +1,75 @@ | ||
| 1 | +@model AdminPollIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-bar-chart me-2"></i>@Localizer["Polls"]</h1> | |
| 7 | + <p class="lead">@Localizer["Manage polls across trips"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"> | |
| 10 | + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"] | |
| 11 | + </a> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="admin-card mb-3"> | |
| 15 | + <div class="admin-card-body"> | |
| 16 | + <form method="get" class="row g-2 align-items-end"> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 19 | + </div> | |
| 20 | + <div class="col-auto"> | |
| 21 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 22 | + </div> | |
| 23 | + </form> | |
| 24 | + </div> | |
| 25 | +</div> | |
| 26 | + | |
| 27 | +<div class="admin-card"> | |
| 28 | + <div class="admin-card-body p-0"> | |
| 29 | + @if (Model.Items.Any()) | |
| 30 | + { | |
| 31 | + <table class="table admin-table mb-0"> | |
| 32 | + <thead> | |
| 33 | + <tr> | |
| 34 | + <th>@Localizer["Question"]</th> | |
| 35 | + <th>@Localizer["Trip"]</th> | |
| 36 | + <th>@Localizer["Created By"]</th> | |
| 37 | + <th>@Localizer["Options"]</th> | |
| 38 | + <th>@Localizer["Multi-Vote"]</th> | |
| 39 | + <th>@Localizer["Anonymous"]</th> | |
| 40 | + <th>@Localizer["Closed"]</th> | |
| 41 | + <th class="text-end">@Localizer["Actions"]</th> | |
| 42 | + </tr> | |
| 43 | + </thead> | |
| 44 | + <tbody> | |
| 45 | + @foreach (var poll in Model.Items) | |
| 46 | + { | |
| 47 | + <tr> | |
| 48 | + <td>@poll.Question</td> | |
| 49 | + <td>@poll.Trip?.Name</td> | |
| 50 | + <td>@(poll.CreatedByUser != null ? $"{poll.CreatedByUser.FirstName} {poll.CreatedByUser.LastName}" : "")</td> | |
| 51 | + <td>@(poll.Options?.Count ?? 0)</td> | |
| 52 | + <td>@(poll.AllowMultipleVotes ? Localizer["Yes"] : Localizer["No"])</td> | |
| 53 | + <td>@(poll.IsAnonymous ? Localizer["Yes"] : Localizer["No"])</td> | |
| 54 | + <td>@(poll.ClosedAt.HasValue ? Localizer["Yes"] : Localizer["No"])</td> | |
| 55 | + <td class="text-end"> | |
| 56 | + <div class="admin-action-group"> | |
| 57 | + <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> | |
| 58 | + <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> | |
| 59 | + <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> | |
| 60 | + </div> | |
| 61 | + </td> | |
| 62 | + </tr> | |
| 63 | + } | |
| 64 | + </tbody> | |
| 65 | + </table> | |
| 66 | + } | |
| 67 | + else | |
| 68 | + { | |
| 69 | + <div class="admin-empty"> | |
| 70 | + <i class="bi bi-inbox"></i> | |
| 71 | + <div>@Localizer["No items yet"]</div> | |
| 72 | + </div> | |
| 73 | + } | |
| 74 | + </div> | |
| 75 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/SettlementPayments/Delete.cshtml +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<SettlementPaymentBllDto> | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-cash-stack text-danger"></i> @Localizer["Delete settlement payment"]</h1> | |
| 7 | + </div> | |
| 8 | + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a> | |
| 9 | +</div> | |
| 10 | + | |
| 11 | +<div class="admin-card border-danger"> | |
| 12 | + <div class="admin-card-body"> | |
| 13 | + <div class="alert alert-danger"> | |
| 14 | + <i class="bi bi-exclamation-triangle me-2"></i> | |
| 15 | + @Localizer["Are you sure you want to delete this settlement payment?"] | |
| 16 | + </div> | |
| 17 | + | |
| 18 | + <dl class="row"> | |
| 19 | + <dt class="col-sm-3">@Localizer["From user"]</dt> | |
| 20 | + <dd class="col-sm-9">@Model.Item.FromUserFullName</dd> | |
| 21 | + | |
| 22 | + <dt class="col-sm-3">@Localizer["To user"]</dt> | |
| 23 | + <dd class="col-sm-9">@Model.Item.ToUserFullName</dd> | |
| 24 | + | |
| 25 | + <dt class="col-sm-3">@Localizer["Amount"]</dt> | |
| 26 | + <dd class="col-sm-9">@Model.Item.Amount.ToString("0.00")</dd> | |
| 27 | + | |
| 28 | + <dt class="col-sm-3">@Localizer["Status"]</dt> | |
| 29 | + <dd class="col-sm-9">@Model.Item.Status</dd> | |
| 30 | + </dl> | |
| 31 | + | |
| 32 | + <form asp-action="Delete"> | |
| 33 | + <input type="hidden" name="id" value="@Model.Item.Id" /> | |
| 34 | + <div class="d-flex gap-2"> | |
| 35 | + <button type="submit" class="btn btn-danger"><i class="bi bi-trash me-1"></i>@Localizer["Delete"]</button> | |
| 36 | + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a> | |
| 37 | + </div> | |
| 38 | + </form> | |
| 39 | + </div> | |
| 40 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Details.cshtml +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<SettlementPaymentBllDto> | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-cash-coin"></i> @Localizer["Settlement payment details"]</h1> | |
| 7 | + </div> | |
| 8 | + <a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>@Localizer["Back to list"]</a> | |
| 9 | +</div> | |
| 10 | + | |
| 11 | +<div class="admin-card"> | |
| 12 | + <div class="admin-card-body"> | |
| 13 | + <dl class="row"> | |
| 14 | + <dt class="col-sm-3">@Localizer["From user"]</dt> | |
| 15 | + <dd class="col-sm-9">@Model.Item.FromUserFullName <small class="text-muted">@Model.Item.FromUser?.Email</small></dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-3">@Localizer["To user"]</dt> | |
| 18 | + <dd class="col-sm-9">@Model.Item.ToUserFullName <small class="text-muted">@Model.Item.ToUser?.Email</small></dd> | |
| 19 | + | |
| 20 | + <dt class="col-sm-3">@Localizer["Amount"]</dt> | |
| 21 | + <dd class="col-sm-9">@Model.Item.Amount.ToString("0.00")</dd> | |
| 22 | + | |
| 23 | + <dt class="col-sm-3">@Localizer["Status"]</dt> | |
| 24 | + <dd class="col-sm-9"><span class="badge bg-secondary">@Model.Item.Status</span></dd> | |
| 25 | + | |
| 26 | + @if (Model.Item.MarkedPaidAt.HasValue) | |
| 27 | + { | |
| 28 | + <dt class="col-sm-3">@Localizer["MarkedPaidAt"]</dt> | |
| 29 | + <dd class="col-sm-9">@Model.Item.MarkedPaidAt.Value.ToString("yyyy-MM-dd HH:mm")</dd> | |
| 30 | + } | |
| 31 | + | |
| 32 | + @if (Model.Item.ConfirmedAt.HasValue) | |
| 33 | + { | |
| 34 | + <dt class="col-sm-3">@Localizer["ConfirmedAt"]</dt> | |
| 35 | + <dd class="col-sm-9">@Model.Item.ConfirmedAt.Value.ToString("yyyy-MM-dd HH:mm")</dd> | |
| 36 | + } | |
| 37 | + </dl> | |
| 38 | + | |
| 39 | + <div class="d-flex gap-2"> | |
| 40 | + <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> | |
| 41 | + <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> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/SettlementPayments/Index.cshtml +69 −0
| @@ -0,0 +1,69 @@ | ||
| 1 | +@model AdminSettlementPaymentIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-credit-card-2-front me-2"></i>@Localizer["SettlementPayments"]</h1> | |
| 7 | + <p class="lead">@Localizer["Review settlement payments across trips"]</p> | |
| 8 | + </div> | |
| 9 | +</div> | |
| 10 | + | |
| 11 | +<div class="admin-card mb-3"> | |
| 12 | + <div class="admin-card-body"> | |
| 13 | + <form method="get" class="row g-2 align-items-end"> | |
| 14 | + <div class="col-auto"> | |
| 15 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 16 | + </div> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 19 | + </div> | |
| 20 | + </form> | |
| 21 | + </div> | |
| 22 | +</div> | |
| 23 | + | |
| 24 | +<div class="admin-card"> | |
| 25 | + <div class="admin-card-body p-0"> | |
| 26 | + @if (Model.Items.Any()) | |
| 27 | + { | |
| 28 | + <table class="table admin-table mb-0"> | |
| 29 | + <thead> | |
| 30 | + <tr> | |
| 31 | + <th>@Localizer["FromUser"]</th> | |
| 32 | + <th>@Localizer["ToUser"]</th> | |
| 33 | + <th class="text-end">@Localizer["Amount"]</th> | |
| 34 | + <th>@Localizer["MarkedPaidAt"]</th> | |
| 35 | + <th>@Localizer["ConfirmedAt"]</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.FromUser?.Email</td> | |
| 45 | + <td>@item.ToUser?.Email</td> | |
| 46 | + <td class="text-end fw-bold">@item.Amount.ToString("F2")</td> | |
| 47 | + <td>@item.MarkedPaidAt?.ToString("g")</td> | |
| 48 | + <td>@item.ConfirmedAt?.ToString("g")</td> | |
| 49 | + <td><span class="badge status-@item.Status.ToString().ToLower()">@WebApp.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td> | |
| 50 | + <td class="text-end"> | |
| 51 | + <div class="admin-action-group"> | |
| 52 | + <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> | |
| 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/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<App.Domain.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/WebApp/Areas/Admin/Views/SettlementPlans/Delete.cshtml +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<SettlementPlanBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"]</h1> | |
| 5 | + | |
| 6 | +<h3>@Localizer["AreYouSure"]</h3> | |
| 7 | +<div> | |
| 8 | + <h4>@Localizer["SettlementPlan"]</h4> | |
| 9 | + <hr /> | |
| 10 | + <dl class="row"> | |
| 11 | + <dt class="col-sm-2">@Localizer["Trip"]</dt> | |
| 12 | + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd> | |
| 13 | + | |
| 14 | + <dt class="col-sm-2">@Localizer["TotalAmount"]</dt> | |
| 15 | + <dd class="col-sm-10">@Model.Item.TotalAmount</dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-2">@Localizer["Status"]</dt> | |
| 18 | + <dd class="col-sm-10">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd> | |
| 19 | + | |
| 20 | + <dt class="col-sm-2">@Localizer["CreatedByUser"]</dt> | |
| 21 | + <dd class="col-sm-10">@Model.Item.CreatedByUser?.Email</dd> | |
| 22 | + </dl> | |
| 23 | + | |
| 24 | + <form asp-action="Delete"> | |
| 25 | + <input type="hidden" asp-for="Item.Id" /> | |
| 26 | + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> | | |
| 27 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 28 | + </form> | |
| 29 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Details.cshtml +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<SettlementPlanBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Details"]</h1> | |
| 5 | + | |
| 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["CreatedByUser"]</dt> | |
| 14 | + <dd class="col-sm-10">@Model.Item.CreatedByUser?.Email</dd> | |
| 15 | + | |
| 16 | + <dt class="col-sm-2">@Localizer["TotalAmount"]</dt> | |
| 17 | + <dd class="col-sm-10">@Model.Item.TotalAmount</dd> | |
| 18 | + | |
| 19 | + <dt class="col-sm-2">@Localizer["Status"]</dt> | |
| 20 | + <dd class="col-sm-10">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd> | |
| 21 | + | |
| 22 | + <dt class="col-sm-2">@Localizer["CompletedAt"]</dt> | |
| 23 | + <dd class="col-sm-10">@Model.Item.CompletedAt?.ToString("d")</dd> | |
| 24 | + </dl> | |
| 25 | +</div> | |
| 26 | +<div> | |
| 27 | + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> | | |
| 28 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 29 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/SettlementPlans/Index.cshtml +75 −0
| @@ -0,0 +1,75 @@ | ||
| 1 | +@model AdminSettlementPlanIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-diagram-3 me-2"></i>@Localizer["SettlementPlans"]</h1> | |
| 7 | + <p class="lead">@Localizer["Manage settlement plans for trips"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"> | |
| 10 | + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"] | |
| 11 | + </a> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="admin-card mb-3"> | |
| 15 | + <div class="admin-card-body"> | |
| 16 | + <form method="get" class="row g-2 align-items-end"> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <select name="tripId" class="form-select"> | |
| 19 | + <option value="">— @Localizer["All"] —</option> | |
| 20 | + @foreach (var t in Model.Trips) | |
| 21 | + { | |
| 22 | + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option> | |
| 23 | + } | |
| 24 | + </select> | |
| 25 | + </div> | |
| 26 | + <div class="col-auto"> | |
| 27 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 28 | + </div> | |
| 29 | + </form> | |
| 30 | + </div> | |
| 31 | +</div> | |
| 32 | + | |
| 33 | +<div class="admin-card"> | |
| 34 | + <div class="admin-card-body p-0"> | |
| 35 | + @if (Model.Items.Any()) | |
| 36 | + { | |
| 37 | + <table class="table admin-table mb-0"> | |
| 38 | + <thead> | |
| 39 | + <tr> | |
| 40 | + <th>@Localizer["Trip"]</th> | |
| 41 | + <th class="text-end">@Localizer["TotalAmount"]</th> | |
| 42 | + <th>@Localizer["CreatedByUser"]</th> | |
| 43 | + <th>@Localizer["Status"]</th> | |
| 44 | + <th class="text-end">@Localizer["Actions"]</th> | |
| 45 | + </tr> | |
| 46 | + </thead> | |
| 47 | + <tbody> | |
| 48 | + @foreach (var item in Model.Items) | |
| 49 | + { | |
| 50 | + <tr> | |
| 51 | + <td>@item.Trip?.Name</td> | |
| 52 | + <td class="text-end fw-bold">@item.TotalAmount</td> | |
| 53 | + <td>@item.CreatedByUser?.Email</td> | |
| 54 | + <td><span class="badge status-@item.Status.ToString().ToLower()">@WebApp.Helpers.EnumHelper.GetDisplayName(item.Status)</span></td> | |
| 55 | + <td class="text-end"> | |
| 56 | + <div class="admin-action-group"> | |
| 57 | + <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> | |
| 58 | + <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> | |
| 59 | + <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> | |
| 60 | + </div> | |
| 61 | + </td> | |
| 62 | + </tr> | |
| 63 | + } | |
| 64 | + </tbody> | |
| 65 | + </table> | |
| 66 | + } | |
| 67 | + else | |
| 68 | + { | |
| 69 | + <div class="admin-empty"> | |
| 70 | + <i class="bi bi-inbox"></i> | |
| 71 | + <div>@Localizer["No items yet"]</div> | |
| 72 | + </div> | |
| 73 | + } | |
| 74 | + </div> | |
| 75 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/Shared/_Layout.cshtml +128 −0
| @@ -0,0 +1,128 @@ | ||
| 1 | +@using Microsoft.AspNetCore.Identity | |
| 2 | +@using App.Domain.Identity | |
| 3 | +@using WebApp.Areas.Admin.Models | |
| 4 | +@inject SignInManager<AppUser> _signInManager | |
| 5 | +@{ | |
| 6 | + var pageTitle = (Model as ITitledViewModel)?.Title; | |
| 7 | + if (string.IsNullOrWhiteSpace(pageTitle)) pageTitle = "Admin"; | |
| 8 | + var ctx = ViewContext.RouteData.Values; | |
| 9 | + var currentController = (ctx["controller"] as string ?? "").ToLowerInvariant(); | |
| 10 | + bool IsActive(string name) => currentController == name.ToLowerInvariant(); | |
| 11 | +} | |
| 12 | + | |
| 13 | +<!DOCTYPE html> | |
| 14 | +<html lang="@Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName"> | |
| 15 | +<head> | |
| 16 | + <meta charset="utf-8" /> | |
| 17 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 18 | + <meta name="theme-color" content="#e8604c" /> | |
| 19 | + <title>@pageTitle - SplitApp Admin</title> | |
| 20 | + | |
| 21 | + <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 22 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 23 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| 24 | + | |
| 25 | + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" /> | |
| 26 | + <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> | |
| 27 | + <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> | |
| 28 | + <link rel="stylesheet" href="~/css/splitapp-design.css" asp-append-version="true" /> | |
| 29 | + <link rel="stylesheet" href="~/css/admin.css" asp-append-version="true" /> | |
| 30 | +</head> | |
| 31 | +<body class="admin-body"> | |
| 32 | + <div id="sa-toast-container" class="sa-toast-container"></div> | |
| 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 | + <div class="admin-shell"> | |
| 39 | + <aside class="admin-sidebar"> | |
| 40 | + <div class="admin-sidebar-brand"> | |
| 41 | + <i class="bi bi-airplane-fill"></i> | |
| 42 | + <span>SplitApp</span> | |
| 43 | + <small class="admin-sidebar-brand-sub">Admin</small> | |
| 44 | + </div> | |
| 45 | + <nav class="admin-sidebar-nav"> | |
| 46 | + <a class="admin-nav-link @(IsActive("Dashboard") ? "active" : "")" asp-area="Admin" asp-controller="Dashboard" asp-action="Index"> | |
| 47 | + <i class="bi bi-speedometer2"></i><span>@Localizer["Dashboard"]</span> | |
| 48 | + </a> | |
| 49 | + <div class="admin-nav-section">@Localizer["Core"]</div> | |
| 50 | + <a class="admin-nav-link @(IsActive("Trips") ? "active" : "")" asp-area="Admin" asp-controller="Trips" asp-action="Index"> | |
| 51 | + <i class="bi bi-suitcase-lg"></i><span>@Localizer["Trips"]</span> | |
| 52 | + </a> | |
| 53 | + <a class="admin-nav-link @(IsActive("Expenses") ? "active" : "")" asp-area="Admin" asp-controller="Expenses" asp-action="Index"> | |
| 54 | + <i class="bi bi-cash-coin"></i><span>@Localizer["Expenses"]</span> | |
| 55 | + </a> | |
| 56 | + <a class="admin-nav-link @(IsActive("BudgetCategories") ? "active" : "")" asp-area="Admin" asp-controller="BudgetCategories" asp-action="Index"> | |
| 57 | + <i class="bi bi-tags"></i><span>@Localizer["Budget Categories"]</span> | |
| 58 | + </a> | |
| 59 | + <a class="admin-nav-link @(IsActive("Currencies") ? "active" : "")" asp-area="Admin" asp-controller="Currencies" asp-action="Index"> | |
| 60 | + <i class="bi bi-currency-exchange"></i><span>@Localizer["Currencies"]</span> | |
| 61 | + </a> | |
| 62 | + <div class="admin-nav-section">@Localizer["Activity"]</div> | |
| 63 | + <a class="admin-nav-link @(IsActive("Polls") ? "active" : "")" asp-area="Admin" asp-controller="Polls" asp-action="Index"> | |
| 64 | + <i class="bi bi-bar-chart"></i><span>@Localizer["Polls"]</span> | |
| 65 | + </a> | |
| 66 | + <a class="admin-nav-link @(IsActive("Wishlist") ? "active" : "")" asp-area="Admin" asp-controller="Wishlist" asp-action="Index"> | |
| 67 | + <i class="bi bi-stars"></i><span>@Localizer["Wishlist"]</span> | |
| 68 | + </a> | |
| 69 | + <a class="admin-nav-link @(IsActive("Invitations") ? "active" : "")" asp-area="Admin" asp-controller="Invitations" asp-action="Index"> | |
| 70 | + <i class="bi bi-envelope"></i><span>@Localizer["Invitations"]</span> | |
| 71 | + </a> | |
| 72 | + <div class="admin-nav-section">@Localizer["Settlements"]</div> | |
| 73 | + <a class="admin-nav-link @(IsActive("SettlementPlans") ? "active" : "")" asp-area="Admin" asp-controller="SettlementPlans" asp-action="Index"> | |
| 74 | + <i class="bi bi-diagram-3"></i><span>@Localizer["Settlement Plans"]</span> | |
| 75 | + </a> | |
| 76 | + <a class="admin-nav-link @(IsActive("SettlementPayments") ? "active" : "")" asp-area="Admin" asp-controller="SettlementPayments" asp-action="Index"> | |
| 77 | + <i class="bi bi-credit-card-2-front"></i><span>@Localizer["Settlement Payments"]</span> | |
| 78 | + </a> | |
| 79 | + <a class="admin-nav-link @(IsActive("SplitPresets") ? "active" : "")" asp-area="Admin" asp-controller="SplitPresets" asp-action="Index"> | |
| 80 | + <i class="bi bi-pie-chart"></i><span>@Localizer["Split Presets"]</span> | |
| 81 | + </a> | |
| 82 | + <a class="admin-nav-link @(IsActive("TripParticipants") ? "active" : "")" asp-area="Admin" asp-controller="TripParticipants" asp-action="Index"> | |
| 83 | + <i class="bi bi-people"></i><span>@Localizer["Trip Participants"]</span> | |
| 84 | + </a> | |
| 85 | + <div class="admin-nav-section">@Localizer["System"]</div> | |
| 86 | + <a class="admin-nav-link @(IsActive("Users") ? "active" : "")" asp-area="Admin" asp-controller="Users" asp-action="Index"> | |
| 87 | + <i class="bi bi-person-gear"></i><span>@Localizer["Users"]</span> | |
| 88 | + </a> | |
| 89 | + </nav> | |
| 90 | + <div class="admin-sidebar-footer"> | |
| 91 | + <a asp-area="" asp-controller="Home" asp-action="Index" class="admin-back-link"> | |
| 92 | + <i class="bi bi-arrow-left"></i>@Localizer["Back to site"] | |
| 93 | + </a> | |
| 94 | + </div> | |
| 95 | + </aside> | |
| 96 | + | |
| 97 | + <div class="admin-main"> | |
| 98 | + <header class="admin-topbar"> | |
| 99 | + <button class="admin-topbar-toggle d-md-none" type="button" onclick="document.body.classList.toggle('admin-sidebar-open')" aria-label="Toggle sidebar"> | |
| 100 | + <i class="bi bi-list"></i> | |
| 101 | + </button> | |
| 102 | + <div class="admin-topbar-title"> | |
| 103 | + <i class="bi bi-shield-lock"></i> @pageTitle | |
| 104 | + </div> | |
| 105 | + <div class="admin-topbar-actions"> | |
| 106 | + <partial name="_LanguageSelection" /> | |
| 107 | + <partial name="_LoginPartial" /> | |
| 108 | + </div> | |
| 109 | + </header> | |
| 110 | + | |
| 111 | + <main role="main" class="admin-content sa-animate-fade-in"> | |
| 112 | + @RenderBody() | |
| 113 | + </main> | |
| 114 | + | |
| 115 | + <footer class="admin-footer"> | |
| 116 | + <span><i class="bi bi-airplane-fill me-1"></i> SplitApp Admin © 2026 TalTech</span> | |
| 117 | + <span>@Thread.CurrentThread.CurrentUICulture.Name</span> | |
| 118 | + </footer> | |
| 119 | + </div> | |
| 120 | + </div> | |
| 121 | + | |
| 122 | + <script src="~/lib/jquery/dist/jquery.min.js"></script> | |
| 123 | + <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> | |
| 124 | + <script src="~/js/splitapp.js" asp-append-version="true"></script> | |
| 125 | + <script src="~/js/site.js" asp-append-version="true"></script> | |
| 126 | + @await RenderSectionAsync("Scripts", required: false) | |
| 127 | +</body> | |
| 128 | +</html> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/SplitPresets/Delete.cshtml +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<SplitPresetBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"]</h1> | |
| 5 | + | |
| 6 | +<h3>@Localizer["AreYouSure"]</h3> | |
| 7 | +<div> | |
| 8 | + <h4>@Localizer["SplitPreset"]</h4> | |
| 9 | + <hr /> | |
| 10 | + <dl class="row"> | |
| 11 | + <dt class="col-sm-2">@Localizer["Name"]</dt> | |
| 12 | + <dd class="col-sm-10">@Model.Item.Name</dd> | |
| 13 | + | |
| 14 | + <dt class="col-sm-2">@Localizer["SplitMethod"]</dt> | |
| 15 | + <dd class="col-sm-10">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.SplitMethod)</dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-2">@Localizer["Trip"]</dt> | |
| 18 | + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd> | |
| 19 | + | |
| 20 | + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt> | |
| 21 | + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd> | |
| 22 | + </dl> | |
| 23 | + | |
| 24 | + <form asp-action="Delete"> | |
| 25 | + <input type="hidden" asp-for="Item.Id" /> | |
| 26 | + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> | | |
| 27 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 28 | + </form> | |
| 29 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Details.cshtml +51 −0
| @@ -0,0 +1,51 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<SplitPresetBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Details"]</h1> | |
| 5 | + | |
| 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">@WebApp.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 | +</div> | |
| 23 | + | |
| 24 | +@if (Model.Item.Members != null && Model.Item.Members.Any()) | |
| 25 | +{ | |
| 26 | + <h5>@Localizer["Members"]</h5> | |
| 27 | + <table class="table"> | |
| 28 | + <thead> | |
| 29 | + <tr> | |
| 30 | + <th>@Localizer["User"]</th> | |
| 31 | + <th>@Localizer["ShareWeight"]</th> | |
| 32 | + <th>@Localizer["Percentage"]</th> | |
| 33 | + </tr> | |
| 34 | + </thead> | |
| 35 | + <tbody> | |
| 36 | + @foreach (var member in Model.Item.Members) | |
| 37 | + { | |
| 38 | + <tr> | |
| 39 | + <td>@member.UserFullName</td> | |
| 40 | + <td>@member.ShareWeight</td> | |
| 41 | + <td>@(member.Percentage != null ? $"{member.Percentage}%" : "")</td> | |
| 42 | + </tr> | |
| 43 | + } | |
| 44 | + </tbody> | |
| 45 | + </table> | |
| 46 | +} | |
| 47 | + | |
| 48 | +<div> | |
| 49 | + <a asp-action="Delete" asp-route-id="@Model.Item.Id">@Localizer["Delete"]</a> | | |
| 50 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 51 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Index.cshtml +66 −0
| @@ -0,0 +1,66 @@ | ||
| 1 | +@model AdminSplitPresetIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-pie-chart me-2"></i>@Localizer["SplitPresets"]</h1> | |
| 7 | + <p class="lead">@Localizer["Review split presets used on trips"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"><i class="bi bi-plus-circle me-1"></i>@Localizer["Create"]</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["SplitMethod"]</th> | |
| 34 | + <th>@Localizer["Trip"]</th> | |
| 35 | + <th>@Localizer["CreatedBy"]</th> | |
| 36 | + <th class="text-end">@Localizer["Actions"]</th> | |
| 37 | + </tr> | |
| 38 | + </thead> | |
| 39 | + <tbody> | |
| 40 | + @foreach (var item in Model.Items) | |
| 41 | + { | |
| 42 | + <tr> | |
| 43 | + <td>@item.Name</td> | |
| 44 | + <td>@WebApp.Helpers.EnumHelper.GetDisplayName(item.SplitMethod)</td> | |
| 45 | + <td>@item.Trip?.Name</td> | |
| 46 | + <td>@item.CreatedBy?.Email</td> | |
| 47 | + <td class="text-end"> | |
| 48 | + <div class="admin-action-group"> | |
| 49 | + <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> | |
| 50 | + <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> | |
| 51 | + </div> | |
| 52 | + </td> | |
| 53 | + </tr> | |
| 54 | + } | |
| 55 | + </tbody> | |
| 56 | + </table> | |
| 57 | + } | |
| 58 | + else | |
| 59 | + { | |
| 60 | + <div class="admin-empty"> | |
| 61 | + <i class="bi bi-inbox"></i> | |
| 62 | + <div>@Localizer["No items yet"]</div> | |
| 63 | + </div> | |
| 64 | + } | |
| 65 | + </div> | |
| 66 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/TripParticipants/Delete.cshtml +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<TripParticipantBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"]</h1> | |
| 5 | + | |
| 6 | +<h3>@Localizer["AreYouSure"]</h3> | |
| 7 | +<div> | |
| 8 | + <h4>@Localizer["TripParticipant"]</h4> | |
| 9 | + <hr /> | |
| 10 | + <dl class="row"> | |
| 11 | + <dt class="col-sm-2">@Localizer["Trip"]</dt> | |
| 12 | + <dd class="col-sm-10">@Model.Item.Trip?.Name</dd> | |
| 13 | + | |
| 14 | + <dt class="col-sm-2">@Localizer["User"]</dt> | |
| 15 | + <dd class="col-sm-10">@Model.Item.User?.Email</dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-2">@Localizer["Role"]</dt> | |
| 18 | + <dd class="col-sm-10">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.Role)</dd> | |
| 19 | + | |
| 20 | + <dt class="col-sm-2">@Localizer["IsActive"]</dt> | |
| 21 | + <dd class="col-sm-10">@(Model.Item.IsActive ? Localizer["Yes"] : Localizer["No"])</dd> | |
| 22 | + </dl> | |
| 23 | + | |
| 24 | + <form asp-action="Delete"> | |
| 25 | + <input type="hidden" asp-for="Item.Id" /> | |
| 26 | + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> | | |
| 27 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 28 | + </form> | |
| 29 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Details.cshtml +35 −0
| @@ -0,0 +1,35 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<TripParticipantBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Details"]</h1> | |
| 5 | + | |
| 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">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.Role)</dd> | |
| 18 | + | |
| 19 | + <dt class="col-sm-2">@Localizer["Nickname"]</dt> | |
| 20 | + <dd class="col-sm-10">@Model.Item.Nickname</dd> | |
| 21 | + | |
| 22 | + <dt class="col-sm-2">@Localizer["JoinedAt"]</dt> | |
| 23 | + <dd class="col-sm-10">@Model.Item.JoinedAt.ToString("d")</dd> | |
| 24 | + | |
| 25 | + <dt class="col-sm-2">@Localizer["LeftAt"]</dt> | |
| 26 | + <dd class="col-sm-10">@Model.Item.LeftAt?.ToString("d")</dd> | |
| 27 | + | |
| 28 | + <dt class="col-sm-2">@Localizer["IsActive"]</dt> | |
| 29 | + <dd class="col-sm-10">@(Model.Item.IsActive ? Localizer["Yes"] : Localizer["No"])</dd> | |
| 30 | + </dl> | |
| 31 | +</div> | |
| 32 | +<div> | |
| 33 | + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> | | |
| 34 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 35 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/TripParticipants/Index.cshtml +80 −0
| @@ -0,0 +1,80 @@ | ||
| 1 | +@model AdminTripParticipantIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-people me-2"></i>@Localizer["TripParticipants"]</h1> | |
| 7 | + <p class="lead">@Localizer["Manage trip participants and roles"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"> | |
| 10 | + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"] | |
| 11 | + </a> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="admin-card mb-3"> | |
| 15 | + <div class="admin-card-body"> | |
| 16 | + <form method="get" class="row g-2 align-items-end"> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <select name="tripId" class="form-select"> | |
| 19 | + <option value="">— @Localizer["All"] —</option> | |
| 20 | + @foreach (var t in Model.Trips) | |
| 21 | + { | |
| 22 | + <option value="@t.Id" selected="@(Model.CurrentTripId?.ToString() == t.Id.ToString())">@t.Name</option> | |
| 23 | + } | |
| 24 | + </select> | |
| 25 | + </div> | |
| 26 | + <div class="col-auto"> | |
| 27 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 28 | + </div> | |
| 29 | + <div class="col-auto"> | |
| 30 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 31 | + </div> | |
| 32 | + </form> | |
| 33 | + </div> | |
| 34 | +</div> | |
| 35 | + | |
| 36 | +<div class="admin-card"> | |
| 37 | + <div class="admin-card-body p-0"> | |
| 38 | + @if (Model.Items.Any()) | |
| 39 | + { | |
| 40 | + <table class="table admin-table mb-0"> | |
| 41 | + <thead> | |
| 42 | + <tr> | |
| 43 | + <th>@Localizer["User"]</th> | |
| 44 | + <th>@Localizer["Trip"]</th> | |
| 45 | + <th>@Localizer["Role"]</th> | |
| 46 | + <th>@Localizer["JoinedAt"]</th> | |
| 47 | + <th>@Localizer["IsActive"]</th> | |
| 48 | + <th class="text-end">@Localizer["Actions"]</th> | |
| 49 | + </tr> | |
| 50 | + </thead> | |
| 51 | + <tbody> | |
| 52 | + @foreach (var item in Model.Items) | |
| 53 | + { | |
| 54 | + <tr> | |
| 55 | + <td>@item.User?.Email</td> | |
| 56 | + <td>@item.Trip?.Name</td> | |
| 57 | + <td>@WebApp.Helpers.EnumHelper.GetDisplayName(item.Role)</td> | |
| 58 | + <td>@item.JoinedAt.ToString("d")</td> | |
| 59 | + <td>@(item.IsActive ? Localizer["Yes"] : Localizer["No"])</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/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<App.Domain.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/WebApp/Areas/Admin/Views/Trips/Delete.cshtml +29 −0
| @@ -0,0 +1,29 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<TripBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"]</h1> | |
| 5 | + | |
| 6 | +<h3>@Localizer["AreYouSure"]</h3> | |
| 7 | +<div> | |
| 8 | + <h4>@Localizer["Trip"]</h4> | |
| 9 | + <hr /> | |
| 10 | + <dl class="row"> | |
| 11 | + <dt class="col-sm-2">@Localizer["Name"]</dt> | |
| 12 | + <dd class="col-sm-10">@Model.Item.Name</dd> | |
| 13 | + | |
| 14 | + <dt class="col-sm-2">@Localizer["Destination"]</dt> | |
| 15 | + <dd class="col-sm-10">@Model.Item.Destination</dd> | |
| 16 | + | |
| 17 | + <dt class="col-sm-2">@Localizer["Status"]</dt> | |
| 18 | + <dd class="col-sm-10">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd> | |
| 19 | + | |
| 20 | + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt> | |
| 21 | + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd> | |
| 22 | + </dl> | |
| 23 | + | |
| 24 | + <form asp-action="Delete"> | |
| 25 | + <input type="hidden" asp-for="Item.Id" /> | |
| 26 | + <input type="submit" value="@Localizer["Delete"]" class="btn btn-danger" /> | | |
| 27 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 28 | + </form> | |
| 29 | +</div> |
added SplitApp/WebApp/Areas/Admin/Views/Trips/Details.cshtml +38 −0
| @@ -0,0 +1,38 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<TripBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Details"]</h1> | |
| 5 | + | |
| 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["Description"]</dt> | |
| 14 | + <dd class="col-sm-10">@Model.Item.Description</dd> | |
| 15 | + | |
| 16 | + <dt class="col-sm-2">@Localizer["Destination"]</dt> | |
| 17 | + <dd class="col-sm-10">@Model.Item.Destination</dd> | |
| 18 | + | |
| 19 | + <dt class="col-sm-2">@Localizer["StartDate"]</dt> | |
| 20 | + <dd class="col-sm-10">@Model.Item.StartDate?.ToString("d")</dd> | |
| 21 | + | |
| 22 | + <dt class="col-sm-2">@Localizer["EndDate"]</dt> | |
| 23 | + <dd class="col-sm-10">@Model.Item.EndDate?.ToString("d")</dd> | |
| 24 | + | |
| 25 | + <dt class="col-sm-2">@Localizer["Status"]</dt> | |
| 26 | + <dd class="col-sm-10">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.Status)</dd> | |
| 27 | + | |
| 28 | + <dt class="col-sm-2">@Localizer["DefaultCurrency"]</dt> | |
| 29 | + <dd class="col-sm-10">@Model.Item.DefaultCurrency?.Code</dd> | |
| 30 | + | |
| 31 | + <dt class="col-sm-2">@Localizer["CreatedBy"]</dt> | |
| 32 | + <dd class="col-sm-10">@Model.Item.CreatedBy?.Email</dd> | |
| 33 | + </dl> | |
| 34 | +</div> | |
| 35 | +<div> | |
| 36 | + <a asp-action="Edit" asp-route-id="@Model.Item.Id">@Localizer["Edit"]</a> | | |
| 37 | + <a asp-action="Index">@Localizer["Back"]</a> | |
| 38 | +</div> |
added 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<App.Domain.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/WebApp/Areas/Admin/Views/Trips/Index.cshtml +71 −0
| @@ -0,0 +1,71 @@ | ||
| 1 | +@model AdminTripIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-suitcase-lg me-2"></i>@Localizer["Trips"]</h1> | |
| 7 | + <p class="lead">@Localizer["Manage all trips across the platform"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"> | |
| 10 | + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"] | |
| 11 | + </a> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="admin-card mb-3"> | |
| 15 | + <div class="admin-card-body"> | |
| 16 | + <form method="get" class="row g-2 align-items-end"> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 19 | + </div> | |
| 20 | + <div class="col-auto"> | |
| 21 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 22 | + </div> | |
| 23 | + </form> | |
| 24 | + </div> | |
| 25 | +</div> | |
| 26 | + | |
| 27 | +<div class="admin-card"> | |
| 28 | + <div class="admin-card-body p-0"> | |
| 29 | + @if (Model.Items.Any()) | |
| 30 | + { | |
| 31 | + <table class="table admin-table mb-0"> | |
| 32 | + <thead> | |
| 33 | + <tr> | |
| 34 | + <th>@Localizer["Name"]</th> | |
| 35 | + <th>@Localizer["Destination"]</th> | |
| 36 | + <th>@Localizer["StartDate"]</th> | |
| 37 | + <th>@Localizer["CreatedBy"]</th> | |
| 38 | + <th>@Localizer["Status"]</th> | |
| 39 | + <th class="text-end">@Localizer["Actions"]</th> | |
| 40 | + </tr> | |
| 41 | + </thead> | |
| 42 | + <tbody> | |
| 43 | + @foreach (var item in Model.Items) | |
| 44 | + { | |
| 45 | + <tr> | |
| 46 | + <td>@item.Name</td> | |
| 47 | + <td>@item.Destination</td> | |
| 48 | + <td>@item.StartDate?.ToString("d")</td> | |
| 49 | + <td>@item.CreatedBy?.Email</td> | |
| 50 | + <td><span class="badge status-@item.Status.ToString().ToLower()">@WebApp.Helpers.EnumHelper.GetDisplayName(item.Status)</span></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/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/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/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/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/WebApp/Areas/Admin/Views/Users/Index.cshtml +46 −0
| @@ -0,0 +1,46 @@ | ||
| 1 | +@model WebApp.Areas.Admin.Models.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/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<App.Domain.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<App.Domain.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/WebApp/Areas/Admin/Views/Wishlist/Delete.cshtml +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDeleteViewModel<TripWishlistItemBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Delete"] @Localizer["Wishlist Item"]</h1> | |
| 5 | + | |
| 6 | +<div class="alert alert-danger"> | |
| 7 | + @Localizer["AreYouSure"] - <strong>@Model.Item.Title</strong> (@Model.Item.Trip?.Name)? | |
| 8 | +</div> | |
| 9 | + | |
| 10 | +<form asp-action="Delete" method="post"> | |
| 11 | + <input type="hidden" asp-for="Item.Id" /> | |
| 12 | + <button type="submit" class="btn btn-danger">@Localizer["Delete"]</button> | |
| 13 | + <a asp-action="Index" class="btn btn-outline-secondary">@Localizer["Cancel"]</a> | |
| 14 | +</form> |
added SplitApp/WebApp/Areas/Admin/Views/Wishlist/Details.cshtml +36 −0
| @@ -0,0 +1,36 @@ | ||
| 1 | +@using App.BLL.DTO | |
| 2 | +@model AdminDetailsViewModel<TripWishlistItemBllDto> | |
| 3 | + | |
| 4 | +<h1>@Localizer["Wishlist Item"] @Localizer["Details"]</h1> | |
| 5 | + | |
| 6 | +<div class="card shadow-sm"> | |
| 7 | + <div class="card-body"> | |
| 8 | + <dl class="row"> | |
| 9 | + <dt class="col-sm-3">@Localizer["Title"]</dt> | |
| 10 | + <dd class="col-sm-9">@Model.Item.Title</dd> | |
| 11 | + <dt class="col-sm-3">@Localizer["Category"]</dt> | |
| 12 | + <dd class="col-sm-9">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.Category)</dd> | |
| 13 | + <dt class="col-sm-3">@Localizer["Priority"]</dt> | |
| 14 | + <dd class="col-sm-9">@WebApp.Helpers.EnumHelper.GetDisplayName(Model.Item.Priority)</dd> | |
| 15 | + <dt class="col-sm-3">@Localizer["Trip"]</dt> | |
| 16 | + <dd class="col-sm-9">@Model.Item.Trip?.Name</dd> | |
| 17 | + <dt class="col-sm-3">@Localizer["Added By"]</dt> | |
| 18 | + <dd class="col-sm-9">@(Model.Item.AddedByUser != null ? $"{Model.Item.AddedByUser.FirstName} {Model.Item.AddedByUser.LastName}" : "")</dd> | |
| 19 | + <dt class="col-sm-3">@Localizer["Description"]</dt> | |
| 20 | + <dd class="col-sm-9">@Model.Item.Description</dd> | |
| 21 | + <dt class="col-sm-3">@Localizer["Estimated Cost"]</dt> | |
| 22 | + <dd class="col-sm-9">@Model.Item.EstimatedCost?.ToString("N2")</dd> | |
| 23 | + <dt class="col-sm-3">@Localizer["URL"]</dt> | |
| 24 | + <dd class="col-sm-9">@Model.Item.Url</dd> | |
| 25 | + <dt class="col-sm-3">@Localizer["Location"]</dt> | |
| 26 | + <dd class="col-sm-9">@Model.Item.Location</dd> | |
| 27 | + <dt class="col-sm-3">@Localizer["Completed"]</dt> | |
| 28 | + <dd class="col-sm-9">@(Model.Item.IsCompleted ? Localizer["Yes"] : Localizer["No"])</dd> | |
| 29 | + <dt class="col-sm-3">@Localizer["Votes"]</dt> | |
| 30 | + <dd class="col-sm-9">@Model.Item.VoteCount</dd> | |
| 31 | + </dl> | |
| 32 | + </div> | |
| 33 | +</div> | |
| 34 | + | |
| 35 | +<a asp-action="Edit" asp-route-id="@Model.Item.Id" class="btn btn-primary mt-3">@Localizer["Edit"]</a> | |
| 36 | +<a asp-action="Index" class="btn btn-outline-secondary mt-3">@Localizer["Back to List"]</a> |
added 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<App.Domain.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<App.Domain.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/WebApp/Areas/Admin/Views/Wishlist/Index.cshtml +73 −0
| @@ -0,0 +1,73 @@ | ||
| 1 | +@model AdminWishlistIndexViewModel | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +<div class="admin-page-header"> | |
| 5 | + <div> | |
| 6 | + <h1><i class="bi bi-stars me-2"></i>@Localizer["Wishlist Items"]</h1> | |
| 7 | + <p class="lead">@Localizer["Manage wishlist items across trips"]</p> | |
| 8 | + </div> | |
| 9 | + <a asp-action="Create" class="btn btn-primary"> | |
| 10 | + <i class="bi bi-plus-lg me-1"></i>@Localizer["New"] | |
| 11 | + </a> | |
| 12 | +</div> | |
| 13 | + | |
| 14 | +<div class="admin-card mb-3"> | |
| 15 | + <div class="admin-card-body"> | |
| 16 | + <form method="get" class="row g-2 align-items-end"> | |
| 17 | + <div class="col-auto"> | |
| 18 | + <input type="text" name="search" value="@Model.CurrentSearch" class="form-control" placeholder="@Localizer["Search..."]" /> | |
| 19 | + </div> | |
| 20 | + <div class="col-auto"> | |
| 21 | + <button type="submit" class="btn btn-outline-primary"><i class="bi bi-search me-1"></i>@Localizer["Search"]</button> | |
| 22 | + </div> | |
| 23 | + </form> | |
| 24 | + </div> | |
| 25 | +</div> | |
| 26 | + | |
| 27 | +<div class="admin-card"> | |
| 28 | + <div class="admin-card-body p-0"> | |
| 29 | + @if (Model.Items.Any()) | |
| 30 | + { | |
| 31 | + <table class="table admin-table mb-0"> | |
| 32 | + <thead> | |
| 33 | + <tr> | |
| 34 | + <th>@Localizer["Title"]</th> | |
| 35 | + <th>@Localizer["Category"]</th> | |
| 36 | + <th>@Localizer["Priority"]</th> | |
| 37 | + <th>@Localizer["Trip"]</th> | |
| 38 | + <th>@Localizer["Added By"]</th> | |
| 39 | + <th>@Localizer["Completed"]</th> | |
| 40 | + <th class="text-end">@Localizer["Actions"]</th> | |
| 41 | + </tr> | |
| 42 | + </thead> | |
| 43 | + <tbody> | |
| 44 | + @foreach (var item in Model.Items) | |
| 45 | + { | |
| 46 | + <tr> | |
| 47 | + <td>@item.Title</td> | |
| 48 | + <td><span class="badge bg-info">@WebApp.Helpers.EnumHelper.GetDisplayName(item.Category)</span></td> | |
| 49 | + <td>@WebApp.Helpers.EnumHelper.GetDisplayName(item.Priority)</td> | |
| 50 | + <td>@item.Trip?.Name</td> | |
| 51 | + <td>@(item.AddedByUser != null ? $"{item.AddedByUser.FirstName} {item.AddedByUser.LastName}" : "")</td> | |
| 52 | + <td>@(item.IsCompleted ? Localizer["Yes"] : Localizer["No"])</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/WebApp/Areas/Admin/Views/_ViewImports.cshtml +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +@using WebApp | |
| 2 | +@using WebApp.Areas.Admin.Models | |
| 3 | +@using WebApp.Helpers | |
| 4 | +@using Microsoft.Extensions.Localization | |
| 5 | +@using Microsoft.AspNetCore.Mvc.Localization | |
| 6 | +@using Microsoft.AspNetCore.Mvc.Rendering | |
| 7 | +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers | |
| 8 | +@inject IStringLocalizer<App.Resources.Views.Shared> Localizer |
added 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/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/WebApp/Areas/Identity/Pages/Account/Register.cshtml.cs +93 −0
| @@ -0,0 +1,93 @@ | ||
| 1 | +using System.ComponentModel.DataAnnotations; | |
| 2 | +using App.Domain.Identity; | |
| 3 | +using Microsoft.AspNetCore.Identity; | |
| 4 | +using Microsoft.AspNetCore.Mvc; | |
| 5 | +using Microsoft.AspNetCore.Mvc.RazorPages; | |
| 6 | + | |
| 7 | +namespace 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/WebApp/Areas/Identity/Pages/_ViewImports.cshtml +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@using Microsoft.AspNetCore.Identity | |
| 2 | +@using App.Domain.Identity | |
| 3 | +@using WebApp.Areas.Identity.Pages | |
| 4 | +@using WebApp.Areas.Identity.Pages.Account | |
| 5 | +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers |
added SplitApp/WebApp/Areas/Identity/Pages/_ViewStart.cshtml +3 −0
| @@ -0,0 +1,3 @@ | ||
| 1 | +@{ | |
| 2 | + Layout = "/Views/Shared/_Layout.cshtml"; | |
| 3 | +} |
added SplitApp/WebApp/ConfigureSwaggerOptions.cs +64 −0
| @@ -0,0 +1,64 @@ | ||
| 1 | +using System.Reflection; | |
| 2 | +using Asp.Versioning.ApiExplorer; | |
| 3 | +using Microsoft.Extensions.Options; | |
| 4 | +using Microsoft.OpenApi; | |
| 5 | +using Swashbuckle.AspNetCore.SwaggerGen; | |
| 6 | + | |
| 7 | +namespace WebApp; | |
| 8 | + | |
| 9 | +public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions> | |
| 10 | +{ | |
| 11 | + private readonly IApiVersionDescriptionProvider _descriptionProvider; | |
| 12 | + | |
| 13 | + public ConfigureSwaggerOptions(IApiVersionDescriptionProvider descriptionProvider) | |
| 14 | + { | |
| 15 | + _descriptionProvider = descriptionProvider; | |
| 16 | + } | |
| 17 | + | |
| 18 | + public void Configure(SwaggerGenOptions options) | |
| 19 | + { | |
| 20 | + foreach (var description in _descriptionProvider.ApiVersionDescriptions) | |
| 21 | + { | |
| 22 | + options.SwaggerDoc( | |
| 23 | + description.GroupName, | |
| 24 | + new OpenApiInfo() | |
| 25 | + { | |
| 26 | + Title = $"SplitApp API {description.ApiVersion}", | |
| 27 | + Version = description.ApiVersion.ToString(), | |
| 28 | + } | |
| 29 | + ); | |
| 30 | + } | |
| 31 | + | |
| 32 | + // use fqn for dto descriptions | |
| 33 | + options.CustomSchemaIds(t => t.FullName); | |
| 34 | + | |
| 35 | + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme() | |
| 36 | + { | |
| 37 | + Description = | |
| 38 | + "JWT Authorization header using the Bearer scheme.\r\n<br/>" + | |
| 39 | + "Enter your token in the text box below.\r\n<br/>" + | |
| 40 | + "You will get the bearer from the <i>account/login</i> or <i>account/register</i> endpoint.", | |
| 41 | + Name = "Authorization", | |
| 42 | + In = ParameterLocation.Header, | |
| 43 | + Type = SecuritySchemeType.Http, | |
| 44 | + Scheme = "Bearer", | |
| 45 | + BearerFormat = "JWT" | |
| 46 | + }); | |
| 47 | + | |
| 48 | + options.DocumentFilter<BearerSecurityRequirementDocumentFilter>(); | |
| 49 | + } | |
| 50 | +} | |
| 51 | + | |
| 52 | +public class BearerSecurityRequirementDocumentFilter : IDocumentFilter | |
| 53 | +{ | |
| 54 | + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) | |
| 55 | + { | |
| 56 | + swaggerDoc.Security = new List<OpenApiSecurityRequirement> | |
| 57 | + { | |
| 58 | + new OpenApiSecurityRequirement | |
| 59 | + { | |
| 60 | + [new OpenApiSecuritySchemeReference("Bearer", swaggerDoc)] = new List<string>() | |
| 61 | + } | |
| 62 | + }; | |
| 63 | + } | |
| 64 | +} |
added SplitApp/WebApp/Controllers/BudgetController.cs +208 −0
| @@ -0,0 +1,208 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Identity; | |
| 5 | +using Base.Domain; | |
| 6 | +using Microsoft.AspNetCore.Authorization; | |
| 7 | +using Microsoft.AspNetCore.Identity; | |
| 8 | +using Microsoft.AspNetCore.Mvc; | |
| 9 | + | |
| 10 | +namespace WebApp.Controllers; | |
| 11 | + | |
| 12 | +[Authorize] | |
| 13 | +public class BudgetController : Controller | |
| 14 | +{ | |
| 15 | + private readonly ITripService _tripService; | |
| 16 | + private readonly IBudgetCategoryService _budgetCategoryService; | |
| 17 | + private readonly UserManager<AppUser> _userManager; | |
| 18 | + | |
| 19 | + public BudgetController( | |
| 20 | + ITripService tripService, | |
| 21 | + IBudgetCategoryService budgetCategoryService, | |
| 22 | + UserManager<AppUser> userManager) | |
| 23 | + { | |
| 24 | + _tripService = tripService; | |
| 25 | + _budgetCategoryService = budgetCategoryService; | |
| 26 | + _userManager = userManager; | |
| 27 | + } | |
| 28 | + | |
| 29 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 30 | + | |
| 31 | + // GET: Budget?tripId=xxx | |
| 32 | + public async Task<IActionResult> Index(Guid tripId) | |
| 33 | + { | |
| 34 | + var userId = GetUserId(); | |
| 35 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 36 | + | |
| 37 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 38 | + if (trip == null) return NotFound(); | |
| 39 | + | |
| 40 | + var categories = await _budgetCategoryService.GetByTripIdAsync(tripId, userId); | |
| 41 | + | |
| 42 | + var model = categories.Select(c => new BudgetCategoryViewModel | |
| 43 | + { | |
| 44 | + Id = c.Id, | |
| 45 | + Name = c.Name, | |
| 46 | + IconName = c.IconName, | |
| 47 | + PlannedAmount = c.PlannedAmount ?? 0, | |
| 48 | + SpentAmount = c.SpentAmount, | |
| 49 | + DisplayOrder = c.DisplayOrder | |
| 50 | + }).ToList(); | |
| 51 | + | |
| 52 | + ViewData["TripId"] = tripId; | |
| 53 | + ViewData["TripName"] = trip.Name; | |
| 54 | + ViewData["TotalPlanned"] = model.Sum(m => m.PlannedAmount); | |
| 55 | + ViewData["TotalSpent"] = model.Sum(m => m.SpentAmount); | |
| 56 | + ViewData["IsOrganizer"] = await _tripService.IsOrganizerAsync(tripId, userId); | |
| 57 | + | |
| 58 | + return View(model); | |
| 59 | + } | |
| 60 | + | |
| 61 | + // GET: Budget/CreateCategory?tripId=xxx | |
| 62 | + public async Task<IActionResult> CreateCategory(Guid tripId) | |
| 63 | + { | |
| 64 | + var userId = GetUserId(); | |
| 65 | + if (!await _tripService.IsOrganizerAsync(tripId, userId)) return Forbid(); | |
| 66 | + | |
| 67 | + ViewData["TripId"] = tripId; | |
| 68 | + return View(new BudgetCategoryBllDto { TripId = tripId }); | |
| 69 | + } | |
| 70 | + | |
| 71 | + // POST: Budget/CreateCategory | |
| 72 | + [HttpPost] | |
| 73 | + [ValidateAntiForgeryToken] | |
| 74 | + public async Task<IActionResult> CreateCategory(BudgetCategoryBllDto category, string? name) | |
| 75 | + { | |
| 76 | + var userId = GetUserId(); | |
| 77 | + | |
| 78 | + category.Name = new LangStr(name ?? "", "en"); | |
| 79 | + ModelState.Remove(nameof(BudgetCategory.Name)); | |
| 80 | + | |
| 81 | + if (string.IsNullOrWhiteSpace(name)) | |
| 82 | + ModelState.AddModelError(nameof(BudgetCategory.Name), "Name is required."); | |
| 83 | + | |
| 84 | + if (ModelState.IsValid) | |
| 85 | + { | |
| 86 | + var (created, errorCode) = await _budgetCategoryService.CreateAsync(category, userId); | |
| 87 | + if (created == null) | |
| 88 | + { | |
| 89 | + if (errorCode == "forbidden") return Forbid(); | |
| 90 | + return NotFound(); | |
| 91 | + } | |
| 92 | + return RedirectToAction(nameof(Index), new { tripId = category.TripId }); | |
| 93 | + } | |
| 94 | + | |
| 95 | + ViewData["TripId"] = category.TripId; | |
| 96 | + return View(category); | |
| 97 | + } | |
| 98 | + | |
| 99 | + // GET: Budget/EditCategory/5 | |
| 100 | + public async Task<IActionResult> EditCategory(Guid id) | |
| 101 | + { | |
| 102 | + var userId = GetUserId(); | |
| 103 | + | |
| 104 | + var category = await _budgetCategoryService.GetByIdAsync(id); | |
| 105 | + if (category == null) return NotFound(); | |
| 106 | + | |
| 107 | + if (!await _tripService.IsOrganizerAsync(category.TripId, userId)) return Forbid(); | |
| 108 | + | |
| 109 | + ViewData["TripId"] = category.TripId; | |
| 110 | + return View(category); | |
| 111 | + } | |
| 112 | + | |
| 113 | + // POST: Budget/EditCategory/5 | |
| 114 | + [HttpPost] | |
| 115 | + [ValidateAntiForgeryToken] | |
| 116 | + public async Task<IActionResult> EditCategory(Guid id, BudgetCategoryBllDto category, string? name) | |
| 117 | + { | |
| 118 | + if (id != category.Id) return NotFound(); | |
| 119 | + | |
| 120 | + var userId = GetUserId(); | |
| 121 | + | |
| 122 | + var existing = await _budgetCategoryService.GetByIdAsync(id); | |
| 123 | + if (existing == null) return NotFound(); | |
| 124 | + | |
| 125 | + if (!await _tripService.IsOrganizerAsync(existing.TripId, userId)) return Forbid(); | |
| 126 | + | |
| 127 | + category.Name = new LangStr(name ?? "", "en"); | |
| 128 | + ModelState.Remove(nameof(BudgetCategory.Name)); | |
| 129 | + | |
| 130 | + if (string.IsNullOrWhiteSpace(name)) | |
| 131 | + ModelState.AddModelError(nameof(BudgetCategory.Name), "Name is required."); | |
| 132 | + | |
| 133 | + if (ModelState.IsValid) | |
| 134 | + { | |
| 135 | + var (ok, errorCode) = await _budgetCategoryService.UpdateAsync(id, category, userId); | |
| 136 | + if (!ok) | |
| 137 | + { | |
| 138 | + return errorCode switch | |
| 139 | + { | |
| 140 | + "forbidden" => Forbid(), | |
| 141 | + _ => NotFound() | |
| 142 | + }; | |
| 143 | + } | |
| 144 | + return RedirectToAction(nameof(Index), new { tripId = existing.TripId }); | |
| 145 | + } | |
| 146 | + | |
| 147 | + ViewData["TripId"] = existing.TripId; | |
| 148 | + return View(category); | |
| 149 | + } | |
| 150 | + | |
| 151 | + // GET: Budget/DeleteCategory/5 | |
| 152 | + public async Task<IActionResult> DeleteCategory(Guid id) | |
| 153 | + { | |
| 154 | + var userId = GetUserId(); | |
| 155 | + | |
| 156 | + var category = await _budgetCategoryService.GetByIdAsync(id); | |
| 157 | + if (category == null) return NotFound(); | |
| 158 | + | |
| 159 | + if (!await _tripService.IsOrganizerAsync(category.TripId, userId)) return Forbid(); | |
| 160 | + | |
| 161 | + ViewData["TripId"] = category.TripId; | |
| 162 | + return View(category); | |
| 163 | + } | |
| 164 | + | |
| 165 | + // POST: Budget/DeleteCategory/5 | |
| 166 | + [HttpPost, ActionName("DeleteCategory")] | |
| 167 | + [ValidateAntiForgeryToken] | |
| 168 | + public async Task<IActionResult> DeleteCategoryConfirmed(Guid id) | |
| 169 | + { | |
| 170 | + var userId = GetUserId(); | |
| 171 | + | |
| 172 | + var category = await _budgetCategoryService.GetByIdAsync(id); | |
| 173 | + if (category == null) return NotFound(); | |
| 174 | + | |
| 175 | + var tripId = category.TripId; | |
| 176 | + var (ok, errorCode) = await _budgetCategoryService.DeleteAsync(id, userId); | |
| 177 | + if (!ok) | |
| 178 | + { | |
| 179 | + return errorCode switch | |
| 180 | + { | |
| 181 | + "forbidden" => Forbid(), | |
| 182 | + _ => NotFound() | |
| 183 | + }; | |
| 184 | + } | |
| 185 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 186 | + } | |
| 187 | +} | |
| 188 | + | |
| 189 | +public class BudgetCategoryViewModel | |
| 190 | +{ | |
| 191 | + public Guid Id { get; set; } | |
| 192 | + public string Name { get; set; } = default!; | |
| 193 | + public string? IconName { get; set; } | |
| 194 | + public decimal PlannedAmount { get; set; } | |
| 195 | + public decimal SpentAmount { get; set; } | |
| 196 | + public int DisplayOrder { get; set; } | |
| 197 | + | |
| 198 | + public int ProgressPercentage => | |
| 199 | + PlannedAmount > 0 ? (int)(SpentAmount / PlannedAmount * 100) : 0; | |
| 200 | + | |
| 201 | + public string ProgressBarClass => | |
| 202 | + ProgressPercentage switch | |
| 203 | + { | |
| 204 | + > 90 => "bg-danger", | |
| 205 | + > 70 => "bg-warning", | |
| 206 | + _ => "bg-success" | |
| 207 | + }; | |
| 208 | +} |
added SplitApp/WebApp/Controllers/ExpensesController.cs +266 −0
| @@ -0,0 +1,266 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Identity; | |
| 5 | +using Microsoft.AspNetCore.Authorization; | |
| 6 | +using Microsoft.AspNetCore.Identity; | |
| 7 | +using Microsoft.AspNetCore.Mvc; | |
| 8 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 9 | + | |
| 10 | +namespace WebApp.Controllers; | |
| 11 | + | |
| 12 | +[Authorize] | |
| 13 | +public class ExpensesController : Controller | |
| 14 | +{ | |
| 15 | + private readonly IExpenseService _expenseService; | |
| 16 | + private readonly ITripService _tripService; | |
| 17 | + private readonly IBudgetCategoryService _budgetCategoryService; | |
| 18 | + private readonly UserManager<AppUser> _userManager; | |
| 19 | + | |
| 20 | + public ExpensesController( | |
| 21 | + IExpenseService expenseService, | |
| 22 | + ITripService tripService, | |
| 23 | + IBudgetCategoryService budgetCategoryService, | |
| 24 | + UserManager<AppUser> userManager) | |
| 25 | + { | |
| 26 | + _expenseService = expenseService; | |
| 27 | + _tripService = tripService; | |
| 28 | + _budgetCategoryService = budgetCategoryService; | |
| 29 | + _userManager = userManager; | |
| 30 | + } | |
| 31 | + | |
| 32 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 33 | + | |
| 34 | + // GET: Expenses?tripId=xxx | |
| 35 | + public async Task<IActionResult> Index(Guid tripId) | |
| 36 | + { | |
| 37 | + var userId = GetUserId(); | |
| 38 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 39 | + | |
| 40 | + var trip = await _tripService.GetByIdWithDetailsAsync(tripId, userId); | |
| 41 | + if (trip == null) return NotFound(); | |
| 42 | + | |
| 43 | + var expenses = await _expenseService.GetByTripIdAsync(tripId, userId); | |
| 44 | + | |
| 45 | + var model = new ExpensesIndexViewModel | |
| 46 | + { | |
| 47 | + TripId = tripId, | |
| 48 | + TripName = trip.Name, | |
| 49 | + DefaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR", | |
| 50 | + CurrencySymbol = trip.DefaultCurrency?.Symbol ?? "\u20ac", | |
| 51 | + TripStatus = trip.Status.ToString(), | |
| 52 | + Expenses = expenses | |
| 53 | + }; | |
| 54 | + | |
| 55 | + return View(model); | |
| 56 | + } | |
| 57 | + | |
| 58 | + // GET: Expenses/Create?tripId=xxx | |
| 59 | + public async Task<IActionResult> Create(Guid tripId) | |
| 60 | + { | |
| 61 | + var userId = GetUserId(); | |
| 62 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 63 | + | |
| 64 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 65 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 66 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 67 | + | |
| 68 | + await PopulateDropdowns(tripId); | |
| 69 | + ViewData["TripId"] = tripId; | |
| 70 | + | |
| 71 | + var expense = new ExpenseBllDto | |
| 72 | + { | |
| 73 | + TripId = tripId, | |
| 74 | + PaidByUserId = userId, | |
| 75 | + ExpenseDate = DateTime.UtcNow, | |
| 76 | + SplitMethod = ESplitMethod.EqualAll | |
| 77 | + }; | |
| 78 | + | |
| 79 | + return View(expense); | |
| 80 | + } | |
| 81 | + | |
| 82 | + // POST: Expenses/Create | |
| 83 | + [HttpPost] | |
| 84 | + [ValidateAntiForgeryToken] | |
| 85 | + public async Task<IActionResult> Create(ExpenseBllDto expense, Guid[] selectedParticipants, decimal[] splitAmounts, decimal[] splitPercentages) | |
| 86 | + { | |
| 87 | + var userId = GetUserId(); | |
| 88 | + if (!await _tripService.IsParticipantAsync(expense.TripId, userId)) return Forbid(); | |
| 89 | + | |
| 90 | + if (ModelState.IsValid) | |
| 91 | + { | |
| 92 | + await _expenseService.CreateExpenseWithSplitsAsync(expense, selectedParticipants, splitAmounts, splitPercentages); | |
| 93 | + return RedirectToAction(nameof(Index), new { tripId = expense.TripId }); | |
| 94 | + } | |
| 95 | + | |
| 96 | + await PopulateDropdowns(expense.TripId, expense.BudgetCategoryId, expense.CurrencyId); | |
| 97 | + ViewData["TripId"] = expense.TripId; | |
| 98 | + return View(expense); | |
| 99 | + } | |
| 100 | + | |
| 101 | + // GET: Expenses/Edit/5 | |
| 102 | + public async Task<IActionResult> Edit(Guid id) | |
| 103 | + { | |
| 104 | + var userId = GetUserId(); | |
| 105 | + | |
| 106 | + var expense = await _expenseService.GetRawByIdAsync(id); | |
| 107 | + if (expense == null) return NotFound(); | |
| 108 | + | |
| 109 | + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid(); | |
| 110 | + | |
| 111 | + var trip = await _tripService.GetRawByIdAsync(expense.TripId); | |
| 112 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 113 | + return RedirectToAction(nameof(Index), new { tripId = expense.TripId }); | |
| 114 | + | |
| 115 | + await PopulateDropdowns(expense.TripId, expense.BudgetCategoryId, expense.CurrencyId); | |
| 116 | + ViewData["TripId"] = expense.TripId; | |
| 117 | + | |
| 118 | + return View(expense); | |
| 119 | + } | |
| 120 | + | |
| 121 | + // POST: Expenses/Edit/5 | |
| 122 | + [HttpPost] | |
| 123 | + [ValidateAntiForgeryToken] | |
| 124 | + public async Task<IActionResult> Edit(Guid id, ExpenseBllDto expense) | |
| 125 | + { | |
| 126 | + if (id != expense.Id) return NotFound(); | |
| 127 | + | |
| 128 | + var userId = GetUserId(); | |
| 129 | + | |
| 130 | + if (ModelState.IsValid) | |
| 131 | + { | |
| 132 | + var result = await _expenseService.UpdateExpenseAsync(id, expense, userId); | |
| 133 | + if (!result.success) | |
| 134 | + { | |
| 135 | + return result.errorCode switch | |
| 136 | + { | |
| 137 | + "notfound" => NotFound(), | |
| 138 | + "forbidden" => Forbid(), | |
| 139 | + "badstatus" => RedirectToAction(nameof(Index), new { tripId = expense.TripId }), | |
| 140 | + _ => NotFound() | |
| 141 | + }; | |
| 142 | + } | |
| 143 | + // Need to get tripId from existing since it's not in incoming after success | |
| 144 | + var updated = await _expenseService.GetRawByIdAsync(id); | |
| 145 | + return RedirectToAction(nameof(Index), new { tripId = updated?.TripId ?? expense.TripId }); | |
| 146 | + } | |
| 147 | + | |
| 148 | + var existingEntity = await _expenseService.GetRawByIdAsync(id); | |
| 149 | + if (existingEntity == null) return NotFound(); | |
| 150 | + | |
| 151 | + await PopulateDropdowns(existingEntity.TripId, expense.BudgetCategoryId, expense.CurrencyId); | |
| 152 | + ViewData["TripId"] = existingEntity.TripId; | |
| 153 | + return View(expense); | |
| 154 | + } | |
| 155 | + | |
| 156 | + // GET: Expenses/Delete/5 | |
| 157 | + public async Task<IActionResult> Delete(Guid id) | |
| 158 | + { | |
| 159 | + var userId = GetUserId(); | |
| 160 | + | |
| 161 | + var expense = await _expenseService.GetByIdWithDetailsAsync(id, userId); | |
| 162 | + if (expense == null) | |
| 163 | + { | |
| 164 | + // Either NotFound or not a participant | |
| 165 | + var raw = await _expenseService.GetRawByIdAsync(id); | |
| 166 | + if (raw == null) return NotFound(); | |
| 167 | + return Forbid(); | |
| 168 | + } | |
| 169 | + | |
| 170 | + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid(); | |
| 171 | + | |
| 172 | + var trip = await _tripService.GetRawByIdAsync(expense.TripId); | |
| 173 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 174 | + return RedirectToAction(nameof(Index), new { tripId = expense.TripId }); | |
| 175 | + | |
| 176 | + ViewData["TripId"] = expense.TripId; | |
| 177 | + return View(expense); | |
| 178 | + } | |
| 179 | + | |
| 180 | + // POST: Expenses/Delete/5 | |
| 181 | + [HttpPost, ActionName("Delete")] | |
| 182 | + [ValidateAntiForgeryToken] | |
| 183 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 184 | + { | |
| 185 | + var userId = GetUserId(); | |
| 186 | + | |
| 187 | + var expense = await _expenseService.GetRawByIdAsync(id); | |
| 188 | + if (expense == null) return NotFound(); | |
| 189 | + | |
| 190 | + if (!await _expenseService.CanEditExpenseAsync(expense, userId)) return Forbid(); | |
| 191 | + | |
| 192 | + var trip = await _tripService.GetRawByIdAsync(expense.TripId); | |
| 193 | + if (trip != null && trip.Status != ETripStatus.Active) | |
| 194 | + return RedirectToAction(nameof(Index), new { tripId = expense.TripId }); | |
| 195 | + | |
| 196 | + var tripId = expense.TripId; | |
| 197 | + | |
| 198 | + await _expenseService.DeleteExpenseWithSplitsAsync(id); | |
| 199 | + | |
| 200 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 201 | + } | |
| 202 | + | |
| 203 | + private async Task PopulateDropdowns(Guid tripId, Guid? selectedCategoryId = null, Guid? selectedCurrencyId = null) | |
| 204 | + { | |
| 205 | + var categories = await _budgetCategoryService.GetByTripIdRawAsync(tripId); | |
| 206 | + ViewData["BudgetCategoryId"] = new SelectList(categories, "Id", "Name", selectedCategoryId); | |
| 207 | + | |
| 208 | + var currencies = await _tripService.GetAllCurrenciesAsync(); | |
| 209 | + ViewData["CurrencyId"] = new SelectList(currencies, "Id", "Code", selectedCurrencyId); | |
| 210 | + | |
| 211 | + ViewData["SplitMethods"] = new SelectList( | |
| 212 | + Enum.GetValues<ESplitMethod>().Select(e => new { Value = (int)e, Text = e.ToString() }), | |
| 213 | + "Value", "Text"); | |
| 214 | + | |
| 215 | + var userId = GetUserId(); | |
| 216 | + var participants = await _tripService.GetParticipantsAsync(tripId, userId); | |
| 217 | + | |
| 218 | + ViewData["Participants"] = participants; | |
| 219 | + ViewData["PaidByUserId"] = new SelectList( | |
| 220 | + participants.Select(p => new | |
| 221 | + { | |
| 222 | + Value = p.UserId, | |
| 223 | + Text = $"{p.User!.FirstName} {p.User.LastName}" | |
| 224 | + }), | |
| 225 | + "Value", "Text", userId); | |
| 226 | + | |
| 227 | + // Load split presets for this trip | |
| 228 | + var presets = await _expenseService.GetSplitPresetsByTripAsync(tripId, userId); | |
| 229 | + ViewData["SplitPresets"] = presets; | |
| 230 | + } | |
| 231 | + | |
| 232 | + // POST: Expenses/SavePreset | |
| 233 | + [HttpPost] | |
| 234 | + [ValidateAntiForgeryToken] | |
| 235 | + public async Task<IActionResult> SavePreset(Guid tripId, string presetName, int splitMethod, Guid[] selectedParticipants, decimal[] splitPercentages) | |
| 236 | + { | |
| 237 | + var userId = GetUserId(); | |
| 238 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 239 | + | |
| 240 | + await _expenseService.SavePresetAsync(tripId, presetName, (ESplitMethod)splitMethod, selectedParticipants, splitPercentages, userId); | |
| 241 | + return RedirectToAction(nameof(Create), new { tripId }); | |
| 242 | + } | |
| 243 | + | |
| 244 | + // POST: Expenses/DeletePreset | |
| 245 | + [HttpPost] | |
| 246 | + [ValidateAntiForgeryToken] | |
| 247 | + public async Task<IActionResult> DeletePreset(Guid id, Guid tripId) | |
| 248 | + { | |
| 249 | + var userId = GetUserId(); | |
| 250 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 251 | + | |
| 252 | + await _expenseService.DeletePresetAsync(id, tripId, userId); | |
| 253 | + | |
| 254 | + return RedirectToAction(nameof(Create), new { tripId }); | |
| 255 | + } | |
| 256 | +} | |
| 257 | + | |
| 258 | +public class ExpensesIndexViewModel | |
| 259 | +{ | |
| 260 | + public Guid TripId { get; set; } | |
| 261 | + public string TripName { get; set; } = default!; | |
| 262 | + public string DefaultCurrencyCode { get; set; } = default!; | |
| 263 | + public string CurrencySymbol { get; set; } = default!; | |
| 264 | + public string TripStatus { get; set; } = default!; | |
| 265 | + public List<ExpenseBllDto> Expenses { get; set; } = new(); | |
| 266 | +} |
added SplitApp/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 WebApp.Models; | |
| 5 | + | |
| 6 | +namespace 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/WebApp/Controllers/MembersController.cs +177 −0
| @@ -0,0 +1,177 @@ | ||
| 1 | +using App.BLL.Services; | |
| 2 | +using App.Domain; | |
| 3 | +using App.Domain.Identity; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Identity; | |
| 6 | +using Microsoft.AspNetCore.Mvc; | |
| 7 | + | |
| 8 | +namespace WebApp.Controllers; | |
| 9 | + | |
| 10 | +[Authorize] | |
| 11 | +public class MembersController : Controller | |
| 12 | +{ | |
| 13 | + private readonly ITripService _tripService; | |
| 14 | + private readonly IInvitationService _invitationService; | |
| 15 | + private readonly UserManager<AppUser> _userManager; | |
| 16 | + | |
| 17 | + public MembersController( | |
| 18 | + ITripService tripService, | |
| 19 | + IInvitationService invitationService, | |
| 20 | + UserManager<AppUser> userManager) | |
| 21 | + { | |
| 22 | + _tripService = tripService; | |
| 23 | + _invitationService = invitationService; | |
| 24 | + _userManager = userManager; | |
| 25 | + } | |
| 26 | + | |
| 27 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 28 | + | |
| 29 | + // GET: Members?tripId=xxx | |
| 30 | + public async Task<IActionResult> Index(Guid tripId) | |
| 31 | + { | |
| 32 | + var userId = GetUserId(); | |
| 33 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 34 | + | |
| 35 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 36 | + if (trip == null) return NotFound(); | |
| 37 | + | |
| 38 | + var participants = await _tripService.GetParticipantsAsync(tripId, userId); | |
| 39 | + | |
| 40 | + var isOrganizer = participants.Any(p => p.UserId == userId && p.Role == EParticipantRole.Organizer); | |
| 41 | + | |
| 42 | + var pendingInvitations = await _invitationService.GetPendingByTripIdAsync(tripId, userId); | |
| 43 | + | |
| 44 | + ViewData["TripId"] = tripId; | |
| 45 | + ViewData["TripName"] = trip.Name; | |
| 46 | + ViewData["CurrentUserId"] = userId; | |
| 47 | + ViewData["IsOrganizer"] = isOrganizer; | |
| 48 | + ViewData["PendingInvitations"] = pendingInvitations; | |
| 49 | + | |
| 50 | + return View(participants); | |
| 51 | + } | |
| 52 | + | |
| 53 | + // GET: Members/Invite?tripId=xxx | |
| 54 | + public async Task<IActionResult> Invite(Guid tripId) | |
| 55 | + { | |
| 56 | + var userId = GetUserId(); | |
| 57 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 58 | + | |
| 59 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 60 | + if (trip == null) return NotFound(); | |
| 61 | + | |
| 62 | + ViewData["TripId"] = tripId; | |
| 63 | + ViewData["TripName"] = trip.Name; | |
| 64 | + | |
| 65 | + return View(); | |
| 66 | + } | |
| 67 | + | |
| 68 | + // POST: Members/Invite | |
| 69 | + [HttpPost] | |
| 70 | + [ValidateAntiForgeryToken] | |
| 71 | + public async Task<IActionResult> Invite(Guid tripId, int _) | |
| 72 | + { | |
| 73 | + var userId = GetUserId(); | |
| 74 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 75 | + | |
| 76 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 77 | + if (trip == null) return NotFound(); | |
| 78 | + | |
| 79 | + var invitation = await _invitationService.CreateInvitationAsync(tripId, userId); | |
| 80 | + | |
| 81 | + var inviteUrl = Url.Action("AcceptInvitation", "Members", new { token = invitation.Token }, Request.Scheme); | |
| 82 | + | |
| 83 | + ViewData["TripId"] = tripId; | |
| 84 | + ViewData["TripName"] = trip.Name; | |
| 85 | + ViewData["InviteUrl"] = inviteUrl; | |
| 86 | + ViewData["Token"] = invitation.Token; | |
| 87 | + | |
| 88 | + return View("InviteGenerated"); | |
| 89 | + } | |
| 90 | + | |
| 91 | + // GET: Members/AcceptInvitation?token=xxx | |
| 92 | + [AllowAnonymous] | |
| 93 | + public async Task<IActionResult> AcceptInvitation(string token) | |
| 94 | + { | |
| 95 | + var invitation = await _invitationService.GetByTokenAsync(token); | |
| 96 | + | |
| 97 | + if (invitation == null) return NotFound(); | |
| 98 | + | |
| 99 | + if (invitation.Status != EInvitationStatus.Pending || invitation.ExpiresAt < DateTime.UtcNow) | |
| 100 | + { | |
| 101 | + ViewData["Error"] = "This invitation has expired or is no longer valid."; | |
| 102 | + return View("InvitationInvalid"); | |
| 103 | + } | |
| 104 | + | |
| 105 | + // Load trip for the view | |
| 106 | + invitation.Trip = await _tripService.GetRawByIdAsync(invitation.TripId); | |
| 107 | + | |
| 108 | + ViewData["Token"] = token; | |
| 109 | + return View(invitation); | |
| 110 | + } | |
| 111 | + | |
| 112 | + // POST: Members/AcceptInvitation | |
| 113 | + [HttpPost] | |
| 114 | + [ValidateAntiForgeryToken] | |
| 115 | + public async Task<IActionResult> AcceptInvitation(string token, int _) | |
| 116 | + { | |
| 117 | + var userId = GetUserId(); | |
| 118 | + | |
| 119 | + var invitation = await _invitationService.GetByTokenAsync(token); | |
| 120 | + if (invitation == null) return NotFound(); | |
| 121 | + | |
| 122 | + var success = await _invitationService.AcceptInvitationAsync(token, userId); | |
| 123 | + | |
| 124 | + if (!success) | |
| 125 | + { | |
| 126 | + ViewData["Error"] = "This invitation has expired or is no longer valid."; | |
| 127 | + return View("InvitationInvalid"); | |
| 128 | + } | |
| 129 | + | |
| 130 | + return RedirectToAction("Details", "Trips", new { id = invitation.TripId }); | |
| 131 | + } | |
| 132 | + | |
| 133 | + // POST: Members/Remove | |
| 134 | + [HttpPost] | |
| 135 | + [ValidateAntiForgeryToken] | |
| 136 | + public async Task<IActionResult> Remove(Guid tripId, Guid participantId) | |
| 137 | + { | |
| 138 | + var userId = GetUserId(); | |
| 139 | + | |
| 140 | + if (!await _tripService.IsOrganizerAsync(tripId, userId)) return Forbid(); | |
| 141 | + | |
| 142 | + var participant = await _tripService.GetParticipantByIdAsync(participantId); | |
| 143 | + if (participant == null || participant.TripId != tripId) return NotFound(); | |
| 144 | + | |
| 145 | + // Cannot remove yourself | |
| 146 | + if (participant.UserId == userId) | |
| 147 | + { | |
| 148 | + TempData["Error"] = "You cannot remove yourself from the trip."; | |
| 149 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 150 | + } | |
| 151 | + | |
| 152 | + await _tripService.RemoveParticipantByIdAsync(tripId, participantId, userId); | |
| 153 | + | |
| 154 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 155 | + } | |
| 156 | + | |
| 157 | + // POST: Members/RevokeInvitation | |
| 158 | + [HttpPost] | |
| 159 | + [ValidateAntiForgeryToken] | |
| 160 | + public async Task<IActionResult> RevokeInvitation(Guid id, Guid tripId) | |
| 161 | + { | |
| 162 | + var userId = GetUserId(); | |
| 163 | + | |
| 164 | + var (ok, errorCode) = await _invitationService.RevokeInvitationAsync(id, tripId, userId); | |
| 165 | + if (!ok) | |
| 166 | + { | |
| 167 | + return errorCode switch | |
| 168 | + { | |
| 169 | + "forbidden" => Forbid(), | |
| 170 | + "notfound" => NotFound(), | |
| 171 | + _ => NotFound() | |
| 172 | + }; | |
| 173 | + } | |
| 174 | + | |
| 175 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 176 | + } | |
| 177 | +} |
added SplitApp/WebApp/Controllers/PollsClientController.cs +144 −0
| @@ -0,0 +1,144 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Identity; | |
| 5 | +using Microsoft.AspNetCore.Authorization; | |
| 6 | +using Microsoft.AspNetCore.Identity; | |
| 7 | +using Microsoft.AspNetCore.Mvc; | |
| 8 | + | |
| 9 | +namespace WebApp.Controllers; | |
| 10 | + | |
| 11 | +[Authorize] | |
| 12 | +public class PollsClientController : Controller | |
| 13 | +{ | |
| 14 | + private readonly ITripService _tripService; | |
| 15 | + private readonly IPollService _pollService; | |
| 16 | + private readonly UserManager<AppUser> _userManager; | |
| 17 | + | |
| 18 | + public PollsClientController( | |
| 19 | + ITripService tripService, | |
| 20 | + IPollService pollService, | |
| 21 | + UserManager<AppUser> userManager) | |
| 22 | + { | |
| 23 | + _tripService = tripService; | |
| 24 | + _pollService = pollService; | |
| 25 | + _userManager = userManager; | |
| 26 | + } | |
| 27 | + | |
| 28 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 29 | + | |
| 30 | + // GET: PollsClient?tripId=xxx | |
| 31 | + public async Task<IActionResult> Index(Guid tripId) | |
| 32 | + { | |
| 33 | + var userId = GetUserId(); | |
| 34 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 35 | + | |
| 36 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 37 | + if (trip == null) return NotFound(); | |
| 38 | + | |
| 39 | + var polls = await _pollService.GetByTripIdAsync(tripId, userId); | |
| 40 | + | |
| 41 | + ViewData["TripId"] = tripId; | |
| 42 | + ViewData["TripName"] = trip.Name; | |
| 43 | + | |
| 44 | + return View(polls); | |
| 45 | + } | |
| 46 | + | |
| 47 | + // GET: PollsClient/Create?tripId=xxx | |
| 48 | + public async Task<IActionResult> Create(Guid tripId) | |
| 49 | + { | |
| 50 | + var userId = GetUserId(); | |
| 51 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 52 | + | |
| 53 | + ViewData["TripId"] = tripId; | |
| 54 | + return View(); | |
| 55 | + } | |
| 56 | + | |
| 57 | + // POST: PollsClient/Create | |
| 58 | + [HttpPost] | |
| 59 | + [ValidateAntiForgeryToken] | |
| 60 | + public async Task<IActionResult> Create(Guid tripId, string question, bool allowMultipleVotes, | |
| 61 | + bool isAnonymous, string option1, string option2, string? option3, string? option4, string? option5) | |
| 62 | + { | |
| 63 | + var userId = GetUserId(); | |
| 64 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 65 | + | |
| 66 | + if (string.IsNullOrWhiteSpace(question) || string.IsNullOrWhiteSpace(option1) || | |
| 67 | + string.IsNullOrWhiteSpace(option2)) | |
| 68 | + { | |
| 69 | + ModelState.AddModelError("", "Question and at least 2 options are required."); | |
| 70 | + ViewData["TripId"] = tripId; | |
| 71 | + return View(); | |
| 72 | + } | |
| 73 | + | |
| 74 | + var poll = new TripPollBllDto | |
| 75 | + { | |
| 76 | + TripId = tripId, | |
| 77 | + CreatedByUserId = userId, | |
| 78 | + Question = question, | |
| 79 | + AllowMultipleVotes = allowMultipleVotes, | |
| 80 | + IsAnonymous = isAnonymous | |
| 81 | + }; | |
| 82 | + | |
| 83 | + var options = new[] { option1, option2, option3, option4, option5 } | |
| 84 | + .Where(o => !string.IsNullOrWhiteSpace(o)) | |
| 85 | + .Select(o => o!) | |
| 86 | + .ToList(); | |
| 87 | + | |
| 88 | + var created = await _pollService.CreatePollWithOptionsAsync(poll, options); | |
| 89 | + | |
| 90 | + return RedirectToAction(nameof(Details), new { id = created.Id }); | |
| 91 | + } | |
| 92 | + | |
| 93 | + // GET: PollsClient/Details/5 | |
| 94 | + public async Task<IActionResult> Details(Guid id) | |
| 95 | + { | |
| 96 | + var userId = GetUserId(); | |
| 97 | + var poll = await _pollService.GetByIdWithDetailsAsync(id, userId); | |
| 98 | + | |
| 99 | + if (poll == null) return NotFound(); | |
| 100 | + | |
| 101 | + ViewData["TripId"] = poll.TripId; | |
| 102 | + ViewData["UserId"] = userId; | |
| 103 | + ViewData["IsCreator"] = poll.CreatedByUserId == userId; | |
| 104 | + | |
| 105 | + return View(poll); | |
| 106 | + } | |
| 107 | + | |
| 108 | + // POST: PollsClient/Vote | |
| 109 | + [HttpPost] | |
| 110 | + [ValidateAntiForgeryToken] | |
| 111 | + public async Task<IActionResult> Vote(Guid pollId, Guid optionId) | |
| 112 | + { | |
| 113 | + var userId = GetUserId(); | |
| 114 | + var poll = await _pollService.GetByIdAsync(pollId, userId); | |
| 115 | + | |
| 116 | + if (poll == null) return NotFound(); | |
| 117 | + | |
| 118 | + if (poll.ClosedAt != null) return RedirectToAction(nameof(Details), new { id = pollId }); | |
| 119 | + | |
| 120 | + await _pollService.ToggleVoteAsync(pollId, optionId, userId); | |
| 121 | + | |
| 122 | + return RedirectToAction(nameof(Details), new { id = pollId }); | |
| 123 | + } | |
| 124 | + | |
| 125 | + // POST: PollsClient/Close/5 | |
| 126 | + [HttpPost] | |
| 127 | + [ValidateAntiForgeryToken] | |
| 128 | + public async Task<IActionResult> Close(Guid id) | |
| 129 | + { | |
| 130 | + var userId = GetUserId(); | |
| 131 | + var (ok, errorCode) = await _pollService.ClosePollAsync(id, userId, organizerAllowed: false); | |
| 132 | + if (!ok) | |
| 133 | + { | |
| 134 | + return errorCode switch | |
| 135 | + { | |
| 136 | + "notfound" => NotFound(), | |
| 137 | + "forbidden" => Forbid(), | |
| 138 | + _ => NotFound() | |
| 139 | + }; | |
| 140 | + } | |
| 141 | + | |
| 142 | + return RedirectToAction(nameof(Details), new { id }); | |
| 143 | + } | |
| 144 | +} |
added SplitApp/WebApp/Controllers/SettlementController.cs +190 −0
| @@ -0,0 +1,190 @@ | ||
| 1 | +using App.BLL.Services; | |
| 2 | +using App.Domain; | |
| 3 | +using App.Domain.Identity; | |
| 4 | +using Microsoft.AspNetCore.Authorization; | |
| 5 | +using Microsoft.AspNetCore.Identity; | |
| 6 | +using Microsoft.AspNetCore.Mvc; | |
| 7 | + | |
| 8 | +namespace WebApp.Controllers; | |
| 9 | + | |
| 10 | +[Authorize] | |
| 11 | +public class SettlementController : Controller | |
| 12 | +{ | |
| 13 | + private readonly ITripService _tripService; | |
| 14 | + private readonly ISettlementService _settlementService; | |
| 15 | + private readonly UserManager<AppUser> _userManager; | |
| 16 | + | |
| 17 | + public SettlementController( | |
| 18 | + ITripService tripService, | |
| 19 | + ISettlementService settlementService, | |
| 20 | + UserManager<AppUser> userManager) | |
| 21 | + { | |
| 22 | + _tripService = tripService; | |
| 23 | + _settlementService = settlementService; | |
| 24 | + _userManager = userManager; | |
| 25 | + } | |
| 26 | + | |
| 27 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 28 | + | |
| 29 | + // GET: Settlement?tripId=xxx | |
| 30 | + public async Task<IActionResult> Index(Guid tripId) | |
| 31 | + { | |
| 32 | + var userId = GetUserId(); | |
| 33 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 34 | + | |
| 35 | + var trip = await _tripService.GetByIdWithDetailsAsync(tripId, userId); | |
| 36 | + if (trip == null) return NotFound(); | |
| 37 | + | |
| 38 | + // Calculate balances via service | |
| 39 | + var balanceEntries = await _settlementService.CalculateBalancesAsync(tripId); | |
| 40 | + | |
| 41 | + // Map to view model | |
| 42 | + var balances = balanceEntries.Select(b => new SettlementBalanceViewModel | |
| 43 | + { | |
| 44 | + UserId = b.UserId, | |
| 45 | + UserName = b.UserName, | |
| 46 | + TotalPaid = b.TotalPaid, | |
| 47 | + TotalOwed = b.TotalOwed | |
| 48 | + }).OrderByDescending(b => b.NetBalance).ToList(); | |
| 49 | + | |
| 50 | + // Get existing settlement plan (only exists after trip is finalized) | |
| 51 | + var latestPlan = await _settlementService.GetLatestPlanRawAsync(tripId); | |
| 52 | + | |
| 53 | + // Preview payments when trip is active (not saved to DB) | |
| 54 | + var previewPayments = trip.Status == ETripStatus.Active | |
| 55 | + ? _settlementService.PreviewSettlement(balanceEntries) | |
| 56 | + : new List<PreviewPayment>(); | |
| 57 | + | |
| 58 | + var model = new SettlementIndexViewModel | |
| 59 | + { | |
| 60 | + TripId = tripId, | |
| 61 | + TripName = trip.Name, | |
| 62 | + CurrencySymbol = trip.DefaultCurrency?.Symbol ?? "$", | |
| 63 | + TripStatus = trip.Status.ToString(), | |
| 64 | + IsOrganizer = await _tripService.IsOrganizerAsync(tripId, userId), | |
| 65 | + CurrentUserId = userId, | |
| 66 | + Balances = balances, | |
| 67 | + LatestPlan = latestPlan, | |
| 68 | + PreviewPayments = previewPayments | |
| 69 | + }; | |
| 70 | + | |
| 71 | + return View(model); | |
| 72 | + } | |
| 73 | + | |
| 74 | + // POST: Settlement/Finalize | |
| 75 | + [HttpPost] | |
| 76 | + [ValidateAntiForgeryToken] | |
| 77 | + public async Task<IActionResult> Finalize(Guid tripId) | |
| 78 | + { | |
| 79 | + var userId = GetUserId(); | |
| 80 | + var (ok, errorCode) = await _tripService.FinalizeTripAsync(tripId, userId); | |
| 81 | + if (!ok) | |
| 82 | + { | |
| 83 | + return errorCode switch | |
| 84 | + { | |
| 85 | + "forbidden" => Forbid(), | |
| 86 | + "notfound" => NotFound(), | |
| 87 | + "badstatus" => BadRequest(), | |
| 88 | + _ => NotFound() | |
| 89 | + }; | |
| 90 | + } | |
| 91 | + | |
| 92 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 93 | + } | |
| 94 | + | |
| 95 | + // POST: Settlement/Reopen | |
| 96 | + [HttpPost] | |
| 97 | + [ValidateAntiForgeryToken] | |
| 98 | + public async Task<IActionResult> Reopen(Guid tripId) | |
| 99 | + { | |
| 100 | + var userId = GetUserId(); | |
| 101 | + var (ok, errorCode) = await _tripService.ReopenTripAsync(tripId, userId); | |
| 102 | + if (!ok) | |
| 103 | + { | |
| 104 | + return errorCode switch | |
| 105 | + { | |
| 106 | + "forbidden" => Forbid(), | |
| 107 | + "notfound" => NotFound(), | |
| 108 | + "badstatus" => RedirectToAction(nameof(Index), new { tripId }), | |
| 109 | + "payments-confirmed" => RedirectToAction(nameof(Index), new { tripId }), | |
| 110 | + _ => NotFound() | |
| 111 | + }; | |
| 112 | + } | |
| 113 | + | |
| 114 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 115 | + } | |
| 116 | + | |
| 117 | + // POST: Settlement/MarkPaid/5 | |
| 118 | + [HttpPost] | |
| 119 | + [ValidateAntiForgeryToken] | |
| 120 | + public async Task<IActionResult> MarkPaid(Guid paymentId) | |
| 121 | + { | |
| 122 | + var userId = GetUserId(); | |
| 123 | + var payment = await _settlementService.GetPaymentByIdAsync(paymentId); | |
| 124 | + if (payment == null) return NotFound(); | |
| 125 | + | |
| 126 | + var plan = await _settlementService.GetPlanByIdAsync(payment.SettlementPlanId); | |
| 127 | + if (plan == null) return NotFound(); | |
| 128 | + | |
| 129 | + var (ok, errorCode) = await _settlementService.MarkPaidGuardedAsync(paymentId, userId); | |
| 130 | + if (!ok) | |
| 131 | + { | |
| 132 | + return errorCode switch | |
| 133 | + { | |
| 134 | + "forbidden" => Forbid(), | |
| 135 | + "notfound" => NotFound(), | |
| 136 | + _ => NotFound() | |
| 137 | + }; | |
| 138 | + } | |
| 139 | + | |
| 140 | + return RedirectToAction(nameof(Index), new { tripId = plan.TripId }); | |
| 141 | + } | |
| 142 | + | |
| 143 | + // POST: Settlement/ConfirmReceipt/5 | |
| 144 | + [HttpPost] | |
| 145 | + [ValidateAntiForgeryToken] | |
| 146 | + public async Task<IActionResult> ConfirmReceipt(Guid paymentId) | |
| 147 | + { | |
| 148 | + var userId = GetUserId(); | |
| 149 | + var payment = await _settlementService.GetPaymentByIdAsync(paymentId); | |
| 150 | + if (payment == null) return NotFound(); | |
| 151 | + | |
| 152 | + var plan = await _settlementService.GetPlanByIdAsync(payment.SettlementPlanId); | |
| 153 | + if (plan == null) return NotFound(); | |
| 154 | + | |
| 155 | + var (ok, errorCode) = await _settlementService.ConfirmPaymentGuardedAsync(paymentId, userId); | |
| 156 | + if (!ok) | |
| 157 | + { | |
| 158 | + return errorCode switch | |
| 159 | + { | |
| 160 | + "forbidden" => Forbid(), | |
| 161 | + "notfound" => NotFound(), | |
| 162 | + _ => NotFound() | |
| 163 | + }; | |
| 164 | + } | |
| 165 | + | |
| 166 | + return RedirectToAction(nameof(Index), new { tripId = plan.TripId }); | |
| 167 | + } | |
| 168 | +} | |
| 169 | + | |
| 170 | +public class SettlementIndexViewModel | |
| 171 | +{ | |
| 172 | + public Guid TripId { get; set; } | |
| 173 | + public string TripName { get; set; } = default!; | |
| 174 | + public string CurrencySymbol { get; set; } = default!; | |
| 175 | + public string TripStatus { get; set; } = default!; | |
| 176 | + public bool IsOrganizer { get; set; } | |
| 177 | + public Guid CurrentUserId { get; set; } | |
| 178 | + public List<SettlementBalanceViewModel> Balances { get; set; } = new(); | |
| 179 | + public App.BLL.DTO.SettlementPlanBllDto? LatestPlan { get; set; } | |
| 180 | + public List<PreviewPayment> PreviewPayments { get; set; } = new(); | |
| 181 | +} | |
| 182 | + | |
| 183 | +public class SettlementBalanceViewModel | |
| 184 | +{ | |
| 185 | + public Guid UserId { get; set; } | |
| 186 | + public string UserName { get; set; } = default!; | |
| 187 | + public decimal TotalPaid { get; set; } | |
| 188 | + public decimal TotalOwed { get; set; } | |
| 189 | + public decimal NetBalance => TotalPaid - TotalOwed; | |
| 190 | +} |
added SplitApp/WebApp/Controllers/TripsController.cs +258 −0
| @@ -0,0 +1,258 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Identity; | |
| 5 | +using Microsoft.AspNetCore.Authorization; | |
| 6 | +using Microsoft.AspNetCore.Identity; | |
| 7 | +using Microsoft.AspNetCore.Mvc; | |
| 8 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 9 | +using WebApp.Helpers; | |
| 10 | + | |
| 11 | +namespace WebApp.Controllers; | |
| 12 | + | |
| 13 | +[Authorize] | |
| 14 | +public class TripsController : Controller | |
| 15 | +{ | |
| 16 | + private readonly ITripService _tripService; | |
| 17 | + private readonly IExpenseService _expenseService; | |
| 18 | + private readonly IBudgetCategoryService _budgetCategoryService; | |
| 19 | + private readonly UserManager<AppUser> _userManager; | |
| 20 | + | |
| 21 | + public TripsController( | |
| 22 | + ITripService tripService, | |
| 23 | + IExpenseService expenseService, | |
| 24 | + IBudgetCategoryService budgetCategoryService, | |
| 25 | + UserManager<AppUser> userManager) | |
| 26 | + { | |
| 27 | + _tripService = tripService; | |
| 28 | + _expenseService = expenseService; | |
| 29 | + _budgetCategoryService = budgetCategoryService; | |
| 30 | + _userManager = userManager; | |
| 31 | + } | |
| 32 | + | |
| 33 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 34 | + | |
| 35 | + // GET: Trips | |
| 36 | + public async Task<IActionResult> Index() | |
| 37 | + { | |
| 38 | + var userId = GetUserId(); | |
| 39 | + | |
| 40 | + var trips = await _tripService.GetUserTripsAsync(userId); | |
| 41 | + | |
| 42 | + var model = new List<TripIndexViewModel>(); | |
| 43 | + foreach (var trip in trips) | |
| 44 | + { | |
| 45 | + var participant = trip.Participants?.FirstOrDefault(p => p.UserId == userId && p.IsActive); | |
| 46 | + if (participant == null) continue; | |
| 47 | + | |
| 48 | + model.Add(new TripIndexViewModel | |
| 49 | + { | |
| 50 | + Id = trip.Id, | |
| 51 | + Name = trip.Name, | |
| 52 | + Destination = trip.Destination, | |
| 53 | + Status = trip.Status, | |
| 54 | + StartDate = trip.StartDate, | |
| 55 | + EndDate = trip.EndDate, | |
| 56 | + Role = participant.Role, | |
| 57 | + CurrencyCode = trip.DefaultCurrency?.Code ?? "" | |
| 58 | + }); | |
| 59 | + } | |
| 60 | + | |
| 61 | + return View(model); | |
| 62 | + } | |
| 63 | + | |
| 64 | + // GET: Trips/Details/5 | |
| 65 | + public async Task<IActionResult> Details(Guid id) | |
| 66 | + { | |
| 67 | + var userId = GetUserId(); | |
| 68 | + | |
| 69 | + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId); | |
| 70 | + if (trip == null) return NotFound(); | |
| 71 | + | |
| 72 | + // Get participant role | |
| 73 | + var participants = await _tripService.GetParticipantsAsync(id, userId); | |
| 74 | + var participant = participants.FirstOrDefault(p => p.UserId == userId); | |
| 75 | + | |
| 76 | + var defaultCurrencyCode = trip.DefaultCurrency?.Code ?? "EUR"; | |
| 77 | + | |
| 78 | + // Get expenses with details for balance calculation | |
| 79 | + var expensesAll = await _expenseService.GetByTripIdAsync(id, userId); | |
| 80 | + | |
| 81 | + var recentExpenses = expensesAll | |
| 82 | + .OrderByDescending(e => e.ExpenseDate) | |
| 83 | + .Take(5) | |
| 84 | + .ToList(); | |
| 85 | + | |
| 86 | + var totalExpenses = expensesAll.Sum(e => | |
| 87 | + CurrencyConverter.Convert(e.Amount, e.Currency?.Code ?? defaultCurrencyCode, defaultCurrencyCode)); | |
| 88 | + | |
| 89 | + // Calculate balances for each participant | |
| 90 | + var balances = new Dictionary<Guid, SettlementBalanceViewModel>(); | |
| 91 | + foreach (var p in participants) | |
| 92 | + { | |
| 93 | + balances[p.UserId] = new SettlementBalanceViewModel | |
| 94 | + { | |
| 95 | + UserId = p.UserId, | |
| 96 | + UserName = p.User != null ? $"{p.User.FirstName} {p.User.LastName}" : "Unknown", | |
| 97 | + TotalPaid = 0, | |
| 98 | + TotalOwed = 0 | |
| 99 | + }; | |
| 100 | + } | |
| 101 | + | |
| 102 | + foreach (var expense in expensesAll) | |
| 103 | + { | |
| 104 | + var expenseWithSplits = await _expenseService.GetByIdWithDetailsAsync(expense.Id, userId); | |
| 105 | + if (expenseWithSplits == null) continue; | |
| 106 | + | |
| 107 | + var expCurrency = expenseWithSplits.Currency?.Code ?? defaultCurrencyCode; | |
| 108 | + var convertedAmount = CurrencyConverter.Convert(expenseWithSplits.Amount, expCurrency, defaultCurrencyCode); | |
| 109 | + | |
| 110 | + if (balances.ContainsKey(expenseWithSplits.PaidByUserId)) | |
| 111 | + balances[expenseWithSplits.PaidByUserId].TotalPaid += convertedAmount; | |
| 112 | + | |
| 113 | + if (expenseWithSplits.Splits != null) | |
| 114 | + { | |
| 115 | + foreach (var split in expenseWithSplits.Splits) | |
| 116 | + { | |
| 117 | + var convertedSplit = CurrencyConverter.Convert(split.Amount, expCurrency, defaultCurrencyCode); | |
| 118 | + if (balances.ContainsKey(split.UserId)) | |
| 119 | + balances[split.UserId].TotalOwed += convertedSplit; | |
| 120 | + } | |
| 121 | + } | |
| 122 | + } | |
| 123 | + | |
| 124 | + // Calculate budget totals (only category-assigned expenses count against budget) | |
| 125 | + var budgetCategories = await _budgetCategoryService.GetByTripIdAsync(id, userId); | |
| 126 | + var totalPlanned = budgetCategories.Sum(c => c.PlannedAmount ?? 0); | |
| 127 | + var totalBudgetSpent = budgetCategories.Sum(c => c.SpentAmount); | |
| 128 | + var budgetUsedPct = totalPlanned > 0 ? (int)(totalBudgetSpent * 100 / totalPlanned) : 0; | |
| 129 | + | |
| 130 | + // Current user's balance | |
| 131 | + var currentUserBalance = balances.ContainsKey(userId) ? balances[userId].NetBalance : 0; | |
| 132 | + | |
| 133 | + ViewData["TripId"] = id; | |
| 134 | + ViewData["TripName"] = trip.Name; | |
| 135 | + ViewData["ParticipantCount"] = participants.Count; | |
| 136 | + ViewData["RecentExpenses"] = recentExpenses; | |
| 137 | + ViewData["TotalExpenses"] = totalExpenses; | |
| 138 | + ViewData["UserRole"] = participant?.Role ?? EParticipantRole.Participant; | |
| 139 | + ViewData["Balances"] = balances.Values.OrderByDescending(b => b.NetBalance).ToList(); | |
| 140 | + ViewData["CurrentUserBalance"] = currentUserBalance; | |
| 141 | + ViewData["BudgetUsedPct"] = budgetUsedPct; | |
| 142 | + ViewData["TotalPlanned"] = totalPlanned; | |
| 143 | + ViewData["CurrencySymbol"] = trip.DefaultCurrency?.Symbol ?? "\u20ac"; | |
| 144 | + | |
| 145 | + return View(trip); | |
| 146 | + } | |
| 147 | + | |
| 148 | + // GET: Trips/Create | |
| 149 | + public async Task<IActionResult> Create() | |
| 150 | + { | |
| 151 | + await PopulateCurrencyDropdown(); | |
| 152 | + return View(); | |
| 153 | + } | |
| 154 | + | |
| 155 | + // POST: Trips/Create | |
| 156 | + [HttpPost] | |
| 157 | + [ValidateAntiForgeryToken] | |
| 158 | + public async Task<IActionResult> Create(TripBllDto trip) | |
| 159 | + { | |
| 160 | + var userId = GetUserId(); | |
| 161 | + | |
| 162 | + if (ModelState.IsValid) | |
| 163 | + { | |
| 164 | + var created = await _tripService.CreateTripAsync(trip, userId); | |
| 165 | + return RedirectToAction(nameof(Details), new { id = created.Id }); | |
| 166 | + } | |
| 167 | + | |
| 168 | + await PopulateCurrencyDropdown(trip.DefaultCurrencyId); | |
| 169 | + return View(trip); | |
| 170 | + } | |
| 171 | + | |
| 172 | + // GET: Trips/Edit/5 | |
| 173 | + public async Task<IActionResult> Edit(Guid id) | |
| 174 | + { | |
| 175 | + var userId = GetUserId(); | |
| 176 | + | |
| 177 | + var trip = await _tripService.GetByIdForOrganizerAsync(id, userId); | |
| 178 | + if (trip == null) | |
| 179 | + { | |
| 180 | + // Distinguish not-organizer vs not-found | |
| 181 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 182 | + return NotFound(); | |
| 183 | + } | |
| 184 | + | |
| 185 | + await PopulateCurrencyDropdown(trip.DefaultCurrencyId); | |
| 186 | + return View(trip); | |
| 187 | + } | |
| 188 | + | |
| 189 | + // POST: Trips/Edit/5 | |
| 190 | + [HttpPost] | |
| 191 | + [ValidateAntiForgeryToken] | |
| 192 | + public async Task<IActionResult> Edit(Guid id, TripBllDto trip) | |
| 193 | + { | |
| 194 | + if (id != trip.Id) return NotFound(); | |
| 195 | + | |
| 196 | + var userId = GetUserId(); | |
| 197 | + | |
| 198 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 199 | + | |
| 200 | + if (ModelState.IsValid) | |
| 201 | + { | |
| 202 | + var updated = await _tripService.UpdateAsync(trip, userId); | |
| 203 | + if (updated == null) return NotFound(); | |
| 204 | + return RedirectToAction(nameof(Details), new { id }); | |
| 205 | + } | |
| 206 | + | |
| 207 | + await PopulateCurrencyDropdown(trip.DefaultCurrencyId); | |
| 208 | + return View(trip); | |
| 209 | + } | |
| 210 | + | |
| 211 | + // GET: Trips/Delete/5 | |
| 212 | + public async Task<IActionResult> Delete(Guid id) | |
| 213 | + { | |
| 214 | + var userId = GetUserId(); | |
| 215 | + | |
| 216 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 217 | + | |
| 218 | + var trip = await _tripService.GetByIdWithDetailsAsync(id, userId); | |
| 219 | + if (trip == null) return NotFound(); | |
| 220 | + | |
| 221 | + return View(trip); | |
| 222 | + } | |
| 223 | + | |
| 224 | + // POST: Trips/Delete/5 | |
| 225 | + [HttpPost, ActionName("Delete")] | |
| 226 | + [ValidateAntiForgeryToken] | |
| 227 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 228 | + { | |
| 229 | + var userId = GetUserId(); | |
| 230 | + | |
| 231 | + var success = await _tripService.DeleteAsync(id, userId); | |
| 232 | + if (!success) | |
| 233 | + { | |
| 234 | + if (!await _tripService.IsOrganizerAsync(id, userId)) return Forbid(); | |
| 235 | + return NotFound(); | |
| 236 | + } | |
| 237 | + | |
| 238 | + return RedirectToAction(nameof(Index)); | |
| 239 | + } | |
| 240 | + | |
| 241 | + private async Task PopulateCurrencyDropdown(Guid? selectedId = null) | |
| 242 | + { | |
| 243 | + var currencies = await _tripService.GetAllCurrenciesAsync(); | |
| 244 | + ViewData["DefaultCurrencyId"] = new SelectList(currencies, "Id", "Code", selectedId); | |
| 245 | + } | |
| 246 | +} | |
| 247 | + | |
| 248 | +public class TripIndexViewModel | |
| 249 | +{ | |
| 250 | + public Guid Id { get; set; } | |
| 251 | + public string Name { get; set; } = default!; | |
| 252 | + public string? Destination { get; set; } | |
| 253 | + public ETripStatus Status { get; set; } | |
| 254 | + public DateTime? StartDate { get; set; } | |
| 255 | + public DateTime? EndDate { get; set; } | |
| 256 | + public EParticipantRole Role { get; set; } | |
| 257 | + public string CurrencyCode { get; set; } = default!; | |
| 258 | +} |
added SplitApp/WebApp/Controllers/WishlistClientController.cs +277 −0
| @@ -0,0 +1,277 @@ | ||
| 1 | +using App.BLL.DTO; | |
| 2 | +using App.BLL.Services; | |
| 3 | +using App.Domain; | |
| 4 | +using App.Domain.Identity; | |
| 5 | +using Microsoft.AspNetCore.Authorization; | |
| 6 | +using Microsoft.AspNetCore.Identity; | |
| 7 | +using Microsoft.AspNetCore.Mvc; | |
| 8 | +using Microsoft.AspNetCore.Mvc.Rendering; | |
| 9 | + | |
| 10 | +namespace WebApp.Controllers; | |
| 11 | + | |
| 12 | +[Authorize] | |
| 13 | +public class WishlistClientController : Controller | |
| 14 | +{ | |
| 15 | + private readonly ITripService _tripService; | |
| 16 | + private readonly IWishlistService _wishlistService; | |
| 17 | + private readonly UserManager<AppUser> _userManager; | |
| 18 | + | |
| 19 | + public WishlistClientController( | |
| 20 | + ITripService tripService, | |
| 21 | + IWishlistService wishlistService, | |
| 22 | + UserManager<AppUser> userManager) | |
| 23 | + { | |
| 24 | + _tripService = tripService; | |
| 25 | + _wishlistService = wishlistService; | |
| 26 | + _userManager = userManager; | |
| 27 | + } | |
| 28 | + | |
| 29 | + private Guid GetUserId() => Guid.Parse(_userManager.GetUserId(User)!); | |
| 30 | + | |
| 31 | + // GET: WishlistClient?tripId=xxx | |
| 32 | + public async Task<IActionResult> Index(Guid tripId) | |
| 33 | + { | |
| 34 | + var userId = GetUserId(); | |
| 35 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 36 | + | |
| 37 | + var trip = await _tripService.GetByIdAsync(tripId, userId); | |
| 38 | + if (trip == null) return NotFound(); | |
| 39 | + | |
| 40 | + var items = await _wishlistService.GetByTripIdAsync(tripId, userId); | |
| 41 | + | |
| 42 | + ViewData["CurrentUserId"] = userId; | |
| 43 | + | |
| 44 | + var model = items.Select(item => new WishlistItemViewModel | |
| 45 | + { | |
| 46 | + Id = item.Id, | |
| 47 | + AddedByUserId = item.AddedByUserId, | |
| 48 | + Title = item.Title, | |
| 49 | + Description = item.Description, | |
| 50 | + Category = item.Category, | |
| 51 | + Priority = item.Priority, | |
| 52 | + EstimatedCost = item.EstimatedCost, | |
| 53 | + Url = item.Url, | |
| 54 | + Location = item.Location, | |
| 55 | + IsCompleted = item.IsCompleted, | |
| 56 | + AddedByName = item.AddedByUserFullName ?? "Unknown", | |
| 57 | + VoteCount = item.VoteCount, | |
| 58 | + UserHasVoted = item.VoterUserIds.Contains(userId) | |
| 59 | + }).ToList(); | |
| 60 | + | |
| 61 | + ViewData["TripId"] = tripId; | |
| 62 | + ViewData["TripName"] = trip.Name; | |
| 63 | + | |
| 64 | + return View(model); | |
| 65 | + } | |
| 66 | + | |
| 67 | + // GET: WishlistClient/Create?tripId=xxx | |
| 68 | + public async Task<IActionResult> Create(Guid tripId) | |
| 69 | + { | |
| 70 | + var userId = GetUserId(); | |
| 71 | + if (!await _tripService.IsParticipantAsync(tripId, userId)) return Forbid(); | |
| 72 | + | |
| 73 | + ViewData["TripId"] = tripId; | |
| 74 | + PopulateEnumDropdowns(); | |
| 75 | + return View(new TripWishlistItemBllDto { TripId = tripId }); | |
| 76 | + } | |
| 77 | + | |
| 78 | + // POST: WishlistClient/Create | |
| 79 | + [HttpPost] | |
| 80 | + [ValidateAntiForgeryToken] | |
| 81 | + public async Task<IActionResult> Create(TripWishlistItemBllDto item) | |
| 82 | + { | |
| 83 | + var userId = GetUserId(); | |
| 84 | + | |
| 85 | + if (ModelState.IsValid) | |
| 86 | + { | |
| 87 | + var (created, errorCode) = await _wishlistService.CreateAsync(item, userId); | |
| 88 | + if (created == null) | |
| 89 | + { | |
| 90 | + if (errorCode == "forbidden") return Forbid(); | |
| 91 | + return NotFound(); | |
| 92 | + } | |
| 93 | + return RedirectToAction(nameof(Index), new { tripId = item.TripId }); | |
| 94 | + } | |
| 95 | + | |
| 96 | + ViewData["TripId"] = item.TripId; | |
| 97 | + PopulateEnumDropdowns(); | |
| 98 | + return View(item); | |
| 99 | + } | |
| 100 | + | |
| 101 | + // POST: WishlistClient/Vote/5 | |
| 102 | + [HttpPost] | |
| 103 | + [ValidateAntiForgeryToken] | |
| 104 | + public async Task<IActionResult> Vote(Guid id) | |
| 105 | + { | |
| 106 | + var userId = GetUserId(); | |
| 107 | + | |
| 108 | + var item = await _wishlistService.GetByIdRawAsync(id); | |
| 109 | + if (item == null) return NotFound(); | |
| 110 | + | |
| 111 | + var (ok, errorCode) = await _wishlistService.ToggleVoteAsync(id, userId); | |
| 112 | + if (!ok) | |
| 113 | + { | |
| 114 | + return errorCode switch | |
| 115 | + { | |
| 116 | + "notfound" => NotFound(), | |
| 117 | + "forbidden" => Forbid(), | |
| 118 | + _ => NotFound() | |
| 119 | + }; | |
| 120 | + } | |
| 121 | + | |
| 122 | + return RedirectToAction(nameof(Index), new { tripId = item.TripId }); | |
| 123 | + } | |
| 124 | + | |
| 125 | + // POST: WishlistClient/Complete/5 | |
| 126 | + [HttpPost] | |
| 127 | + [ValidateAntiForgeryToken] | |
| 128 | + public async Task<IActionResult> Complete(Guid id) | |
| 129 | + { | |
| 130 | + var userId = GetUserId(); | |
| 131 | + | |
| 132 | + var item = await _wishlistService.GetByIdRawAsync(id); | |
| 133 | + if (item == null) return NotFound(); | |
| 134 | + | |
| 135 | + var (ok, errorCode) = await _wishlistService.ToggleCompleteAsync(id, userId); | |
| 136 | + if (!ok) | |
| 137 | + { | |
| 138 | + return errorCode switch | |
| 139 | + { | |
| 140 | + "notfound" => NotFound(), | |
| 141 | + "forbidden" => Forbid(), | |
| 142 | + _ => NotFound() | |
| 143 | + }; | |
| 144 | + } | |
| 145 | + | |
| 146 | + return RedirectToAction(nameof(Index), new { tripId = item.TripId }); | |
| 147 | + } | |
| 148 | + | |
| 149 | + // GET: WishlistClient/Edit/5 | |
| 150 | + public async Task<IActionResult> Edit(Guid id) | |
| 151 | + { | |
| 152 | + var userId = GetUserId(); | |
| 153 | + | |
| 154 | + var item = await _wishlistService.GetByIdAsync(id, userId); | |
| 155 | + if (item == null) | |
| 156 | + { | |
| 157 | + var raw = await _wishlistService.GetByIdRawAsync(id); | |
| 158 | + if (raw == null) return NotFound(); | |
| 159 | + return Forbid(); | |
| 160 | + } | |
| 161 | + | |
| 162 | + if (item.AddedByUserId != userId) return Forbid(); | |
| 163 | + | |
| 164 | + ViewData["TripId"] = item.TripId; | |
| 165 | + PopulateEnumDropdowns(); | |
| 166 | + return View(item); | |
| 167 | + } | |
| 168 | + | |
| 169 | + // POST: WishlistClient/Edit/5 | |
| 170 | + [HttpPost] | |
| 171 | + [ValidateAntiForgeryToken] | |
| 172 | + public async Task<IActionResult> Edit(Guid id, TripWishlistItemBllDto item) | |
| 173 | + { | |
| 174 | + if (id != item.Id) return NotFound(); | |
| 175 | + | |
| 176 | + var userId = GetUserId(); | |
| 177 | + | |
| 178 | + if (ModelState.IsValid) | |
| 179 | + { | |
| 180 | + var preUpdate = await _wishlistService.GetByIdRawAsync(id); | |
| 181 | + if (preUpdate == null) return NotFound(); | |
| 182 | + | |
| 183 | + var (ok, errorCode) = await _wishlistService.UpdateAsync(id, item, userId); | |
| 184 | + if (!ok) | |
| 185 | + { | |
| 186 | + return errorCode switch | |
| 187 | + { | |
| 188 | + "notfound" => NotFound(), | |
| 189 | + "forbidden" => Forbid(), | |
| 190 | + _ => NotFound() | |
| 191 | + }; | |
| 192 | + } | |
| 193 | + | |
| 194 | + return RedirectToAction(nameof(Index), new { tripId = preUpdate.TripId }); | |
| 195 | + } | |
| 196 | + | |
| 197 | + var raw = await _wishlistService.GetByIdRawAsync(id); | |
| 198 | + if (raw == null) return NotFound(); | |
| 199 | + ViewData["TripId"] = raw.TripId; | |
| 200 | + PopulateEnumDropdowns(); | |
| 201 | + return View(item); | |
| 202 | + } | |
| 203 | + | |
| 204 | + // GET: WishlistClient/Delete/5 | |
| 205 | + public async Task<IActionResult> Delete(Guid id) | |
| 206 | + { | |
| 207 | + var userId = GetUserId(); | |
| 208 | + | |
| 209 | + var item = await _wishlistService.GetByIdAsync(id, userId); | |
| 210 | + if (item == null) | |
| 211 | + { | |
| 212 | + var raw = await _wishlistService.GetByIdRawAsync(id); | |
| 213 | + if (raw == null) return NotFound(); | |
| 214 | + return Forbid(); | |
| 215 | + } | |
| 216 | + if (item.AddedByUserId != userId) return Forbid(); | |
| 217 | + | |
| 218 | + // Re-fetch with includes via trip items list | |
| 219 | + var tripItems = await _wishlistService.GetByTripIdAsync(item.TripId, userId); | |
| 220 | + var itemWithDetails = tripItems.FirstOrDefault(w => w.Id == id); | |
| 221 | + | |
| 222 | + ViewData["TripId"] = item.TripId; | |
| 223 | + return View(itemWithDetails ?? item); | |
| 224 | + } | |
| 225 | + | |
| 226 | + // POST: WishlistClient/Delete/5 | |
| 227 | + [HttpPost, ActionName("Delete")] | |
| 228 | + [ValidateAntiForgeryToken] | |
| 229 | + public async Task<IActionResult> DeleteConfirmed(Guid id) | |
| 230 | + { | |
| 231 | + var userId = GetUserId(); | |
| 232 | + | |
| 233 | + var existing = await _wishlistService.GetByIdRawAsync(id); | |
| 234 | + if (existing == null) return NotFound(); | |
| 235 | + var tripId = existing.TripId; | |
| 236 | + | |
| 237 | + var (ok, errorCode) = await _wishlistService.DeleteAsync(id, userId); | |
| 238 | + if (!ok) | |
| 239 | + { | |
| 240 | + return errorCode switch | |
| 241 | + { | |
| 242 | + "notfound" => NotFound(), | |
| 243 | + "forbidden" => Forbid(), | |
| 244 | + _ => NotFound() | |
| 245 | + }; | |
| 246 | + } | |
| 247 | + | |
| 248 | + return RedirectToAction(nameof(Index), new { tripId }); | |
| 249 | + } | |
| 250 | + | |
| 251 | + private void PopulateEnumDropdowns() | |
| 252 | + { | |
| 253 | + ViewData["Categories"] = new SelectList( | |
| 254 | + Enum.GetValues<EWishlistCategory>().Select(e => new { Value = (int)e, Text = e.ToString() }), | |
| 255 | + "Value", "Text"); | |
| 256 | + ViewData["Priorities"] = new SelectList( | |
| 257 | + Enum.GetValues<EWishlistPriority>().Select(e => new { Value = (int)e, Text = e.ToString() }), | |
| 258 | + "Value", "Text"); | |
| 259 | + } | |
| 260 | +} | |
| 261 | + | |
| 262 | +public class WishlistItemViewModel | |
| 263 | +{ | |
| 264 | + public Guid Id { get; set; } | |
| 265 | + public Guid AddedByUserId { get; set; } | |
| 266 | + public string Title { get; set; } = default!; | |
| 267 | + public string? Description { get; set; } | |
| 268 | + public EWishlistCategory Category { get; set; } | |
| 269 | + public EWishlistPriority Priority { get; set; } | |
| 270 | + public decimal? EstimatedCost { get; set; } | |
| 271 | + public string? Url { get; set; } | |
| 272 | + public string? Location { get; set; } | |
| 273 | + public bool IsCompleted { get; set; } | |
| 274 | + public string AddedByName { get; set; } = default!; | |
| 275 | + public int VoteCount { get; set; } | |
| 276 | + public bool UserHasVoted { get; set; } | |
| 277 | +} |
added SplitApp/WebApp/Helpers/CurrencyConverter.cs +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +namespace WebApp.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/WebApp/Helpers/EnumHelper.cs +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +using System.Resources; | |
| 2 | + | |
| 3 | +namespace WebApp.Helpers; | |
| 4 | + | |
| 5 | +public static class EnumHelper | |
| 6 | +{ | |
| 7 | + private static readonly ResourceManager ResManager = | |
| 8 | + new("App.Resources.Domain.Enums", typeof(App.Resources.Domain.Enums).Assembly); | |
| 9 | + | |
| 10 | + public static string GetDisplayName<TEnum>(TEnum value) where TEnum : struct, Enum | |
| 11 | + { | |
| 12 | + var key = $"{typeof(TEnum).Name}_{value}"; | |
| 13 | + return ResManager.GetString(key, Thread.CurrentThread.CurrentUICulture) ?? value.ToString(); | |
| 14 | + } | |
| 15 | +} |
added SplitApp/WebApp/InvariantDecimalModelBinderProvider.cs +56 −0
| @@ -0,0 +1,56 @@ | ||
| 1 | +using System.Globalization; | |
| 2 | +using Microsoft.AspNetCore.Mvc.ModelBinding; | |
| 3 | + | |
| 4 | +namespace WebApp; | |
| 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 | + // Normalize: replace comma with dot so InvariantCulture can parse both formats | |
| 43 | + value = value.Replace(',', '.'); | |
| 44 | + | |
| 45 | + if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result)) | |
| 46 | + { | |
| 47 | + bindingContext.Result = ModelBindingResult.Success(result); | |
| 48 | + } | |
| 49 | + else | |
| 50 | + { | |
| 51 | + bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, "Invalid number format."); | |
| 52 | + } | |
| 53 | + | |
| 54 | + return Task.CompletedTask; | |
| 55 | + } | |
| 56 | +} |
added SplitApp/WebApp/Models/ErrorViewModel.cs +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +namespace 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/WebApp/Program.cs +295 −0
| @@ -0,0 +1,295 @@ | ||
| 1 | +using System.Globalization; | |
| 2 | +using System.IdentityModel.Tokens.Jwt; | |
| 3 | +using System.Text; | |
| 4 | +using App.BLL.Services; | |
| 5 | +using App.DAL.EF; | |
| 6 | +using App.DAL.EF.Seeding; | |
| 7 | +using App.Domain.Contracts; | |
| 8 | +using App.Domain.Identity; | |
| 9 | +using Asp.Versioning; | |
| 10 | +using Asp.Versioning.ApiExplorer; | |
| 11 | +using Microsoft.AspNetCore.DataProtection; | |
| 12 | +using Microsoft.AspNetCore.HttpOverrides; | |
| 13 | +using Microsoft.AspNetCore.Identity; | |
| 14 | +using Microsoft.AspNetCore.Localization; | |
| 15 | +using Microsoft.EntityFrameworkCore; | |
| 16 | +using Microsoft.EntityFrameworkCore.Diagnostics; | |
| 17 | +using Microsoft.Extensions.Options; | |
| 18 | +using Microsoft.IdentityModel.Tokens; | |
| 19 | +using Npgsql; | |
| 20 | +using Swashbuckle.AspNetCore.SwaggerGen; | |
| 21 | +using WebApp; | |
| 22 | + | |
| 23 | +var builder = WebApplication.CreateBuilder(args); | |
| 24 | + | |
| 25 | +// Add services to the container. | |
| 26 | +var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? | |
| 27 | + throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); | |
| 28 | + | |
| 29 | +// DAL composition — single entry point from App.DAL.EF (Clean: WebApp doesn't wire DAL internals) | |
| 30 | +builder.Services.AddDalServices(connectionString); | |
| 31 | + | |
| 32 | +builder.Services.AddDatabaseDeveloperPageExceptionFilter(); | |
| 33 | + | |
| 34 | +// BLL - Services | |
| 35 | +builder.Services.AddScoped<ITripService, TripService>(); | |
| 36 | +builder.Services.AddScoped<IExpenseService, ExpenseService>(); | |
| 37 | +builder.Services.AddScoped<ISettlementService, SettlementService>(); | |
| 38 | +builder.Services.AddScoped<IInvitationService, InvitationService>(); | |
| 39 | +builder.Services.AddScoped<IPollService, PollService>(); | |
| 40 | +builder.Services.AddScoped<IBudgetCategoryService, BudgetCategoryService>(); | |
| 41 | +builder.Services.AddScoped<IWishlistService, WishlistService>(); | |
| 42 | +builder.Services.AddScoped<ISplitPresetService, SplitPresetService>(); | |
| 43 | +builder.Services.AddScoped<App.BLL.Services.Identity.IIdentityService, App.BLL.Services.Identity.IdentityService>(); | |
| 44 | + | |
| 45 | +// BLL - Admin Services (Clean: admin controllers go through services, not UoW) | |
| 46 | +builder.Services.AddScoped<App.BLL.Services.Admin.IBudgetCategoryAdminService, App.BLL.Services.Admin.BudgetCategoryAdminService>(); | |
| 47 | +builder.Services.AddScoped<App.BLL.Services.Admin.ICurrencyAdminService, App.BLL.Services.Admin.CurrencyAdminService>(); | |
| 48 | +builder.Services.AddScoped<App.BLL.Services.Admin.ITripAdminService, App.BLL.Services.Admin.TripAdminService>(); | |
| 49 | +builder.Services.AddScoped<App.BLL.Services.Admin.IExpenseAdminService, App.BLL.Services.Admin.ExpenseAdminService>(); | |
| 50 | +builder.Services.AddScoped<App.BLL.Services.Admin.IPollAdminService, App.BLL.Services.Admin.PollAdminService>(); | |
| 51 | +builder.Services.AddScoped<App.BLL.Services.Admin.IWishlistAdminService, App.BLL.Services.Admin.WishlistAdminService>(); | |
| 52 | +builder.Services.AddScoped<App.BLL.Services.Admin.ISettlementPlanAdminService, App.BLL.Services.Admin.SettlementPlanAdminService>(); | |
| 53 | +builder.Services.AddScoped<App.BLL.Services.Admin.ISettlementPaymentAdminService, App.BLL.Services.Admin.SettlementPaymentAdminService>(); | |
| 54 | +builder.Services.AddScoped<App.BLL.Services.Admin.ISplitPresetAdminService, App.BLL.Services.Admin.SplitPresetAdminService>(); | |
| 55 | +builder.Services.AddScoped<App.BLL.Services.Admin.ITripParticipantAdminService, App.BLL.Services.Admin.TripParticipantAdminService>(); | |
| 56 | +builder.Services.AddScoped<App.BLL.Services.Admin.IInvitationAdminService, App.BLL.Services.Admin.InvitationAdminService>(); | |
| 57 | +builder.Services.AddScoped<App.BLL.Services.Admin.IAdminStatsService, App.BLL.Services.Admin.AdminStatsService>(); | |
| 58 | + | |
| 59 | +builder.Services | |
| 60 | + .AddDataProtection() | |
| 61 | + .PersistKeysToDbContext<AppDbContext>(); | |
| 62 | + | |
| 63 | +builder.Services.AddIdentity<AppUser, AppRole>(options => options.SignIn.RequireConfirmedAccount = false) | |
| 64 | + .AddDefaultUI() | |
| 65 | + .AddEntityFrameworkStores<AppDbContext>() | |
| 66 | + .AddDefaultTokenProviders(); | |
| 67 | + | |
| 68 | +JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); | |
| 69 | +builder.Services | |
| 70 | + .AddAuthentication() | |
| 71 | + .AddCookie(options => { options.SlidingExpiration = true; }) | |
| 72 | + .AddJwtBearer(cfg => | |
| 73 | + { | |
| 74 | + cfg.RequireHttpsMetadata = false; // TODO: set to true in production! | |
| 75 | + cfg.SaveToken = true; | |
| 76 | + cfg.TokenValidationParameters = new TokenValidationParameters | |
| 77 | + { | |
| 78 | + ValidIssuer = builder.Configuration["JWT:Issuer"], | |
| 79 | + ValidAudience = builder.Configuration["JWT:Audience"], | |
| 80 | + IssuerSigningKey = new SymmetricSecurityKey( | |
| 81 | + Encoding.UTF8.GetBytes(builder.Configuration["JWT:Key"]!)), | |
| 82 | + ClockSkew = TimeSpan.Zero | |
| 83 | + }; | |
| 84 | + }); | |
| 85 | + | |
| 86 | +var supportedCultures = builder.Configuration | |
| 87 | + .GetSection("SupportedCultures") | |
| 88 | + .GetChildren() | |
| 89 | + .Select(x => new CultureInfo(x.Value!)) | |
| 90 | + .ToArray(); | |
| 91 | + | |
| 92 | +builder.Services.Configure<RequestLocalizationOptions>(options => | |
| 93 | +{ | |
| 94 | + options.SupportedCultures = supportedCultures; | |
| 95 | + options.SupportedUICultures = supportedCultures; | |
| 96 | + options.DefaultRequestCulture = new RequestCulture("en", "en"); | |
| 97 | + options.SetDefaultCulture("en"); | |
| 98 | + | |
| 99 | + options.RequestCultureProviders = new List<IRequestCultureProvider> | |
| 100 | + { | |
| 101 | + new QueryStringRequestCultureProvider(), | |
| 102 | + new CookieRequestCultureProvider() | |
| 103 | + }; | |
| 104 | +}); | |
| 105 | + | |
| 106 | +builder.Services.AddCors(options => | |
| 107 | +{ | |
| 108 | + options.AddPolicy("CorsAllowAll", policy => | |
| 109 | + { | |
| 110 | + policy | |
| 111 | + .AllowAnyOrigin() | |
| 112 | + .AllowAnyHeader() | |
| 113 | + .AllowAnyMethod() | |
| 114 | + .WithExposedHeaders("X-Version", "X-Version-Created-At"); | |
| 115 | + }); | |
| 116 | +}); | |
| 117 | + | |
| 118 | +var apiVersioningBuilder = builder.Services.AddApiVersioning(options => | |
| 119 | +{ | |
| 120 | + options.ReportApiVersions = true; | |
| 121 | + options.DefaultApiVersion = new ApiVersion(1, 0); | |
| 122 | +}); | |
| 123 | + | |
| 124 | +apiVersioningBuilder.AddApiExplorer(options => | |
| 125 | +{ | |
| 126 | + options.GroupNameFormat = "'v'VVV"; | |
| 127 | + options.SubstituteApiVersionInUrl = true; | |
| 128 | +}); | |
| 129 | + | |
| 130 | +builder.Services.AddEndpointsApiExplorer(); | |
| 131 | +builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>(); | |
| 132 | +builder.Services.AddSwaggerGen(); | |
| 133 | + | |
| 134 | +// ForwardedHeaders — when app is behind a reverse proxy (e.g. Docker + Caddy), | |
| 135 | +// trust X-Forwarded-For / X-Forwarded-Host / X-Forwarded-Proto so Url.Action() generates | |
| 136 | +// links using the external hostname instead of the container IP. | |
| 137 | +builder.Services.Configure<ForwardedHeadersOptions>(options => | |
| 138 | +{ | |
| 139 | + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | |
| 140 | + | ForwardedHeaders.XForwardedProto | |
| 141 | + | ForwardedHeaders.XForwardedHost; | |
| 142 | + // Proxy runs in an unknown network — trust any proxy | |
| 143 | + options.KnownIPNetworks.Clear(); | |
| 144 | + options.KnownProxies.Clear(); | |
| 145 | +}); | |
| 146 | + | |
| 147 | +builder.Services.AddLocalization(options => options.ResourcesPath = ""); | |
| 148 | +builder.Services.AddControllersWithViews(options => | |
| 149 | + { | |
| 150 | + // Fix decimal binding: HTML number inputs always send dot-separated values, | |
| 151 | + // but et-EE locale expects comma. This binder handles both formats. | |
| 152 | + options.ModelBinderProviders.Insert(0, new InvariantDecimalModelBinderProvider()); | |
| 153 | + }) | |
| 154 | + .AddViewLocalization() | |
| 155 | + .AddDataAnnotationsLocalization(); | |
| 156 | + | |
| 157 | +// ============================================== | |
| 158 | +var app = builder.Build(); | |
| 159 | +// ============================================== PIPELINE =============================== | |
| 160 | +// Skip data initialization in tests — WebApplicationFactory provides its own SQLite DB | |
| 161 | +if (!app.Environment.IsEnvironment("Testing")) | |
| 162 | +{ | |
| 163 | + SetupAppData(app, app.Environment, app.Configuration); | |
| 164 | +} | |
| 165 | + | |
| 166 | +// MUST be first — process proxy headers before anything else touches Request.Host/Scheme | |
| 167 | +app.UseForwardedHeaders(); | |
| 168 | + | |
| 169 | +// Configure the HTTP request pipeline. | |
| 170 | +if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing")) | |
| 171 | +{ | |
| 172 | + app.UseMigrationsEndPoint(); | |
| 173 | + app.UseDeveloperExceptionPage(); | |
| 174 | +} | |
| 175 | +else | |
| 176 | +{ | |
| 177 | + app.UseExceptionHandler("/Home/Error"); | |
| 178 | + app.UseHsts(); | |
| 179 | +} | |
| 180 | + | |
| 181 | +app.UseHttpsRedirection(); | |
| 182 | + | |
| 183 | +app.UseRequestLocalization(options: app.Services | |
| 184 | + .GetService<IOptions<RequestLocalizationOptions>>()!.Value); | |
| 185 | + | |
| 186 | +app.UseCors("CorsAllowAll"); | |
| 187 | + | |
| 188 | +app.UseRouting(); | |
| 189 | + | |
| 190 | +app.UseAuthorization(); | |
| 191 | + | |
| 192 | +app.UseSwagger(); | |
| 193 | +app.UseSwaggerUI(options => | |
| 194 | +{ | |
| 195 | + var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>(); | |
| 196 | + foreach (var description in provider.ApiVersionDescriptions) | |
| 197 | + { | |
| 198 | + options.SwaggerEndpoint( | |
| 199 | + $"/swagger/{description.GroupName}/swagger.json", | |
| 200 | + description.GroupName.ToUpperInvariant() | |
| 201 | + ); | |
| 202 | + } | |
| 203 | +}); | |
| 204 | + | |
| 205 | +app.MapStaticAssets(); | |
| 206 | + | |
| 207 | +app.MapControllerRoute( | |
| 208 | + name: "areas", | |
| 209 | + pattern: "{area:exists}/{controller=Dashboard}/{action=Index}/{id?}") | |
| 210 | + .WithStaticAssets(); | |
| 211 | + | |
| 212 | +app.MapControllerRoute( | |
| 213 | + name: "default", | |
| 214 | + pattern: "{controller=Home}/{action=Index}/{id?}") | |
| 215 | + .WithStaticAssets(); | |
| 216 | + | |
| 217 | +app.MapRazorPages() | |
| 218 | + .WithStaticAssets(); | |
| 219 | + | |
| 220 | +app.Run(); | |
| 221 | + | |
| 222 | +return; | |
| 223 | + | |
| 224 | +static void SetupAppData(IApplicationBuilder app, IWebHostEnvironment env, IConfiguration configuration) | |
| 225 | +{ | |
| 226 | + using var serviceScope = ((IApplicationBuilder)app).ApplicationServices | |
| 227 | + .GetRequiredService<IServiceScopeFactory>() | |
| 228 | + .CreateScope(); | |
| 229 | + var logger = serviceScope.ServiceProvider.GetRequiredService<ILogger<IApplicationBuilder>>(); | |
| 230 | + | |
| 231 | + using var context = serviceScope.ServiceProvider.GetRequiredService<AppDbContext>(); | |
| 232 | + | |
| 233 | + WaitDbConnection(context, logger); | |
| 234 | + | |
| 235 | + using var userManager = serviceScope.ServiceProvider.GetRequiredService<UserManager<AppUser>>(); | |
| 236 | + using var roleManager = serviceScope.ServiceProvider.GetRequiredService<RoleManager<AppRole>>(); | |
| 237 | + | |
| 238 | + if (configuration.GetValue<bool>("DataInitialization:DropDatabase")) | |
| 239 | + { | |
| 240 | + logger.LogWarning("DropDatabase"); | |
| 241 | + AppDataInit.DeleteDatabase(context); | |
| 242 | + } | |
| 243 | + | |
| 244 | + if (configuration.GetValue<bool>("DataInitialization:MigrateDatabase")) | |
| 245 | + { | |
| 246 | + logger.LogInformation("MigrateDatabase"); | |
| 247 | + AppDataInit.MigrateDatabase(context); | |
| 248 | + } | |
| 249 | + | |
| 250 | + if (configuration.GetValue<bool>("DataInitialization:SeedIdentity")) | |
| 251 | + { | |
| 252 | + logger.LogInformation("SeedIdentity"); | |
| 253 | + AppDataInit.SeedIdentity(userManager, roleManager); | |
| 254 | + } | |
| 255 | + | |
| 256 | + if (configuration.GetValue<bool>("DataInitialization:SeedData")) | |
| 257 | + { | |
| 258 | + logger.LogInformation("SeedData"); | |
| 259 | + AppDataInit.SeedAppData(context); | |
| 260 | + } | |
| 261 | +} | |
| 262 | + | |
| 263 | +static void WaitDbConnection(AppDbContext ctx, ILogger<IApplicationBuilder> logger) | |
| 264 | +{ | |
| 265 | + while (true) | |
| 266 | + { | |
| 267 | + try | |
| 268 | + { | |
| 269 | + ctx.Database.OpenConnection(); | |
| 270 | + ctx.Database.CloseConnection(); | |
| 271 | + return; | |
| 272 | + } | |
| 273 | + catch (Npgsql.PostgresException e) | |
| 274 | + { | |
| 275 | + logger.LogWarning("Checked postgres db connection. Got: {}", e.Message); | |
| 276 | + | |
| 277 | + if (e.Message.Contains("does not exist")) | |
| 278 | + { | |
| 279 | + logger.LogWarning("Applying migration, probably db is not there (but server is)"); | |
| 280 | + return; | |
| 281 | + } | |
| 282 | + | |
| 283 | + logger.LogWarning("Waiting for db connection. Sleep 1 sec"); | |
| 284 | + System.Threading.Thread.Sleep(1000); | |
| 285 | + } | |
| 286 | + catch (Exception e) | |
| 287 | + { | |
| 288 | + logger.LogWarning("DB not available yet: {}", e.Message); | |
| 289 | + System.Threading.Thread.Sleep(1000); | |
| 290 | + } | |
| 291 | + } | |
| 292 | +} | |
| 293 | + | |
| 294 | +// Exposed for WebApplicationFactory<Program> in App.Tests | |
| 295 | +public partial class Program { } |
added 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:5086", | |
| 9 | + "environmentVariables": { | |
| 10 | + "ASPNETCORE_ENVIRONMENT": "Development" | |
| 11 | + } | |
| 12 | + }, | |
| 13 | + "https": { | |
| 14 | + "commandName": "Project", | |
| 15 | + "dotnetRunMessages": true, | |
| 16 | + "launchBrowser": true, | |
| 17 | + "applicationUrl": "https://localhost:7040;http://localhost:5086", | |
| 18 | + "environmentVariables": { | |
| 19 | + "ASPNETCORE_ENVIRONMENT": "Development" | |
| 20 | + } | |
| 21 | + } | |
| 22 | + } | |
| 23 | +} |
added SplitApp/WebApp/Views/Budget/CreateCategory.cshtml +71 −0
| @@ -0,0 +1,71 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/Budget/DeleteCategory.cshtml +51 −0
| @@ -0,0 +1,51 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/Budget/EditCategory.cshtml +72 −0
| @@ -0,0 +1,72 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/Budget/Index.cshtml +155 −0
| @@ -0,0 +1,155 @@ | ||
| 1 | +@model List<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/WebApp/Views/Expenses/Create.cshtml +365 −0
| @@ -0,0 +1,365 @@ | ||
| 1 | +@model App.BLL.DTO.ExpenseBllDto | |
| 2 | +@using App.Domain | |
| 3 | + | |
| 4 | +@{ | |
| 5 | + ViewData["Title"] = Localizer["Add Expense"]; | |
| 6 | + var tripId = (Guid)ViewData["TripId"]!; | |
| 7 | + var participants = (List<App.BLL.DTO.TripParticipantBllDto>)ViewData["Participants"]!; | |
| 8 | + var presets = (List<App.BLL.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/WebApp/Views/Expenses/Delete.cshtml +51 −0
| @@ -0,0 +1,51 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/Expenses/Edit.cshtml +84 −0
| @@ -0,0 +1,84 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/Expenses/Index.cshtml +111 −0
| @@ -0,0 +1,111 @@ | ||
| 1 | +@model WebApp.Controllers.ExpensesIndexViewModel | |
| 2 | +@using WebApp.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/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/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/WebApp/Views/Members/AcceptInvitation.cshtml +60 −0
| @@ -0,0 +1,60 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/Members/Index.cshtml +125 −0
| @@ -0,0 +1,125 @@ | ||
| 1 | +@model List<App.BLL.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<App.BLL.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 == App.Domain.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 != App.Domain.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">@WebApp.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/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/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/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/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/WebApp/Views/PollsClient/Details.cshtml +126 −0
| @@ -0,0 +1,126 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/PollsClient/Index.cshtml +99 −0
| @@ -0,0 +1,99 @@ | ||
| 1 | +@model List<App.BLL.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/WebApp/Views/Settlement/Index.cshtml +232 −0
| @@ -0,0 +1,232 @@ | ||
| 1 | +@model 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 | + App.Domain.ESettlementStatus.Completed => "sa-badge-success", | |
| 20 | + App.Domain.ESettlementStatus.InProgress => "sa-badge-warning", | |
| 21 | + _ => "sa-badge-info" | |
| 22 | + }; | |
| 23 | + var statusIcon = Model.LatestPlan.Status switch | |
| 24 | + { | |
| 25 | + App.Domain.ESettlementStatus.Completed => "bi-check-circle-fill", | |
| 26 | + App.Domain.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>@WebApp.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 != App.Domain.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 | + App.Domain.ESettlementStatus.Completed => "sa-badge-success", | |
| 110 | + App.Domain.ESettlementStatus.InProgress => "sa-badge-warning", | |
| 111 | + _ => "sa-badge-neutral" | |
| 112 | + }; | |
| 113 | + } | |
| 114 | + <span class="sa-badge @planBadge">@WebApp.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 == App.Domain.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 == App.Domain.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/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/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/WebApp/Views/Shared/_Layout.cshtml +116 −0
| @@ -0,0 +1,116 @@ | ||
| 1 | +@using Microsoft.AspNetCore.Identity | |
| 2 | +@using App.Domain.Identity | |
| 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="~/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/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/WebApp/Views/Shared/_LoginPartial.cshtml +50 −0
| @@ -0,0 +1,50 @@ | ||
| 1 | +@using Microsoft.AspNetCore.Identity | |
| 2 | +@using App.Domain.Identity | |
| 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/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/WebApp/Views/Trips/Create.cshtml +82 −0
| @@ -0,0 +1,82 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/Trips/Delete.cshtml +56 −0
| @@ -0,0 +1,56 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/Trips/Details.cshtml +291 −0
| @@ -0,0 +1,291 @@ | ||
| 1 | +@model App.BLL.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<App.BLL.DTO.ExpenseBllDto>)ViewData["RecentExpenses"]!; | |
| 8 | + var totalExpenses = (decimal)ViewData["TotalExpenses"]!; | |
| 9 | + var userRole = (App.Domain.EParticipantRole)ViewData["UserRole"]!; | |
| 10 | + var balances = (List<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 | + App.Domain.ETripStatus.Active => "sa-badge-success", | |
| 27 | + App.Domain.ETripStatus.Finalizing => "sa-badge-warning", | |
| 28 | + App.Domain.ETripStatus.Settled => "sa-badge-info", | |
| 29 | + _ => "sa-badge-neutral" | |
| 30 | + }; | |
| 31 | + } | |
| 32 | + <span class="sa-badge @statusBadge">@WebApp.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 == App.Domain.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/WebApp/Views/Trips/Edit.cshtml +87 −0
| @@ -0,0 +1,87 @@ | ||
| 1 | +@model App.BLL.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<App.Domain.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/WebApp/Views/Trips/Index.cshtml +104 −0
| @@ -0,0 +1,104 @@ | ||
| 1 | +@model List<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 | + App.Domain.ETripStatus.Active => "sa-badge-success", | |
| 52 | + App.Domain.ETripStatus.Finalizing => "sa-badge-warning", | |
| 53 | + App.Domain.ETripStatus.Settled => "sa-badge-info", | |
| 54 | + _ => "sa-badge-neutral" | |
| 55 | + }; | |
| 56 | + } | |
| 57 | + <span class="sa-badge @statusClass">@WebApp.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 == App.Domain.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/WebApp/Views/WishlistClient/Create.cshtml +85 −0
| @@ -0,0 +1,85 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/WishlistClient/Delete.cshtml +45 −0
| @@ -0,0 +1,45 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/WishlistClient/Edit.cshtml +77 −0
| @@ -0,0 +1,77 @@ | ||
| 1 | +@model App.BLL.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/WebApp/Views/WishlistClient/Index.cshtml +147 −0
| @@ -0,0 +1,147 @@ | ||
| 1 | +@model List<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 | + App.Domain.EWishlistCategory.Place => "var(--sa-info)", | |
| 48 | + App.Domain.EWishlistCategory.Activity => "var(--sa-success)", | |
| 49 | + App.Domain.EWishlistCategory.Restaurant => "var(--sa-accent)", | |
| 50 | + _ => "var(--sa-gray-400)" | |
| 51 | + }; | |
| 52 | + var categoryBadge = item.Category switch | |
| 53 | + { | |
| 54 | + App.Domain.EWishlistCategory.Place => "sa-badge-info", | |
| 55 | + App.Domain.EWishlistCategory.Activity => "sa-badge-success", | |
| 56 | + App.Domain.EWishlistCategory.Restaurant => "sa-badge-accent", | |
| 57 | + _ => "sa-badge-neutral" | |
| 58 | + }; | |
| 59 | + var priorityBadge = item.Priority switch | |
| 60 | + { | |
| 61 | + App.Domain.EWishlistPriority.MustDo => "sa-badge-danger", | |
| 62 | + App.Domain.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;">@WebApp.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">@WebApp.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/WebApp/Views/_ViewImports.cshtml +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +@using WebApp | |
| 2 | +@using WebApp.Models | |
| 3 | +@using WebApp.Helpers | |
| 4 | +@using Microsoft.Extensions.Localization | |
| 5 | +@using Microsoft.AspNetCore.Mvc.Localization | |
| 6 | +@using Microsoft.AspNetCore.Mvc.Rendering | |
| 7 | +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers | |
| 8 | +@inject IStringLocalizer<App.Resources.Views.Shared> Localizer |
added SplitApp/WebApp/Views/_ViewStart.cshtml +3 −0
| @@ -0,0 +1,3 @@ | ||
| 1 | +@{ | |
| 2 | + Layout = "_Layout"; | |
| 3 | +} |
added SplitApp/WebApp/WebApp.csproj +32 −0
| @@ -0,0 +1,32 @@ | ||
| 1 | +<Project Sdk="Microsoft.NET.Sdk.Web"> | |
| 2 | + | |
| 3 | + <PropertyGroup> | |
| 4 | + <TargetFramework>net10.0</TargetFramework> | |
| 5 | + <Nullable>enable</Nullable> | |
| 6 | + <ImplicitUsings>enable</ImplicitUsings> | |
| 7 | + <UserSecretsId>aspnet-WebApp-21a2a4db-c8e5-47dc-842a-2a7ec387283e</UserSecretsId> | |
| 8 | + </PropertyGroup> | |
| 9 | + | |
| 10 | + <ItemGroup> | |
| 11 | + <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.1" /> | |
| 12 | + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" /> | |
| 13 | + <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.5" /> | |
| 14 | + <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" /> | |
| 15 | + <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.5" /> | |
| 16 | + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5"> | |
| 17 | + <PrivateAssets>all</PrivateAssets> | |
| 18 | + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | |
| 19 | + </PackageReference> | |
| 20 | + <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> | |
| 21 | + <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" /> | |
| 22 | + </ItemGroup> | |
| 23 | + | |
| 24 | + <ItemGroup> | |
| 25 | + <ProjectReference Include="..\App.DAL.EF\App.DAL.EF.csproj" /> | |
| 26 | + <ProjectReference Include="..\App.DTO\App.DTO.csproj" /> | |
| 27 | + <ProjectReference Include="..\App.Resources\App.Resources.csproj" /> | |
| 28 | + <ProjectReference Include="..\Base.Helpers\Base.Helpers.csproj" /> | |
| 29 | + <ProjectReference Include="..\App.BLL\App.BLL.csproj" /> | |
| 30 | + </ItemGroup> | |
| 31 | + | |
| 32 | +</Project> |
added 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": true, | |
| 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/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/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/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/WebApp/wwwroot/favicon.ico +0 −0
Line changes are not available for this file.
added 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/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/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/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/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/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 architecture.md +137 −0
| @@ -0,0 +1,137 @@ | ||
| 1 | +# Clean / Onion Architecture — Project Layer Mapping | |
| 2 | + | |
| 3 | +This document maps the 9 csproj projects in [SplitApp/](SplitApp/) to the Onion architecture rings as taught in the TalTech lecture (`courses.taltech.akaver.com/web-applications-with-csharp/lectures/architecture1`). | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## The 9 projects | |
| 8 | + | |
| 9 | +``` | |
| 10 | +SplitApp/ | |
| 11 | +├── Base.Contracts/ ← Domain Contracts (generic / cross-cutting, innermost) | |
| 12 | +├── Base.Domain/ ← Domain Primitives (cross-cutting) | |
| 13 | +├── Base.Helpers/ ← Infrastructure (cross-cutting helpers) | |
| 14 | +├── App.Domain/ ← Domain Layer (CORE) | |
| 15 | +├── App.DAL.EF/ ← Infrastructure (Data Access Layer) | |
| 16 | +├── App.BLL/ ← Application Services / Business Logic Layer | |
| 17 | +├── App.DTO/ ← Presentation Contracts (public API DTOs) | |
| 18 | +├── App.Resources/ ← Infrastructure (i18n / localization) | |
| 19 | +└── WebApp/ ← Presentation / UI (MVC + REST + Composition Root) | |
| 20 | +``` | |
| 21 | + | |
| 22 | +--- | |
| 23 | + | |
| 24 | +## Onion Rings (innermost → outermost) | |
| 25 | + | |
| 26 | +### Ring 1 — Domain Model (CORE) | |
| 27 | + | |
| 28 | +The innermost ring. Knows nothing about EF, ASP.NET, JWT, or any other technology. | |
| 29 | + | |
| 30 | +| Project | Lecture name | Contents | | |
| 31 | +|---|---|---| | |
| 32 | +| **`Base.Contracts`** | **Domain Contracts (generic / base)** | [IBaseEntity.cs](SplitApp/Base.Contracts/IBaseEntity.cs), [IBaseRepository.cs](SplitApp/Base.Contracts/IBaseRepository.cs), [IUnitOfWork.cs](SplitApp/Base.Contracts/IUnitOfWork.cs) | | |
| 33 | +| **`Base.Domain`** | **Domain Primitives / base entity classes** | [BaseEntity.cs](SplitApp/Base.Domain/BaseEntity.cs) (Id, CreatedAt, UpdatedAt), [LangStr.cs](SplitApp/Base.Domain/LangStr.cs) (i18n value object) | | |
| 34 | +| **`App.Domain`** | **Domain Entities + Domain Contracts (app-specific)** | All concrete entities ([Trip.cs](SplitApp/App.Domain/Trip.cs), Expense, BudgetCategory, Currency, TripPoll, ...), enums (`ETripStatus`, `EParticipantRole`, ...), `Identity/` (AppUser, AppRole, AppRefreshToken), and `App.Domain/Contracts/` with `IAppUnitOfWork`, `ITripRepository`, `IExpenseRepository`, etc. | | |
| 35 | + | |
| 36 | +**Key rule:** Repository interfaces (`ITripRepository`, `IAppUnitOfWork`) live in `App.Domain/Contracts/`, **not** in the DAL. That is the classical Onion rule — the inner ring defines the contract, the outer ring implements it, so dependencies always point inward. | |
| 37 | + | |
| 38 | +--- | |
| 39 | + | |
| 40 | +### Ring 2 — Application / BLL (Business Logic Layer) | |
| 41 | + | |
| 42 | +Orchestrates use cases. Talks to the Domain only through the repository contracts in `App.Domain/Contracts/`. | |
| 43 | + | |
| 44 | +| Project | Lecture name | Contents | | |
| 45 | +|---|---|---| | |
| 46 | +| **`App.BLL`** | **Application Services / Business Logic Layer (BLL)** | `Services/` (`ITripService` + `TripService`, `IExpenseService`, `IInvitationService`, ...), `Services/Admin/` (admin services), `Services/Identity/` (`IIdentityService` — JWT issuing, refresh tokens), `DTO/` (`TripBllDto`, `ExpenseBllDto`, `BalanceBllDto`, ...), `Mappers/` (`TripBllDtoFactory.Create(entity)` and `.ToEntity(dto)`) | | |
| 47 | + | |
| 48 | +--- | |
| 49 | + | |
| 50 | +### Ring 3 — Infrastructure | |
| 51 | + | |
| 52 | +Implements the contracts from the inner rings using concrete technologies (EF Core, JWT library, .NET resx). | |
| 53 | + | |
| 54 | +| Project | Lecture name | Contents | | |
| 55 | +|---|---|---| | |
| 56 | +| **`App.DAL.EF`** | **Infrastructure — Data Access Layer (EF Core implementation)** | [AppDbContext.cs](SplitApp/App.DAL.EF/AppDbContext.cs) (EF DbContext, IdentityDbContext), [AppUnitOfWork.cs](SplitApp/App.DAL.EF/AppUnitOfWork.cs) (implements `IAppUnitOfWork`), `Repositories/*` (concrete repos implementing the interfaces from `App.Domain/Contracts/`), `Migrations/`, `Seeding/`, [ServiceCollectionExtensions.cs](SplitApp/App.DAL.EF/ServiceCollectionExtensions.cs) (`AddDalServices`), `UtcDateTimeConverter.cs` | | |
| 57 | +| **`App.Resources`** | **Infrastructure — i18n / Localization resources** | `Domain/*.resx` + `*.et.resx`, `Common.resx`, `Views/Shared.resx`, `Domain/Enums.resx` | | |
| 58 | +| **`Base.Helpers`** | **Infrastructure — cross-cutting helpers** | [IdentityHelpers.cs](SplitApp/Base.Helpers/IdentityHelpers.cs) (JWT generate/validate using `System.IdentityModel.Tokens.Jwt`) | | |
| 59 | + | |
| 60 | +**Note on DAL contracts placement:** Akaver sometimes splits this into `App.DAL.Contracts` (interfaces) + `App.DAL.EF` (implementation). In this project the DAL contracts are inlined into `App.Domain/Contracts/`, which is also valid Onion — and arguably purer, because the contracts live in the Domain ring rather than in a separate "DAL contracts" project. | |
| 61 | + | |
| 62 | +--- | |
| 63 | + | |
| 64 | +### Ring 4 — Presentation / UI (outermost) | |
| 65 | + | |
| 66 | +The composition root. Wires everything together. | |
| 67 | + | |
| 68 | +| Project | Lecture name | Contents | | |
| 69 | +|---|---|---| | |
| 70 | +| **`App.DTO`** | **Presentation Contracts (public API DTOs)** | `v1/TripDto.cs`, `v1/TripCreateDto.cs`, `v1/TripUpdateDto.cs`, `v1/Identity/*` (LoginInfo, RegisterInfo, TokenRefreshInfo) | | |
| 71 | +| **`WebApp`** | **Presentation / UI** (MVC + REST API + Composition Root) | [Program.cs](SplitApp/WebApp/Program.cs) (composition root), `ApiControllers/` (versioned REST), `Controllers/` (client MVC), `Areas/Admin/Controllers/` + `Areas/Admin/Views/` (admin MVC area), `Areas/Identity/` (scaffolded Identity UI), `Views/`, [ConfigureSwaggerOptions.cs](SplitApp/WebApp/ConfigureSwaggerOptions.cs), `Helpers/` | | |
| 72 | + | |
| 73 | +--- | |
| 74 | + | |
| 75 | +## Dependency direction (must flow inward only) | |
| 76 | + | |
| 77 | +``` | |
| 78 | +WebApp ──────┐ | |
| 79 | + ├──► App.BLL ──► App.Domain ──► Base.Domain ──► Base.Contracts | |
| 80 | +App.DTO ─────┘ ▲ | |
| 81 | + │ (App.Domain/Contracts/ITripRepository etc.) | |
| 82 | +App.DAL.EF ─────────────────────┘ implements those contracts | |
| 83 | +App.Resources, Base.Helpers ─── leaf utilities, used by outer rings | |
| 84 | +``` | |
| 85 | + | |
| 86 | +**Verified in code:** `App.DAL.EF` references `App.Domain` (so it can implement `ITripRepository`), but `App.Domain` does **not** reference `App.DAL.EF`. That is correct. | |
| 87 | + | |
| 88 | +--- | |
| 89 | + | |
| 90 | +## What "Infrastructure" means in this project | |
| 91 | + | |
| 92 | +In the lecture's vocabulary, **Infrastructure = anything that talks to the outside world or to a specific technology**. In this project that is: | |
| 93 | + | |
| 94 | +1. **`App.DAL.EF`** — primary infrastructure (EF Core, Postgres, migrations). | |
| 95 | +2. **`App.Resources`** — secondary infrastructure (resx files for the .NET localization framework). | |
| 96 | +3. **`Base.Helpers`** — JWT token plumbing (depends on `System.IdentityModel.Tokens.Jwt`, which is a tech-specific library). | |
| 97 | + | |
| 98 | +Everything in those three projects can be swapped (e.g. switch from EF to Dapper, from resx to a translations DB, from JWT to OAuth) without touching `App.Domain` or `App.BLL`. That is the test of "is it infrastructure?". | |
| 99 | + | |
| 100 | +--- | |
| 101 | + | |
| 102 | +## Three-tier DTO flow | |
| 103 | + | |
| 104 | +``` | |
| 105 | +[ DB row ] | |
| 106 | + │ EF Core | |
| 107 | + ▼ | |
| 108 | +App.Domain.Trip ◄── domain entity (innermost) | |
| 109 | + │ TripBllDtoFactory.Create() in App.BLL/Mappers/ | |
| 110 | + ▼ | |
| 111 | +App.BLL.DTO.TripBllDto ◄── BLL-internal DTO | |
| 112 | + │ inline mapping in WebApp/ApiControllers/TripsController.cs | |
| 113 | + ▼ | |
| 114 | +App.DTO.v1.TripDto ◄── public REST DTO (the wire format) | |
| 115 | + │ JSON | |
| 116 | + ▼ | |
| 117 | +[ HTTP response ] | |
| 118 | +``` | |
| 119 | + | |
| 120 | +- **Entity ↔ BllDto** mapper: [App.BLL/Mappers/TripBllDtoFactory.cs](SplitApp/App.BLL/Mappers/TripBllDtoFactory.cs) | |
| 121 | +- **BllDto ↔ public ApiDto** mapper: currently inlined in the API controllers (no `WebApp/Mappers/` folder exists yet). | |
| 122 | + | |
| 123 | +--- | |
| 124 | + | |
| 125 | +## Summary table — at a glance | |
| 126 | + | |
| 127 | +| Onion ring | Lecture name | Project(s) | | |
| 128 | +|---|---|---| | |
| 129 | +| 1 (innermost) | **Domain Contracts (generic)** | `Base.Contracts` | | |
| 130 | +| 1 | **Domain Primitives** | `Base.Domain` | | |
| 131 | +| 1 | **Domain Entities + app-specific Domain Contracts** | `App.Domain` (incl. `App.Domain/Contracts/`) | | |
| 132 | +| 2 | **Application Services / BLL** | `App.BLL` | | |
| 133 | +| 3 | **Infrastructure — DAL** | `App.DAL.EF` | | |
| 134 | +| 3 | **Infrastructure — i18n** | `App.Resources` | | |
| 135 | +| 3 | **Infrastructure — cross-cutting helpers** | `Base.Helpers` | | |
| 136 | +| 4 | **Presentation Contracts (public DTOs)** | `App.DTO` | | |
| 137 | +| 4 (outermost) | **Presentation / UI + Composition Root** | `WebApp` | |
added arhitektuur.md +181 −0
| @@ -0,0 +1,181 @@ | ||
| 1 | +# SplitApp Onion Arhitektuur — joonis ja võrdlus TalTech-i loenguga | |
| 2 | + | |
| 3 | +## 1. Sibula-joonis (ringid seestpoolt väljapoole) | |
| 4 | + | |
| 5 | +``` | |
| 6 | + ┌─────────────────────────────────────────────────┐ | |
| 7 | + │ WebApp (Presentation) │ | |
| 8 | + │ MVC Controllers · API Controllers (v1) │ | |
| 9 | + │ Areas/Admin · Areas/Identity · Views │ | |
| 10 | + │ ViewModels · Program.cs (DI Composition Root) │ | |
| 11 | + │ ┌───────────────────────────────────────────┐ │ | |
| 12 | + │ │ App.BLL (Application Services) │ │ | |
| 13 | + │ │ TripService · ExpenseService · │ │ | |
| 14 | + │ │ SettlementService · AdminServices │ │ | |
| 15 | + │ │ (kasutab IAppUnitOfWork liidese kaudu) │ │ | |
| 16 | + │ │ ┌─────────────────────────────────────┐ │ │ | |
| 17 | + │ │ │ App.Domain (Core / Süda) │ │ │ | |
| 18 | + │ │ │ Entities: Trip, Expense, ... │ │ │ | |
| 19 | + │ │ │ Enums, AppUser, AppRole │ │ │ | |
| 20 | + │ │ │ Contracts: IAppUnitOfWork, │ │ │ | |
| 21 | + │ │ │ ITripRepository, ... │ │ │ | |
| 22 | + │ │ │ ┌───────────────────────────────┐ │ │ │ | |
| 23 | + │ │ │ │ Base.Domain · Base.Contracts │ │ │ │ | |
| 24 | + │ │ │ │ BaseEntity · IBaseRepository │ │ │ │ | |
| 25 | + │ │ │ │ IUnitOfWork · LangStr │ │ │ │ | |
| 26 | + │ │ │ └───────────────────────────────┘ │ │ │ | |
| 27 | + │ │ └─────────────────────────────────────┘ │ │ | |
| 28 | + │ └───────────────────────────────────────────┘ │ | |
| 29 | + └─────────────────────────────────────────────────┘ | |
| 30 | + ▲ ▲ | |
| 31 | + │ │ | |
| 32 | + │ ┌────────────────────────────────────┐ │ | |
| 33 | + │ │ App.DAL.EF (Infrastructure) │ │ | |
| 34 | + │ │ AppDbContext · AppUnitOfWork │─── implementeerib ───┘ | |
| 35 | + │ │ Repositories · Migrations │ IAppUnitOfWork, | |
| 36 | + │ │ Seeding · ServiceCollectionExt │ ITripRepository | |
| 37 | + │ └────────────────────────────────────┘ | |
| 38 | + │ ▲ | |
| 39 | + │ │ plugitav (saab vahetada nt Dapperi vastu) | |
| 40 | + │ | |
| 41 | + │ ┌────────────────────────────────────┐ | |
| 42 | + └────│ App.DTO · App.Resources │ | |
| 43 | + │ v1 DTOs · Mappers · .resx │ | |
| 44 | + └────────────────────────────────────┘ | |
| 45 | +``` | |
| 46 | + | |
| 47 | +**Tähtis**: Infrastructure (DAL.EF) on **väljaspool** sibulat ja **osutab sissepoole** — just nagu TalTech-i loengus: *"Infrastructure -> Domain <- Application <- Web"*. | |
| 48 | + | |
| 49 | +--- | |
| 50 | + | |
| 51 | +## 2. Sõltuvuste graaf (nooled = `ProjectReference`) | |
| 52 | + | |
| 53 | +``` | |
| 54 | + Base.Domain ◄──── Base.Contracts | |
| 55 | + ▲ ▲ | |
| 56 | + │ │ | |
| 57 | + └──── App.Domain ─┤ | |
| 58 | + ▲ │ | |
| 59 | + ┌────────┼───────┴─────────┐ | |
| 60 | + │ │ │ | |
| 61 | + App.DTO App.BLL App.DAL.EF | |
| 62 | + ▲ ▲ ▲ | |
| 63 | + │ │ │ | |
| 64 | + └────────┴──── WebApp ─────┘ | |
| 65 | + (Composition Root) | |
| 66 | +``` | |
| 67 | + | |
| 68 | +**Tähelepanu**: `App.BLL` **ei viita** `App.DAL.EF`-le. BLL näeb ainult liideseid (`IAppUnitOfWork`), mis elavad Domain-is. See ongi Onion-i **"Dependency Inversion"** — täpselt nagu loengus kirjeldatud. | |
| 69 | + | |
| 70 | +--- | |
| 71 | + | |
| 72 | +## 3. Võrdlus TalTech-i loengu nõuetega | |
| 73 | + | |
| 74 | +| TalTech-i Clean/Onion nõue | SplitApp teostus | Vastab? | | |
| 75 | +|---|---|---| | |
| 76 | +| **"Who owns the interfaces"** → liidesed elavad Domain-is | `IAppUnitOfWork`, `ITripRepository` jt asuvad [App.Domain/Contracts/](SplitApp/App.Domain/Contracts/) | Jah | | |
| 77 | +| **Entities in Domain** (mitte DAL-is) | Kõik entiteedid [App.Domain/](SplitApp/App.Domain/), POCO, ilma EF-atribuutideta | Jah | | |
| 78 | +| **Infrastructure → Domain** (pöördsuund) | `App.DAL.EF` viitab `App.Domain`-le, mitte vastupidi | Jah | | |
| 79 | +| **Application ← Web, Application → Domain** | `WebApp → App.BLL → App.Domain` | Jah | | |
| 80 | +| **Infrastructure pluginatav** | BLL kasutab ainult liideseid; EF-i saaks teoreetiliselt Dapperi vastu vahetada | Jah | | |
| 81 | +| **Repository pattern** | [BaseRepository.cs](SplitApp/App.DAL.EF/Repositories/BaseRepository.cs) + spetsiifilised repod | Jah | | |
| 82 | +| **Unit of Work** (`SaveChangesAsync()` atomic commit) | [AppUnitOfWork.cs](SplitApp/App.DAL.EF/AppUnitOfWork.cs), lazy-load repod | Jah | | |
| 83 | +| **DTOs** ("dumb objects, no logic") | [App.DTO/v1/](SplitApp/App.DTO/v1/) + Mappers | Jah | | |
| 84 | +| **Mappers** entiteet↔DTO | Staatilised mapper-klassid [App.DTO/Mappers/](SplitApp/App.DTO/Mappers/) | Jah | | |
| 85 | +| **Dependency Injection** (konstruktori kaudu) | Teenused `AddScoped`-ina Program.cs-is, konstruktori-injection | Jah | | |
| 86 | +| **DI Composition Root** välimises ringis | Ainult [Program.cs](SplitApp/WebApp/Program.cs) teab konkreetseid implementatsioone | Jah | | |
| 87 | +| **SOLID → DIP**: kõrgemad kihid sõltuvad abstraktsioonidest | BLL sõltub `IAppUnitOfWork`-ist, mitte `AppUnitOfWork`-ist | Jah | | |
| 88 | +| **ViewModelid** (mitte entiteedid vaadetes) | [WebApp/Models/](SplitApp/WebApp/Models/) sisaldab ViewModel-eid | Jah | | |
| 89 | + | |
| 90 | +--- | |
| 91 | + | |
| 92 | +## 4. Lisaväärtused üle loengu miinimumi | |
| 93 | + | |
| 94 | +- **Versioneeritud API** (`[ApiVersion("1.0")]`, `api/v{version}/...`) — Asp.Versioning | |
| 95 | +- **JWT + Cookie hübriid-autentimine** — MVC ja REST samaaegselt | |
| 96 | +- **Admin Area** eraldi teenustega (12 AdminService-t) | |
| 97 | +- **Lokaliseerimine** läbi `LangStr` (Domain-is) + .resx ([App.Resources/](SplitApp/App.Resources/)) | |
| 98 | +- **DataProtection** võtmed DB-s (`PersistKeysToDbContext`) | |
| 99 | +- **PostgreSQL + Docker** tootmiseks | |
| 100 | +- **Swagger** versioonidega ([ConfigureSwaggerOptions.cs](SplitApp/WebApp/ConfigureSwaggerOptions.cs)) | |
| 101 | + | |
| 102 | +--- | |
| 103 | + | |
| 104 | +## 5. Projektide detailne kirjeldus | |
| 105 | + | |
| 106 | +### Sisemine südamik | |
| 107 | + | |
| 108 | +**[Base.Domain](SplitApp/Base.Domain/)** — `BaseEntity` (Guid Id, CreatedAt, UpdatedAt), `LangStr` (mitmekeelne string, implitsiitne cast `string ↔ LangStr`). | |
| 109 | + | |
| 110 | +**[Base.Contracts](SplitApp/Base.Contracts/)** — generic abstraktsioonid: | |
| 111 | +- `IBaseRepository<T>` — CRUD (GetAllAsync, GetByIdAsync, Add, Update, RemoveAsync, ExistsAsync) | |
| 112 | +- `IUnitOfWork` — `SaveChangesAsync()` | |
| 113 | +- `IBaseEntity` | |
| 114 | + | |
| 115 | +**[App.Domain](SplitApp/App.Domain/)** — puhas äridomeen, POCO entiteedid: | |
| 116 | +- **Trip, Expense, ExpenseSplit** — reisid ja kulud | |
| 117 | +- **TripParticipant, TripInvitation** — osavõtjad ja kutsed | |
| 118 | +- **SettlementPlan, SettlementPayment** — arveldused | |
| 119 | +- **TripPoll, TripPollOption, TripPollVote** — küsitlused | |
| 120 | +- **TripWishlistItem, TripWishlistVote** — sooviste nimekiri | |
| 121 | +- **BudgetCategory, Currency, SplitPreset** — abi-entiteedid | |
| 122 | +- **Identity/** — `AppUser`, `AppRole` | |
| 123 | +- **Contracts/** — `IAppUnitOfWork` + repo-liidesed (`ITripRepository` jne) | |
| 124 | +- **Enumid**: `ETripStatus`, `EParticipantRole`, `ESplitMethod`, `ESettlementStatus`, `EPaymentStatus`, `EInvitationStatus`, `EWishlistPriority`, `EWishlistCategory` | |
| 125 | + | |
| 126 | +### Infrastructure — DAL | |
| 127 | + | |
| 128 | +**[App.DAL.EF](SplitApp/App.DAL.EF/)** — EF Core + PostgreSQL (Npgsql): | |
| 129 | +- **[AppDbContext.cs](SplitApp/App.DAL.EF/AppDbContext.cs)** — `IdentityDbContext<AppUser, AppRole, Guid>`, ~13 DbSet, UTC DateTime converter, unique indexid, `DeleteBehavior.Restrict`, `LangStr` JSON-seerimine. | |
| 130 | +- **[AppUnitOfWork.cs](SplitApp/App.DAL.EF/AppUnitOfWork.cs)** — lazy-load repod, generic `GetRepository<T>()`. | |
| 131 | +- **[Repositories/](SplitApp/App.DAL.EF/Repositories/)** — `BaseRepository<T>` + spetsiifilised (nt `TripRepository` `GetUserTripsAsync`, `GetByIdWithDetailsAsync`). | |
| 132 | +- **Migrations/** — EF Core migreeringud. | |
| 133 | +- **Seeding/** — `InitialData.cs`, `AppDataInit.cs`. | |
| 134 | +- **[ServiceCollectionExtensions.cs](SplitApp/App.DAL.EF/ServiceCollectionExtensions.cs)** — `AddDalServices()` DI registreerimine, `NoTrackingWithIdentityResolution`, SplitQuery käitumine. | |
| 135 | + | |
| 136 | +### Application Layer — BLL | |
| 137 | + | |
| 138 | +**[App.BLL](SplitApp/App.BLL/)** — ärieteenused, sõltub **ainult** Domain-ist (mitte DAL-ist otse): | |
| 139 | +- **Services**: `TripService`, `ExpenseService`, `SettlementService`, `InvitationService`, `PollService`, `WishlistService`, `BudgetCategoryService`, `SplitPresetService` | |
| 140 | +- **Admin services** — 12 eraldi admin teenust (`BudgetCategoryAdminService`, `CurrencyAdminService` jne) | |
| 141 | +- **Helpers/** — `CurrencyConverter` jt | |
| 142 | + | |
| 143 | +### DTO kiht | |
| 144 | + | |
| 145 | +**[App.DTO](SplitApp/App.DTO/)** — API- ja teenuste-tasemel andmeobjektid: | |
| 146 | +- **v1/** — versioneeritud API DTO-d (`TripDto`, `TripCreateDto`, `TripUpdateDto`, `ExpenseDto`, `SettlementPlanDto`, `BudgetCategoryDto`) | |
| 147 | +- **v1/Identity/** — `LoginInfo`, `RegisterInfo`, `JWTResponse`, `TokenRefreshInfo` | |
| 148 | +- **Mappers/** — staatilised mapper-klassid (`TripMapper` jt) | |
| 149 | + | |
| 150 | +### Presentation — WebApp | |
| 151 | + | |
| 152 | +**MVC Controllers** [Controllers/](SplitApp/WebApp/Controllers/) — Razor Views: | |
| 153 | +`TripsController`, `ExpensesController`, `BudgetController`, `MembersController`, `PollsClientController`, `WishlistClientController`, `SettlementController` | |
| 154 | + | |
| 155 | +**API Controllers** [ApiControllers/](SplitApp/WebApp/ApiControllers/) — REST API: | |
| 156 | +- `[ApiVersion("1.0")]` + `api/v{version:apiVersion}/[controller]` | |
| 157 | +- JWT Bearer autentimine | |
| 158 | + | |
| 159 | +**Areas**: | |
| 160 | +- **Admin** — 13 admin-controllerit (Users, Currencies, Trips, Expenses, Polls, Wishlist, SettlementPlans jne) | |
| 161 | +- **Identity** — Razor Pages (Register jne) | |
| 162 | + | |
| 163 | +**Models/** — **ViewModelid** (õppejõu nõue, mitte ViewBag/ViewData) | |
| 164 | + | |
| 165 | +**[Program.cs](SplitApp/WebApp/Program.cs)** — DI konfig: | |
| 166 | +- Identity (`AppUser`, `AppRole`) | |
| 167 | +- JWT Bearer + Cookie autentimine (SlidingExpiration) | |
| 168 | +- `AddDataProtection().PersistKeysToDbContext<AppDbContext>()` | |
| 169 | +- Request localization | |
| 170 | +- Swagger + API versioning | |
| 171 | +- `InvariantDecimalModelBinderProvider` — kümnendkoha parser | |
| 172 | + | |
| 173 | +--- | |
| 174 | + | |
| 175 | +## Kokkuvõte | |
| 176 | + | |
| 177 | +**SplitApp on korrektne Clean/Onion Architecture** täpselt nii, nagu TalTech-i loeng "architecture1" kirjeldab: | |
| 178 | + | |
| 179 | +> *"The entire difference between N-tier and Clean Architecture is who owns the interfaces."* | |
| 180 | + | |
| 181 | +Meie projektis **liidesed kuuluvad Domain-ile** (`App.Domain.Contracts`), Infrastructure (`App.DAL.EF`) asub välimises ringis ja **osutab sissepoole**, mistõttu BLL-i äriloogika ei tea midagi EF Core-ist ega PostgreSQL-ist. See on just see "pööratud sõltuvus", mida Onion nõuab. |
added docker-compose.prod.yml +57 −0
| @@ -0,0 +1,57 @@ | ||
| 1 | +# Production compose — for the VPS (travel.rasmusj.com). | |
| 2 | +# Differences from docker-compose.yml (local): | |
| 3 | +# - No public ports anywhere. Caddy (on the external `web` network) is the only entrypoint. | |
| 4 | +# - Postgres lives only on the internal network — unreachable from the internet. | |
| 5 | +# - All secrets come from .env (never committed). See .env.example. | |
| 6 | +# | |
| 7 | +# Caddy reaches the app by container name: reverse_proxy csweb-travel:8080 | |
| 8 | +# Requires the shared external network to exist on the server: docker network create web | |
| 9 | +services: | |
| 10 | + db: | |
| 11 | + image: postgres:16 | |
| 12 | + restart: unless-stopped | |
| 13 | + environment: | |
| 14 | + POSTGRES_DB: splitapp | |
| 15 | + POSTGRES_USER: postgres | |
| 16 | + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} | |
| 17 | + volumes: | |
| 18 | + - pgdata:/var/lib/postgresql/data | |
| 19 | + networks: | |
| 20 | + - internal | |
| 21 | + healthcheck: | |
| 22 | + test: ["CMD-SHELL", "pg_isready -U postgres -d splitapp"] | |
| 23 | + interval: 10s | |
| 24 | + timeout: 5s | |
| 25 | + retries: 5 | |
| 26 | + | |
| 27 | + app: | |
| 28 | + # CI builds & pushes this image (GitHub Actions → GHCR); the server only pulls it. | |
| 29 | + # `build` is kept as a fallback for manual `docker compose ... build` on the server. | |
| 30 | + image: ghcr.io/rasmusjy/cswebtravel:latest | |
| 31 | + build: . | |
| 32 | + container_name: csweb-travel | |
| 33 | + restart: unless-stopped | |
| 34 | + environment: | |
| 35 | + - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=splitapp;Username=postgres;Password=${POSTGRES_PASSWORD} | |
| 36 | + - JWT__Key=${JWT_KEY} | |
| 37 | + - SEED_ADMIN_PASSWORD=${SEED_ADMIN_PASSWORD} | |
| 38 | + - ASPNETCORE_URLS=http://+:8080 | |
| 39 | + # Seeding is idempotent (guards on existing rows) — safe to leave on across restarts. | |
| 40 | + - DataInitialization__DropDatabase=false | |
| 41 | + - DataInitialization__MigrateDatabase=true | |
| 42 | + - DataInitialization__SeedIdentity=true | |
| 43 | + - DataInitialization__SeedData=true | |
| 44 | + depends_on: | |
| 45 | + db: | |
| 46 | + condition: service_healthy | |
| 47 | + networks: | |
| 48 | + - web # shared with Caddy — public entrypoint | |
| 49 | + - internal # private link to db | |
| 50 | + | |
| 51 | +networks: | |
| 52 | + web: | |
| 53 | + external: true | |
| 54 | + internal: | |
| 55 | + | |
| 56 | +volumes: | |
| 57 | + pgdata: |
added docker-compose.yml +27 −0
| @@ -0,0 +1,27 @@ | ||
| 1 | +services: | |
| 2 | + db: | |
| 3 | + image: postgres:16 | |
| 4 | + environment: | |
| 5 | + POSTGRES_DB: splitapp | |
| 6 | + POSTGRES_USER: postgres | |
| 7 | + POSTGRES_PASSWORD: postgres | |
| 8 | + ports: | |
| 9 | + - "5432:5432" | |
| 10 | + volumes: | |
| 11 | + - pgdata:/var/lib/postgresql/data | |
| 12 | + | |
| 13 | + web: | |
| 14 | + build: . | |
| 15 | + ports: | |
| 16 | + - "84:8080" | |
| 17 | + environment: | |
| 18 | + - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=splitapp;Username=postgres;Password=postgres | |
| 19 | + - DataInitialization__DropDatabase=false | |
| 20 | + - DataInitialization__MigrateDatabase=true | |
| 21 | + - DataInitialization__SeedIdentity=true | |
| 22 | + - DataInitialization__SeedData=true | |
| 23 | + depends_on: | |
| 24 | + - db | |
| 25 | + | |
| 26 | +volumes: | |
| 27 | + 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 +729 −0
| @@ -0,0 +1,729 @@ | ||
| 1 | +# SplitApp — Reisikulude haldamise rakendus | |
| 2 | + | |
| 3 | +## Ülevaade | |
| 4 | + | |
| 5 | +SplitApp on ASP.NET Core 10.0 veebirakendus grupireisi kulude jagamiseks ja haldamiseks. Rakendus võimaldab kasutajatel luua reise, kutsuda sõpru, lisada kulusid paindliku jagamisega, hallata eelarvet, teha küsitlusi, pidada soovinimekirja ja arveldada võlgu optimeeritud algoritmiga. Rakendus kasutab **Clean Architecture**'t: sõltuvused liiguvad sissepoole Domain-i, interfejsid elavad Domain-kihis (`App.Domain/Contracts/`) ja `App.DAL.EF` on "plugin", mis neid implementeerib. `App.BLL` sõltub ainult Domain-abstraktsioonidest ja WebApp kontrollerid kasutavad ainult BLL teenuseid — mitte kunagi `IAppUnitOfWork`-i ega repositore otse. | |
| 6 | + | |
| 7 | +Projekt on tehtud TalTech kursuse "Web Applications with C#" **Personal Project — Phase 1** raames. | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 0. Nõuete täitmine (Assignment requirements) | |
| 12 | + | |
| 13 | +Phase 1 ülesande järgi peavad olemas olema järgmised asjad. Alljärgnevas tabelis on iga nõue, selle täitmise staatus ja konkreetne asukoht koodis. | |
| 14 | + | |
| 15 | +| # | Nõue | Staatus | Kus näha | | |
| 16 | +|---|------|---------|----------| | |
| 17 | +| 1 | Domeenikujundus: min 10 mõtestatud entiteeti | ✅ **16 entiteeti** | [App.Domain/](SplitApp/App.Domain/) — Trip, Expense, ExpenseSplit, BudgetCategory, Currency, TripParticipant, TripPoll, TripPollOption, TripPollVote, TripWishlistItem, TripWishlistVote, SplitPreset, SplitPresetMember, TripInvitation, SettlementPlan, SettlementPayment + 3 Identity entiteeti | | |
| 18 | +| 2 | REST API + versioneerimine + avalikud DTO-d | ✅ | [WebApp/ApiControllers/](SplitApp/WebApp/ApiControllers/), `[ApiVersion("1.0")]`, marsruut `/api/v{version:apiVersion}/[controller]`, DTO-d [App.DTO/v1/](SplitApp/App.DTO/v1/) | | |
| 19 | +| 3 | Swagger | ✅ | `/swagger` endpoint, [ConfigureSwaggerOptions.cs](SplitApp/WebApp/ConfigureSwaggerOptions.cs) — Bearer auth + versioneerimine integreeritud | | |
| 20 | +| 4 | Autentimine (JWT) | ✅ | [Program.cs](SplitApp/WebApp/Program.cs) JWT Bearer konfiguratsioon; [AccountController.cs](SplitApp/WebApp/ApiControllers/AccountController.cs) — register, login, refreshtoken, logout | | |
| 21 | +| 5 | Kliendi UX (MVC, scaffolded) | ✅ | [WebApp/Controllers/](SplitApp/WebApp/Controllers/) — 8 MVC kontrollerit; standardsed CRUD-vaated, tõestavad domeeni toimimise | | |
| 22 | +| 6 | Admin UX (MVC, Area, kaitstud, kujundatud, ViewModelid, **no ViewBag/ViewData**) | ✅ | [WebApp/Areas/Admin/](SplitApp/WebApp/Areas/Admin/) — 13 kontrollerit, `[Authorize(Roles = "admin")]`, oma sidebar-layout, admin.css, custom Dashboard. **0 ViewData/ViewBag kasutust** — grep kontrollitud | | |
| 23 | +| 7 | UI tõlked (i18n, .resx) | ✅ EN + ET | [App.Resources/](SplitApp/App.Resources/) — Shared.resx, Common.resx, Domain/* (Trip, Expense, Currency, BudgetCategory jne) | | |
| 24 | +| 8 | Andmebaasi tõlked (LangStr) | ✅ | [Base.Domain/LangStr.cs](SplitApp/Base.Domain/LangStr.cs); kasutatud `Currency.Name` ja `BudgetCategory.Name` väljadel — JSON-ina PostgreSQL-is | | |
| 25 | +| 9 | IDOR kaitse (kasutaja näeb ainult oma andmeid) | ✅ | Kaitse elab BLL teenustes: iga meetod, mis puudutab reisi-andmeid, võtab `Guid userId` ja kontrollib osaleja/organiseerija staatust sees. WebApp kontrollerid ei pääse UoW-le üldse — kontrolli vahele jätta on võimatu | | |
| 26 | +| 10 | CI/CD deploy (äpp + DB) | ✅ | `.gitlab-ci.yml` — `docker compose up --build` `main` harul; `Dockerfile` multi-stage; `docker-compose.yml` — app + PostgreSQL 16 + persistent volume + automaatne migreerimine ja seeding | | |
| 27 | +| 11 | Admin pole lihtsalt scaffold — "designed, nice, good to use" | ✅ | Eraldi admin layout (sidebar + topbar), [admin.css](SplitApp/WebApp/wwwroot/css/admin.css) — metric cards, status badges, timeline feed, empty states; Dashboard custom statistikaga (Top Active Trips, Biggest Expenses, User Activity 7d/30d, Top Active Users, Activity Feed) | | |
| 28 | + | |
| 29 | +**Lisaks** (pole nõutud, aga olemas): | |
| 30 | +- Repository pattern ([Base.Contracts/IBaseRepository.cs](SplitApp/Base.Contracts/IBaseRepository.cs) + entiteedispetsiifilised repositoryd) | |
| 31 | +- Unit of Work pattern (`IAppUnitOfWork`) | |
| 32 | +- Service layer äriloogika jaoks (`SettlementService` greedy algoritm, `ExpenseService` 4 split-meetodit, `InvitationService` token-põhised kutsed, `PollService` hääletamise toggle) | |
| 33 | +- Manuaalsed DTO mapperid (ei kasuta AutoMapper-it) | |
| 34 | + | |
| 35 | +--- | |
| 36 | + | |
| 37 | +## 1. Arhitektuur — Clean Architecture | |
| 38 | + | |
| 39 | +Projekt kasutab **Clean Architecture**'t. Sõltuvused liiguvad **sissepoole** (Dependency Inversion Principle): kõik kihid sõltuvad Domain-ist (või millestki sisemisest), mitte väliskihtidest. | |
| 40 | + | |
| 41 | +### Kursuse loengu (architecture1) võtmelause | |
| 42 | + | |
| 43 | +*"The entire difference between N-tier and Clean Architecture is who owns the interfaces."* — *"Move `IPersonRepository` from DAL into Domain, and your dependency arrow flips."* | |
| 44 | + | |
| 45 | +Meie projekt järgib seda põhimõtet: | |
| 46 | +- **`IAppUnitOfWork` ja 10 repository-interfejsi elavad [App.Domain/Contracts/](SplitApp/App.Domain/Contracts/)-is**, mitte DAL-is. | |
| 47 | +- **`App.DAL.EF`** (infrastructure) implementeerib neid interfejse — on "plugin" Domain-kihi peal. | |
| 48 | +- **`App.BLL.csproj`** ei viita enam `App.DAL.EF`-ile — ainult `App.Domain`-ile ja `App.DTO`-le. | |
| 49 | +- **WebApp kontrollerid** (kõik 23: client MVC + API + Admin) **ei kasuta** `IAppUnitOfWork`-i ega repositore otse — ainult BLL teenuseid. | |
| 50 | + | |
| 51 | +### Sõltuvuse graaf | |
| 52 | + | |
| 53 | +``` | |
| 54 | + Base.Contracts (IBaseEntity, IBaseRepository, IUnitOfWork) | |
| 55 | + ▲ | |
| 56 | + │ | |
| 57 | + Base.Domain (BaseEntity, LangStr) | |
| 58 | + ▲ | |
| 59 | + │ | |
| 60 | + App.Domain ← SEES | |
| 61 | + + Contracts/ (IAppUnitOfWork, 10 × I*Repository) | |
| 62 | + ▲ | |
| 63 | + ┌──────┴──────┐ | |
| 64 | + │ │ | |
| 65 | + App.DAL.EF App.DTO | |
| 66 | + (implem.) │ | |
| 67 | + │ | |
| 68 | + App.BLL (Services — sõltub AINULT Domain+DTO) | |
| 69 | + ▲ | |
| 70 | + │ | |
| 71 | + WebApp (Controllers — kasutavad BLL teenuseid) | |
| 72 | + (DAL viide ainult Program.cs DI jaoks | |
| 73 | + → AddDalServices() extension method) | |
| 74 | +``` | |
| 75 | + | |
| 76 | +**Clean-i võtmeomadused** (verifitseeritavad): | |
| 77 | +- `grep "using App.DAL.EF" App.BLL/` → **0 tulemust** | |
| 78 | +- `grep "IAppUnitOfWork\|_uow\." WebApp/Controllers/ WebApp/ApiControllers/ WebApp/Areas/` → **0 tulemust** | |
| 79 | +- `App.BLL.csproj` refs: ainult `App.Domain`, `App.DTO` | |
| 80 | +- `App.DAL.EF` viitab Domain-i interfejsidele ja implementeerib neid (`AppUnitOfWork : IAppUnitOfWork` Domain-ist) | |
| 81 | + | |
| 82 | +### Projekti kihid | |
| 83 | + | |
| 84 | +``` | |
| 85 | +Base.Contracts ← Geneerilised liidesed (IBaseEntity, IBaseRepository, IUnitOfWork) | |
| 86 | +Base.Domain ← Base-entiteedid (BaseEntity, LangStr) | |
| 87 | +Base.Helpers ← JWT genereerimine/valideerimine | |
| 88 | +App.Domain ← 16 domeeni entiteeti + 8 enum-i + Contracts/ (IAppUnitOfWork + I*Repository) | |
| 89 | +App.DAL.EF ← AppDbContext, AppUnitOfWork, repository-implementatsioonid, migratsioonid, | |
| 90 | + ServiceCollectionExtensions.AddDalServices() | |
| 91 | +App.DTO ← DTO-d (v1/) + manuaalsed Mapper klassid (ei kasuta AutoMapper-it) | |
| 92 | +App.BLL ← Application-kiht, teenused koos äriloogikaga | |
| 93 | + Services/ — Trip, Expense, Settlement, Invitation, Poll, | |
| 94 | + BudgetCategory, Wishlist, SplitPreset | |
| 95 | + Services/Admin/ — 12 admin-teenust (üks iga admin-sektsiooni jaoks) | |
| 96 | +App.Resources ← .resx tõlkefailid (EN + ET) | |
| 97 | +WebApp ← MVC + API + Admin kontrollerid, vaated, ViewModelid, Program.cs | |
| 98 | +``` | |
| 99 | + | |
| 100 | +### Miks Clean Architecture? | |
| 101 | + | |
| 102 | +- **Dependency Inversion** — WebApp kontrollerid sõltuvad BLL liidestest, BLL sõltub Domain liidestest. Kui tahame DAL-i vahetada (nt MongoDB), asendame ainult `App.DAL.EF` — ülejäänud projekt ei muutu. | |
| 103 | +- **Testitavus** — iga teenust ja repositoryd saab mockida, sest kõik sõltuvused on interface'id ja elavad sees (Domain-is). BLL-i teste saab kirjutada ilma päris andmebaasita. | |
| 104 | +- **Separation of Concerns** — äriloogika (settlement algoritm, expense splitting, token-kutsed) elab ainult BLL-is; andmeligipääs ainult DAL-is; HTTP-mure ainult WebApp-is. | |
| 105 | +- **IDOR kaitse tsentraliseeritud** — iga BLL teenuse meetod, mis puudutab reisi-andmeid, võtab vastu `Guid userId` parameetri ja kontrollib osaleja/organiseerija staatust teenuse sees. Kontroller ei saa kogemata kontrolli vahele jätta. | |
| 106 | + | |
| 107 | +### `App.DAL.EF` kui plugin | |
| 108 | + | |
| 109 | +Kuigi DAL sõltub Domain-ist (järgides Clean reeglit), jääb DAL "väliseks" Domain-i suhtes. Program.cs kutsub `builder.Services.AddDalServices(connectionString)` — üks composition-root rida — mis registreerib `AppDbContext` ja `IAppUnitOfWork → AppUnitOfWork`. WebApp kontrollerid pole teadlikud DAL-i implementatsioonist. See on Clean-i "plugin architecture" omadus. | |
| 110 | + | |
| 111 | +--- | |
| 112 | + | |
| 113 | +## 2. Repository ja Unit of Work muster | |
| 114 | + | |
| 115 | +### Repository muster | |
| 116 | + | |
| 117 | +Iga entiteet on kättesaadav läbi repository liidese. Geneerilised operatsioonid on defineeritud `IBaseRepository<TEntity>` liideses (`Base.Contracts`): | |
| 118 | + | |
| 119 | +- `GetAllAsync()` — kõik kirjed (`Task<IEnumerable<TEntity>>`) | |
| 120 | +- `GetByIdAsync(Guid id)` — üks kirje ID järgi (`Task<TEntity?>`) | |
| 121 | +- `Add(entity)` — lisa uus (sünkroonne — tegelik salvestus toimub `SaveChangesAsync()` kaudu) | |
| 122 | +- `Update(entity)` — uuenda olemasolevat (sünkroonne) | |
| 123 | +- `RemoveAsync(Guid id)` — kustuta (`Task<TEntity?>`) | |
| 124 | +- `ExistsAsync(Guid id)` — kontrolli olemasolu (`Task<bool>`) | |
| 125 | + | |
| 126 | +Geneerilise baasrepository (`BaseRepository<TEntity>`) peal on ehitatud **entiteedispetsiifilised repositoryd** oma päringumeetoditega. Näiteks `TripRepository`: | |
| 127 | + | |
| 128 | +- `GetUserTripsAsync(Guid userId)` — kasutaja reisid koos valuuta ja osalejatega (Include) | |
| 129 | +- `GetByIdWithDetailsAsync(Guid id)` — reis kõigi seostega | |
| 130 | +- `RemoveAsync(Guid id)` — **override**, mis teostab kaskaadse kustutamise õiges järjekorras (lapselapsed → lapsed → reis), kuna kõik võõrvõtmed on `DeleteBehavior.Restrict` | |
| 131 | + | |
| 132 | +`TripParticipantRepository` on arhitektuuri selgroog — sisaldab `IsParticipantAsync()` ja `IsOrganizerAsync()` meetodeid, mida kasutavad KÕIK kontrollerid autoriseerimiseks. Enne refaktoreerimist oli see loogika kopeeritud igasse kontrollerisse eraldi. | |
| 133 | + | |
| 134 | +### Unit of Work muster | |
| 135 | + | |
| 136 | +`IAppUnitOfWork` koondab kõik repositoryd üheks liideseks ja haldab `SaveChangesAsync()` kutsumist: | |
| 137 | + | |
| 138 | +``` | |
| 139 | +IAppUnitOfWork | |
| 140 | +├── Trips (ITripRepository) | |
| 141 | +├── Expenses (IExpenseRepository) | |
| 142 | +├── TripParticipants (ITripParticipantRepository) | |
| 143 | +├── TripInvitations (ITripInvitationRepository) | |
| 144 | +├── SettlementPlans (ISettlementPlanRepository) | |
| 145 | +├── TripPolls (ITripPollRepository) | |
| 146 | +├── TripWishlistItems (ITripWishlistItemRepository) | |
| 147 | +├── SplitPresets (ISplitPresetRepository) | |
| 148 | +├── BudgetCategories (IBudgetCategoryRepository) | |
| 149 | +├── GetRepository<T>() — geneeriliste entiteetide jaoks | |
| 150 | +└── SaveChangesAsync() — salvestab KÕIK muudatused atomaarselt | |
| 151 | +``` | |
| 152 | + | |
| 153 | +Kursuse loeng ütleb: *"DbContext already is a Unit of Work."* Meie `AppUnitOfWork` on selle peale ehitatud kiht, mis annab puhta liidese ja peidab EF Core detailid. | |
| 154 | + | |
| 155 | +Repositoryd on lazy-initsialiseeritud — luuakse ainult siis, kui neid esimest korda kasutatakse. | |
| 156 | + | |
| 157 | +--- | |
| 158 | + | |
| 159 | +## 3. Teenuste kiht (App.BLL) | |
| 160 | + | |
| 161 | +Teenused sisaldavad **äriloogikat**, mida kontrollerid ei peaks ise teadma. Teenused sõltuvad `IAppUnitOfWork` liidesest (mitte DbContext-ist otse). | |
| 162 | + | |
| 163 | +### SettlementService — arvelduse äriloogika | |
| 164 | + | |
| 165 | +Kõige keerulisem teenus. Põhimeetodid + guarded wrapper'id IDOR kaitseks: | |
| 166 | + | |
| 167 | +- **`CalculateBalancesAsync(Guid tripId)`** — arvutab iga osaleja kohta: kui palju on maksnud vs kui palju peab maksma. Kasutab `CurrencyConverter`-it valuuta normaliseerimiseks vaikevaluutasse. Tagastab saldod sorteerituna kahanevas järjekorras. | |
| 168 | + | |
| 169 | +- **`CalculateSettlementAsync(Guid tripId, Guid userId)`** — greedy algoritm, mis paardab suurima võlausaldaja suurima võlgnikuga, minimeerides maksete arvu. Kasutab kahte sorteeritud nimekirja (võlausaldajad ja võlgnikud) ning two-pointer lähenemist. Lävi: 0.01m (väldib ümardamisartefakte). Loob `SettlementPayment` kirjed staatusega Pending. | |
| 170 | + | |
| 171 | +- **`MarkPaidAsync(Guid paymentId, Guid userId)`** — võlgnik märgib makse tehtuks. Muudab staatust Pending → MarkedPaid, salvestab kuupäeva. Ainult `FromUserId` saab seda teha. Guarded variant `MarkPaidGuardedAsync` kontrollib osalust ja tagastab `(ok, errorCode)` tupli — kontroller tõlgib `"forbidden"` → `Forbid()`. | |
| 172 | + | |
| 173 | +- **`ConfirmPaymentAsync(Guid paymentId, Guid userId)`** — võlausaldaja kinnitab makse laekumist. Muudab staatust MarkedPaid → Confirmed. Ainult `ToUserId` saab seda teha (guarded variant `ConfirmPaymentGuardedAsync` jõustab selle). Pärast mutatsiooni laaditakse plaan `SettlementPlanRepository.GetByIdAsync`-iga (koos Payments-iga), et kontrollida kas kõik maksed on kinnitatud. **NB!** DAL on konfigureeritud `QueryTrackingBehavior.NoTrackingWithIdentityResolution`-iga — iga laetud entiteet on detached, mistõttu mutatsioonide salvestamiseks on vajalik selgesõnaline `paymentRepo.Update(payment)` kutse (vastasel juhul `SaveChangesAsync` ei näe muudatust ja andmebaasi ei kirjutata midagi). Plaani ja reisi uuendamine käib **shallow** base-repo kaudu (`_uow.GetRepository<SettlementPlan>()`, `_uow.GetRepository<Trip>()`), mis ei lae AppUser navigatsioonivarasid — muidu `DbSet.Update(plan)` ketaks läbi Payments→FromUser/ToUser graafi ja rikuks Identity ridu (`ConcurrencyStamp`, `SecurityStamp`). Kui kõik maksed on Confirmed → plan.Status = Completed + plan.CompletedAt; kui reis on `Finalizing`, läheb see nüüd `Settled`-iks. Vastasel juhul plan.Status = InProgress. | |
| 174 | + | |
| 175 | +### Reisi elutsükkel (`ETripStatus`) | |
| 176 | + | |
| 177 | +`Active → Finalizing → Settled` (+ `Archived`). Organisaator klõpsab **Finalize Trip** — `TripService.FinalizeTripAsync` lukustab kulude muutmise (iga kulu CRUD kontrollib `trip.Status != Active`-it) ja kutsub `CalculateSettlementAsync`-i, mis loob plaani. Reis läheb staatusesse **Finalizing** (mitte enam otse `Settled`, nagu varasem versioon tegi). Kui kedagi pole midagi võlgu ja plaani ei looda, läheb reis kohe `Settled` peale. **Settled** staatus saavutatakse automaatselt alles siis, kui viimane makse on saaja poolt kinnitatud — see toimub `ConfirmPaymentAsync`-is. Reopen on lubatud ainult `Finalizing` seisundis (või tagasiühilduvuse pärast `Settled` seisundis, kui plaan pole veel `Completed`); niipea kui mõni makse on juba kinnitatud, `ReopenTripAsync` tagastab `"payments-confirmed"` veakoodi. | |
| 178 | + | |
| 179 | +### ExpenseService — kulu loomine koos jaotusega | |
| 180 | + | |
| 181 | +2 meetodit: | |
| 182 | + | |
| 183 | +- **`CreateExpenseWithSplitsAsync(...)`** — loob kulu ja jaotuse (`ExpenseSplit` kirjed) atomaarselt ühes transaktsioonis. Toetab nelja jagamismeetodit: | |
| 184 | + - **EqualAll** — võrdselt kõigi aktiivse osaleja vahel. `baseAmount = Math.Floor(total / count * 100) / 100`, ülejääk jaotatakse 0.01 kaupa esimestele. | |
| 185 | + - **EqualSubset** — sama loogika, aga ainult valitud osalejatele. | |
| 186 | + - **ExactAmounts** — täpsed summad iga osaleja kohta, otse 1:1 vastendus. | |
| 187 | + - **Percentages** — `amount = Math.Round(expense.Amount * percentage / 100, 2)`, salvestab nii protsendi kui arvutatud summa. | |
| 188 | + | |
| 189 | +- **`DeleteExpenseWithSplitsAsync(Guid expenseId)`** — kaskaadne kustutamine: kõigepealt split-id, siis kulu ise. | |
| 190 | + | |
| 191 | +### InvitationService — kutsete haldus | |
| 192 | + | |
| 193 | +2 meetodit: | |
| 194 | + | |
| 195 | +- **`CreateInvitationAsync(Guid tripId, Guid userId)`** — genereerib **krüptograafilise tokeni** (`RandomNumberGenerator.GetBytes(32)` = 256 bitti), teisendab URL-ohutuks Base64-ks (asendab `+`→`-`, `/`→`_`, eemaldab `=`). Kutse aegub 7 päeva pärast. | |
| 196 | + | |
| 197 | +- **`AcceptInvitationAsync(string token, Guid userId)`** — valideerib tokeni olemasolu, staatuse (Pending) ja aegumise. Haldab kolme stsenaariumit: | |
| 198 | + 1. Kasutaja on juba aktiivne osaleja → lihtsalt aktsepteerib kutse | |
| 199 | + 2. Kasutaja on mitteaktiivne osaleja → taasaktiveerib (IsActive=true, LeftAt=null) | |
| 200 | + 3. Kasutaja pole osaleja → loob uue `TripParticipant` kirje Participant rolliga | |
| 201 | + | |
| 202 | +### TripService — reisi loomine | |
| 203 | + | |
| 204 | +- **`CreateTripAsync(Trip trip, Guid userId)`** — loob reisi ja esimese osaleja (Organizer rolli) atomaarselt. | |
| 205 | + | |
| 206 | +### PollService — küsitluste haldus | |
| 207 | + | |
| 208 | +3 meetodit: | |
| 209 | + | |
| 210 | +- **`CreatePollWithOptionsAsync(TripPoll poll, List<string> optionTexts)`** — loob küsitluse koos valikuvariantidega (`TripPollOption` kirjed koos `DisplayOrder`-iga). Filtreerib tühjad variandid välja. | |
| 211 | + | |
| 212 | +- **`ToggleVoteAsync(Guid pollId, Guid optionId, Guid userId)`** — hääletamise toggle-loogika. Kui `AllowMultipleVotes = false`, eemaldab kõigepealt kasutaja kõik varasemad hääled selles küsitluses. Ei tee midagi, kui küsitlus on suletud (`ClosedAt != null`). | |
| 213 | + | |
| 214 | +- **`DeletePollCascadeAsync(Guid pollId)`** — kaskaadne kustutamine: hääled → variandid → küsitlus. | |
| 215 | + | |
| 216 | +--- | |
| 217 | + | |
| 218 | +## 4. DTO-d ja mapperid | |
| 219 | + | |
| 220 | +### DTO-d (Data Transfer Objects) | |
| 221 | + | |
| 222 | +DTO-d asuvad `App.DTO/v1/` kaustas. Need on andmekandjad ilma äriloogikita — neid kasutatakse API sisendiks/väljundiks: | |
| 223 | + | |
| 224 | +- **Response DTO-d**: `TripDto`, `ExpenseDto`, `BudgetCategoryDto`, `SettlementPlanDto`, `SettlementPaymentDto`, `BalanceDto`, `SettlementSummaryDto`, `CurrencyDto`, `PollDto`, `PollOptionDto`, `WishlistItemDto`, `SplitPresetDto`, `SplitPresetMemberDto`, `InvitationDto`, `TripParticipantDto`, `ExpenseSplitDto` — API tagastab neid, mitte kunagi domeeni entiteete otse | |
| 225 | +- **Request DTO-d**: `TripCreateDto`, `TripUpdateDto`, `ExpenseCreateDto`, `ExpenseSplitCreateDto`, `BudgetCategoryCreateDto`, `PollCreateDto`, `WishlistItemCreateDto`, `SplitPresetCreateDto`, `InvitationCreateDto` — API võtab neid vastu kasutajalt | |
| 226 | +- **Identity DTO-d**: `RegisterInfo`, `LoginInfo`, `TokenRefreshInfo`, `LogoutInfo`, `JWTResponse` — autentimise andmevahetuseks | |
| 227 | +- **Vea DTO**: `RestApiErrorResponse` — standardne veaformaat | |
| 228 | + | |
| 229 | +### Mapperid | |
| 230 | + | |
| 231 | +Mapperid asuvad `App.DTO/Mappers/` kaustas. Need on **manuaalsed staatilised klassid** (mitte AutoMapper), mis teisendavad domeeni entiteete DTO-deks: | |
| 232 | + | |
| 233 | +- `TripMapper`, `ExpenseMapper`, `SettlementMapper`, `CurrencyMapper`, `BudgetCategoryMapper`, `InvitationMapper`, `PollMapper`, `WishlistMapper`, `SplitPresetMapper` | |
| 234 | + | |
| 235 | +Kursuse `desc.md` nõuab: *"Manual mappers (no AutoMapper)"*. Iga mapper on lihtne staatiline meetod, mis kopeerib omadused ühest tüübist teise. | |
| 236 | + | |
| 237 | +--- | |
| 238 | + | |
| 239 | +## 5. Domeeni mudelid (App.Domain) | |
| 240 | + | |
| 241 | +16 domeeni entiteeti + 3 Identity entiteeti + 8 enum-i. Kõik entiteedid pärivad `BaseEntity`-lt (Id, CreatedAt, UpdatedAt). `BaseEntity` genereerib `Id` automaatselt (`Guid.NewGuid()`) ja seab ajatemplid UTC-s. | |
| 242 | + | |
| 243 | +### Peamised entiteedid | |
| 244 | + | |
| 245 | +| Entiteet | Vastutus | | |
| 246 | +|----------|----------| | |
| 247 | +| **Trip** | Keskne entiteet — reis nimi, sihtkoht, kuupäevad, olek, vaikevaluuta | | |
| 248 | +| **TripParticipant** | Seob kasutaja reisiga, roll (Organizer/Participant), unikaalne (TripId, UserId) | | |
| 249 | +| **TripInvitation** | Token-põhine kutse reisiga liitumiseks, unikaalne tokeni indeks | | |
| 250 | +| **Expense** | Üksik kulutus — summa, maksja, kategooria, jagamismeetod | | |
| 251 | +| **ExpenseSplit** | Ühe osaleja osa konkreetses kulus | | |
| 252 | +| **BudgetCategory** | Eelarve kategooria reisi-spetsiifiline (nt Food, Transport), nimi on LangStr | | |
| 253 | +| **SplitPreset** | Salvestatud jagamise mall (nt "Hotelli grupp") | | |
| 254 | +| **SplitPresetMember** | Üks osaleja preset-is | | |
| 255 | +| **SettlementPlan** | Arveldusplaan optimeeritud maksetega | | |
| 256 | +| **SettlementPayment** | Üks makse arveldusplaanis (kahepoolne kinnitus) | | |
| 257 | +| **Currency** | Valuuta referentsandmed (EUR, USD, GBP, SEK, NOK), nimi on LangStr | | |
| 258 | +| **TripWishlistItem** | Soovinimekirja element (koht, tegevus, restoran) | | |
| 259 | +| **TripWishlistVote** | Hääl soovinimekirja elemendile, unikaalne (WishlistItemId, UserId) | | |
| 260 | +| **TripPoll** | Grupi küsitlus otsuste tegemiseks | | |
| 261 | +| **TripPollOption** | Küsitluse valikuvariant | | |
| 262 | +| **TripPollVote** | Hääl küsitluse valikule, unikaalne (PollOptionId, UserId) | | |
| 263 | + | |
| 264 | +### Identity entiteedid | |
| 265 | + | |
| 266 | +| Entiteet | Vastutus | | |
| 267 | +|----------|----------| | |
| 268 | +| **AppUser** | Pärib `IdentityUser<Guid>`, lisab FirstName ja LastName (max 128) | | |
| 269 | +| **AppRole** | Pärib `IdentityRole<Guid>` | | |
| 270 | +| **AppRefreshToken** | JWT refresh token koos rotatsiooniga (eelmine token + aegumisaeg) | | |
| 271 | + | |
| 272 | +### Enum-id | |
| 273 | + | |
| 274 | +| Enum | Väärtused | | |
| 275 | +|------|-----------| | |
| 276 | +| ETripStatus | Active, Finalizing, Settled, Archived (Finalizing on vahepealne seisund: plaan loodud, maksed käimas, kuid kõik pole veel kinnitatud) | | |
| 277 | +| ESplitMethod | EqualAll, EqualSubset, ExactAmounts, Percentages | | |
| 278 | +| EParticipantRole | Organizer, Participant | | |
| 279 | +| EInvitationStatus | Pending, Accepted, Declined, Expired, Revoked | | |
| 280 | +| EPaymentStatus | Pending, MarkedPaid, Confirmed | | |
| 281 | +| ESettlementStatus | Pending, InProgress, Completed | | |
| 282 | +| EWishlistCategory | Place, Activity, Restaurant, Other | | |
| 283 | +| EWishlistPriority | MustDo, NiceToHave, Optional | | |
| 284 | + | |
| 285 | +--- | |
| 286 | + | |
| 287 | +## 6. Autentimine ja autoriseerimine | |
| 288 | + | |
| 289 | +### JWT Bearer (API) | |
| 290 | + | |
| 291 | +1. Kasutaja registreerib/logib sisse läbi `POST /api/v1/identity/account/login` | |
| 292 | +2. Server genereerib JWT tokeni (HS256, claims: userId, rollid, email) ja refresh tokeni | |
| 293 | +3. Klient saadab tokeni iga päringuga: `Authorization: Bearer <token>` | |
| 294 | +4. ASP.NET middleware valideerib allkirja, aegumist ja väljastajat automaatselt | |
| 295 | + | |
| 296 | +Refresh token rotatsioon: vana token märgitakse kasutatud ja antakse uus. Vanal tokenil on 1-minutiline üleminekuperiood. | |
| 297 | + | |
| 298 | +### JWT Helper (Base.Helpers) | |
| 299 | + | |
| 300 | +- `GenerateJwt(...)` — loob JWT tokeni `SymmetricSecurityKey` + HMAC-SHA256-ga | |
| 301 | +- `ValidateJWT(...)` — valideerib allkirja ja väljastajat, **aga mitte aegumist** (`ValidateLifetime = false`) — seda kasutatakse refresh flow's, kus aegunud token on oodatud | |
| 302 | + | |
| 303 | +### Cookie autentimine (MVC) | |
| 304 | + | |
| 305 | +MVC vaated kasutavad küpsisepõhist autentimist — ASP.NET Identity haldab sessiooni. `SlidingExpiration` on lubatud. | |
| 306 | + | |
| 307 | +### Rollipõhine autoriseerimine | |
| 308 | + | |
| 309 | +**Süsteemi rollid** (Identity): `admin`, `user` — kontrollitakse `[Authorize(Roles = "admin")]` atribuudiga. | |
| 310 | + | |
| 311 | +**Reisi rollid** (domeen): `Organizer`, `Participant` — kontrollitakse BLL teenustes (nt `ITripService.IsOrganizerAsync()`), mis omakorda kutsuvad `ITripParticipantRepository.IsOrganizerAsync()`. Kontrollerid ei pääse repository-le ligi otse. | |
| 312 | + | |
| 313 | +### IDOR kaitse | |
| 314 | + | |
| 315 | +Iga BLL teenuse meetod, mis puudutab reisi-andmeid, võtab konstruktoris vastu `Guid userId` parameetri ja kontrollib osaleja/organiseerija staatust teenuse sees. Näiteks `TripService.GetByIdWithDetailsAsync(tripId, userId)` kutsub esmalt `IsParticipantAsync`-i — kui false, tagastab `null`, mida kontroller tõlgib `NotFound()`/`Forbid()`-iks. Kuna WebApp kontrollerid ei inject'i `IAppUnitOfWork`-i (see on Clean-reegel — verifitseeritav grep'iga), **kontrollerid ei saa kogemata IDOR-kontrolli vahele jätta** — nad peavad alati minema teenuse kaudu, mis kontrolli teeb. | |
| 316 | + | |
| 317 | +--- | |
| 318 | + | |
| 319 | +## 7. ViewModelid | |
| 320 | + | |
| 321 | +Kursuse nõue on kasutada **ViewModele** andmete edastamiseks vaadetesse, mitte ViewBag/ViewData'd. Rakenduses on **26 ViewModel klassi**. | |
| 322 | + | |
| 323 | +### Admin ViewModelid (AdminViewModels.cs) | |
| 324 | + | |
| 325 | +Admin alal on **21 ViewModel klassi**, mis tagavad järjepideva mustri: | |
| 326 | + | |
| 327 | +**Index ViewModelid** (loendite kuvamiseks, filtrite ja otsinguga): | |
| 328 | +- `AdminTripIndexViewModel`, `AdminExpenseIndexViewModel`, `AdminBudgetCategoryIndexViewModel`, `AdminSettlementPlanIndexViewModel`, `AdminTripParticipantIndexViewModel`, `AdminSettlementPaymentIndexViewModel`, `AdminPollIndexViewModel`, `AdminWishlistIndexViewModel`, `AdminInvitationIndexViewModel`, `AdminCurrencyIndexViewModel`, `AdminSplitPresetIndexViewModel` | |
| 329 | + | |
| 330 | +**Form ViewModelid** (loomine/muutmine koos SelectList-idega): | |
| 331 | +- `AdminTripFormViewModel`, `AdminExpenseFormViewModel`, `AdminBudgetCategoryFormViewModel`, `AdminSettlementPlanFormViewModel`, `AdminTripParticipantFormViewModel`, `AdminPollFormViewModel`, `AdminWishlistFormViewModel` | |
| 332 | + | |
| 333 | +**Spetsiaalsed ViewModelid:** | |
| 334 | +- `AdminDashboardViewModel` — 23 statistikanumbrit + 3 nimekirja (viimased reisid, kulud, kasutajad) | |
| 335 | +- `AdminEditRolesViewModel` — kasutaja rollide haldamine | |
| 336 | +- `RoleAssignmentViewModel` — abiklass rollide jaoks | |
| 337 | + | |
| 338 | +### Kliendi ViewModelid (kontrollerite failides) | |
| 339 | + | |
| 340 | +5 ViewModeli on defineeritud otse kontrolleri failides: | |
| 341 | + | |
| 342 | +| ViewModel | Kontroller | Eesmärk | | |
| 343 | +|-----------|-----------|---------| | |
| 344 | +| `TripIndexViewModel` | TripsController | Reisi loendi element koos rolliga | | |
| 345 | +| `ExpensesIndexViewModel` | ExpensesController | Kulud koos reisi ja valuuta kontekstiga | | |
| 346 | +| `BudgetCategoryViewModel` | BudgetController | Kategooria + kulutused + progressiriba arvutused | | |
| 347 | +| `SettlementBalanceViewModel` | SettlementController | Kasutaja saldo (makstud vs võlgu + NetBalance) | | |
| 348 | +| `WishlistItemViewModel` | WishlistClientController | Soovinimekiri + hääled + kasutaja hääl | | |
| 349 | + | |
| 350 | +### Andmete edastamise mustrid | |
| 351 | + | |
| 352 | +- **Admin ala**: 100% ViewModel-põhine, SelectList-id ViewModeli sees | |
| 353 | +- **Kliendi kontrollerid**: ViewModeleid kasutatakse peamise andmekandja jaoks; ViewData kasutatakse kontekstandmete jaoks (TripId, TripName, CurrencySymbol, IsOrganizer jne) | |
| 354 | +- **PollsClientController** ja **MembersController** edastavad domeeni entiteete otse (pole eraldi ViewModeli) | |
| 355 | + | |
| 356 | +--- | |
| 357 | + | |
| 358 | +## 8. Tõlked | |
| 359 | + | |
| 360 | +### UI tõlked (.resx failid) | |
| 361 | + | |
| 362 | +Staatilised tekstid (nupud, sildid, veateated) on `.resx` failides. Iga fail on kahes keeles: | |
| 363 | +- `Shared.resx` (inglise) / `Shared.et.resx` (eesti) | |
| 364 | +- `Common.resx` / `Common.et.resx` — valideerimisteated | |
| 365 | +- `Domain/*.resx` — vormiväljanimede ja enum-ide tõlked (Trip, Expense, Currency, BudgetCategory, TripParticipant, TripPoll, TripPollOption, TripWishlistItem, SettlementPlan, SettlementPayment, Enums) | |
| 366 | + | |
| 367 | +Razor vaadetes: `@Localizer["Save"]` → "Salvesta" (ET) või "Save" (EN). | |
| 368 | + | |
| 369 | +Keelevahetaja on navbaris — salvestab keele küpsisesse. | |
| 370 | + | |
| 371 | +### Enum-ide tõlked | |
| 372 | + | |
| 373 | +`EnumHelper` (WebApp/Helpers/) kasutab `ResourceManager`-it enum väärtuste lokaliseerimiseks. Võti: `{EnumType}_{Value}` (nt `ETripStatus_Active`), otsitakse `App.Resources.Domain.Enums` ressursist. | |
| 374 | + | |
| 375 | +### Andmebaasi tõlked (LangStr) | |
| 376 | + | |
| 377 | +Dünaamiline süsteemne sisu, mida admin haldab, kasutab `LangStr` — `Dictionary<string, string>` salvestatakse JSON-ina PostgreSQL-i: | |
| 378 | + | |
| 379 | +```json | |
| 380 | +{"en": "Euro", "et": "Euro"} | |
| 381 | +``` | |
| 382 | + | |
| 383 | +`Currency.Name` ja `BudgetCategory.Name` kasutavad `LangStr`-i. Admin vormis on kaks inputit (Name EN, Name ET). `LangStr.ToString()` tagastab automaatselt kasutaja keeles tõlke, fallback-iga neutraalsele kultuurile ja seejärel vaikekultuurile. | |
| 384 | + | |
| 385 | +Kasutaja-loodud sisu (reisi nimed, kulud, soovinimekirja elemendid) **ei kasutata LangStr-i** — see on kasutaja enda tekst, mitte süsteemne referentsandmed. | |
| 386 | + | |
| 387 | +--- | |
| 388 | + | |
| 389 | +## 9. API (REST) | |
| 390 | + | |
| 391 | +Versioonitud: `/api/v1/...`. Kõik kaitstud endpointid nõuavad JWT Bearer tokenit. Marsruudi muster: `/api/v1/[controller]/[action]`. | |
| 392 | + | |
| 393 | +### Kontrollerid | |
| 394 | + | |
| 395 | +| Kontroller | Endpointid | | |
| 396 | +|-----------|-----------| | |
| 397 | +| AccountController | register, login, refreshtoken, logout | | |
| 398 | +| TripsController | CRUD + osalejate info | | |
| 399 | +| ExpensesController | CRUD + jagamise loomine | | |
| 400 | +| BudgetCategoriesController | CRUD reisi kategooriatele | | |
| 401 | +| InvitationsController | kutse loomine, info, accept/decline/revoke | | |
| 402 | +| WishlistController | CRUD + hääletus + valmis märkimine | | |
| 403 | +| PollsController | CRUD + hääletus + sulgemine | | |
| 404 | +| SettlementsController | saldod, arvelduse arvutamine, mark-paid, confirm | | |
| 405 | +| SplitPresetsController | CRUD jagamismallidele | | |
| 406 | +| CurrenciesController | valuutade nimekiri | | |
| 407 | + | |
| 408 | +Swagger on konfigureeritud JWT Bearer turvameetmega — saab otse brauseris testida tokeniga. | |
| 409 | + | |
| 410 | +--- | |
| 411 | + | |
| 412 | +## 10. MVC veebirakendus | |
| 413 | + | |
| 414 | +### Kliendi kontrollerid (8 tk) | |
| 415 | + | |
| 416 | +| Kontroller | Peamised tegevused | Autoriseerimismuster | | |
| 417 | +|-----------|-----------|-----------| | |
| 418 | +| **HomeController** | Index, Privacy | Avalik (pole `[Authorize]`) | | |
| 419 | +| **TripsController** | CRUD + detailvaade statistikaga | `[Authorize]` + osaleja kontroll | | |
| 420 | +| **ExpensesController** | CRUD koos 4 jagamismeetodiga | `[Authorize]` + osaleja kontroll | | |
| 421 | +| **BudgetController** | Kategooriate haldamine + progressiribad | `[Authorize]` + organizer kontroll muutmisteks | | |
| 422 | +| **MembersController** | Kutselingi genereerimine, accept, eemaldamine | `[Authorize]` + organizer kontroll | | |
| 423 | +| **SettlementController** | Saldod, makse märkimine, kinnitamine | `[Authorize]` + osaleja kontroll | | |
| 424 | +| **PollsClientController** | Loomine, hääletus, sulgemine | `[Authorize]` + osaleja kontroll | | |
| 425 | +| **WishlistClientController** | CRUD + hääletus + valmis märkimine | `[Authorize]` + osaleja kontroll | | |
| 426 | + | |
| 427 | +Reisi kontekstis navigeerimine: Trip Details → nav-grid → Expenses / Budget / Members / Wishlist / Polls / Settlement. | |
| 428 | + | |
| 429 | +### Admin paneel (13 kontrollerit) | |
| 430 | + | |
| 431 | +Süsteemiadministraatori vaade `[Authorize(Roles = "admin")]`: | |
| 432 | + | |
| 433 | +| Kontroller | Vastutus | | |
| 434 | +|-----------|----------| | |
| 435 | +| **DashboardController** | Töölaud statistikaga (AdminDashboardViewModel) | | |
| 436 | +| **TripsController** | Kõigi reiside CRUD | | |
| 437 | +| **TripParticipantsController** | Osalejate haldamine | | |
| 438 | +| **ExpensesController** | Kulude haldamine | | |
| 439 | +| **BudgetCategoriesController** | Eelarvekategooriate haldamine | | |
| 440 | +| **CurrenciesController** | Valuutade haldamine mitmekeelsete nimedega | | |
| 441 | +| **SettlementPlansController** | Arveldusplaanide haldamine | | |
| 442 | +| **SettlementPaymentsController** | Maksete jälgimine | | |
| 443 | +| **PollsController** | Küsitluste haldamine | | |
| 444 | +| **WishlistController** | Soovinimekirja haldamine | | |
| 445 | +| **SplitPresetsController** | Jagamismallide haldamine | | |
| 446 | +| **InvitationsController** | Kutsete vaatamine ja haldamine | | |
| 447 | +| **UsersController** | Kasutajate nimekiri + rollide muutmine | | |
| 448 | + | |
| 449 | +Admin link navbaris on nähtav ainult kui `User.IsInRole("admin")` JA kasutaja on sisse logitud. | |
| 450 | + | |
| 451 | +--- | |
| 452 | + | |
| 453 | +## 11. Vaated (Views) | |
| 454 | + | |
| 455 | +### Kliendi vaated (34 tk) | |
| 456 | + | |
| 457 | +**Jagatud kujunduselemendid (Shared/):** | |
| 458 | +- `_Layout.cshtml` — peamine kujundusmall, toast-teated TempData kaudu, tinglik admin-link | |
| 459 | +- `_LoginPartial.cshtml` — sisselogimine/väljalogimine | |
| 460 | +- `_LanguageSelection.cshtml` — keelevahetaja | |
| 461 | +- `_ValidationScriptsPartial.cshtml` — kliendipoolne valideerimine | |
| 462 | +- `Error.cshtml` — vealeht | |
| 463 | + | |
| 464 | +**Reisid:** Index, Details (dashboard saldo/eelarve ülevaatega), Create, Edit, Delete | |
| 465 | + | |
| 466 | +**Kulud:** Index, Create (jagamismeetodi valik + osalejate valik), Edit, Delete | |
| 467 | + | |
| 468 | +**Liikmed:** Index, Invite, InviteGenerated (kutselingi kuvamine), AcceptInvitation, InvitationInvalid | |
| 469 | + | |
| 470 | +**Eelarve:** Index (progressiribadega), CreateCategory, EditCategory, DeleteCategory | |
| 471 | + | |
| 472 | +**Arveldus:** Index (saldod + maksestaatused) | |
| 473 | + | |
| 474 | +**Küsitlused:** Index, Create, Details (hääletus + tulemused) | |
| 475 | + | |
| 476 | +**Soovinimekiri:** Index, Create, Edit, Delete | |
| 477 | + | |
| 478 | +### Admin vaated (43+ tk) | |
| 479 | + | |
| 480 | +Iga admin kontroller omab standardset CRUD vaadete komplekti (Index, Details, Create, Edit, Delete). Eraldi: | |
| 481 | +- Dashboard/Index — statistika | |
| 482 | +- Users/Index — kasutajate nimekiri | |
| 483 | +- Users/EditRoles — rollide muutmine | |
| 484 | + | |
| 485 | +--- | |
| 486 | + | |
| 487 | +## 12. Helperid (WebApp/Helpers) | |
| 488 | + | |
| 489 | +### CurrencyConverter | |
| 490 | + | |
| 491 | +Staatiline klass valuutade teisendamiseks. Olemas **kahes kohas**: `WebApp/Helpers/CurrencyConverter.cs` (MVC kontrollerite jaoks) ja `App.BLL/Helpers/CurrencyConverter.cs` (teenuste jaoks). Mõlemad on identsed. | |
| 492 | + | |
| 493 | +- Hardcoded kursid EUR baasil: EUR=1.0, USD=0.92, GBP=1.16, SEK=0.087, NOK=0.086 | |
| 494 | +- Teisendus: summa → EUR → sihtvaluuta, ümardamine 2 kohani | |
| 495 | +- Tundmatu valuuta korral tagastab 1:1 (fallback) | |
| 496 | + | |
| 497 | +### EnumHelper | |
| 498 | + | |
| 499 | +Staatiline klass enum-väärtuste lokaliseeritud nimede saamiseks: | |
| 500 | +- `GetDisplayName<TEnum>(TEnum value)` — kasutab `ResourceManager`-it (`App.Resources.Domain.Enums`) | |
| 501 | +- Võtmeformaat: `{EnumType}_{Value}`, fallback: enum väärtuse nimi stringina | |
| 502 | + | |
| 503 | +### InvariantDecimalModelBinderProvider | |
| 504 | + | |
| 505 | +Custom model binder, mis lubab kasumi sisendites nii punkti (`.`) kui koma (`,`) kümnendkoha eraldajana. See lahendab probleemi, kus erinevad brauseri lokaadid saadavad erinevaid formaate. | |
| 506 | + | |
| 507 | +--- | |
| 508 | + | |
| 509 | +## 13. Infrastruktuur | |
| 510 | + | |
| 511 | +### Docker | |
| 512 | + | |
| 513 | +- **Dockerfile** — multi-stage build (SDK 10.0 → runtime ASP.NET 10.0), minimeerib image suurust. Port: 8080. | |
| 514 | +- **docker-compose.yml** — PostgreSQL 16 + veebirakendus, persistent volume andmebaasile. Hostport: 84 → konteiner 8080. | |
| 515 | +- Käivitamisel: `docker compose down -v && docker compose up --build` | |
| 516 | + | |
| 517 | +### CORS | |
| 518 | + | |
| 519 | +`CorsAllowAll` poliitika — lubab kõik päritolud, päised ja meetodid. Eksponeerib päised: `X-Version`, `X-Version-Created-At`. | |
| 520 | + | |
| 521 | +### Andmebaas (AppDbContext) | |
| 522 | + | |
| 523 | +PostgreSQL 16 läbi Npgsql. Konfiguratsioon: | |
| 524 | +- **SplitQuery** — väldib karteerianist plahvatust (`UseQuerySplittingBehavior`) | |
| 525 | +- **NoTrackingWithIdentityResolution** — parem jõudlus, aga säilitab entiteetide identiteedi | |
| 526 | +- **Restrict delete behavior** — kõik võõrvõtmed, kaskaad teostatud manuaalselt repositorys | |
| 527 | +- **UTC ajatemplid** — custom `UtcDateTimeConverter` kõigile DateTime omadustele | |
| 528 | +- **LangStr JSON** — `Currency.Name` ja `BudgetCategory.Name` salvestatakse JSON-ina | |
| 529 | +- **Unikaalsed indeksid** — TripInvitation.Token, (TripParticipant.TripId, UserId), küsitlus- ja soovinimekirja hääled | |
| 530 | +- **Automaatsed ajatemplid** — `SaveChangesAsync()` override uuendab `CreatedAt`/`UpdatedAt` | |
| 531 | + | |
| 532 | +### Data Protection | |
| 533 | + | |
| 534 | +ASP.NET Core Data Protection võtmed salvestatakse andmebaasi (`PersistKeysToDbContext`). | |
| 535 | + | |
| 536 | +### API versioonimine | |
| 537 | + | |
| 538 | +Asp.Versioning teek, vaikeversioon 1.0, formaat `'v'VVV` (nt v1.0). | |
| 539 | + | |
| 540 | +### Teenuste registreerimine (Program.cs, DI) | |
| 541 | + | |
| 542 | +Kõik teenused on registreeritud **Scoped** elutsükliga: | |
| 543 | +``` | |
| 544 | +IAppUnitOfWork → AppUnitOfWork | |
| 545 | +ITripService → TripService | |
| 546 | +IExpenseService → ExpenseService | |
| 547 | +ISettlementService → SettlementService | |
| 548 | +IInvitationService → InvitationService | |
| 549 | +IPollService → PollService | |
| 550 | +``` | |
| 551 | + | |
| 552 | +### Marsruutimine | |
| 553 | + | |
| 554 | +1. Admin ala: `{area:exists}/{controller=Dashboard}/{action=Index}/{id?}` | |
| 555 | +2. Vaikimisi: `{controller=Home}/{action=Index}/{id?}` | |
| 556 | +3. Razor Pages (Identity UI jaoks) | |
| 557 | + | |
| 558 | +### Andmebaasi initsialiseerimine | |
| 559 | + | |
| 560 | +Startup ajal (`SetupAppData`): | |
| 561 | +- Ootab PostgreSQL ühendust (retry loop) | |
| 562 | +- Konfiguratsioonist loetavad lipud: `DropDatabase`, `MigrateDatabase`, `SeedIdentity`, `SeedData` | |
| 563 | + | |
| 564 | +### Seed andmed | |
| 565 | + | |
| 566 | +**Kasutajad:** | |
| 567 | +- admin@taltech.ee (admin roll) | |
| 568 | +- user@taltech.ee, alice@taltech.ee, bob@taltech.ee, charlie@taltech.ee, diana@taltech.ee (user roll) | |
| 569 | + | |
| 570 | +**Valuutad:** EUR, USD, GBP, SEK, NOK (mitmekeelsete nimedega) | |
| 571 | + | |
| 572 | +**Näidisreisid (4 tk):** | |
| 573 | +1. **Barcelona Weekend** — 4 osalejat, Active, EUR, 11 kulu, eelarve kategooriad, jagamismallid, küsitlus, soovinimekiri | |
| 574 | +2. **London Business Trip** — 3 osalejat, Settled, GBP, 6 kulu, kinnitatud arveldusplaan | |
| 575 | +3. **Summer Cabin Getaway** — 5 osalejat, Active, EUR, 7 kulu, pooleliolev arveldus, küsitlus, soovinimekiri, ootel kutse | |
| 576 | +4. **NYC Adventure** — 3 osalejat, Archived, USD, 9 kulu, suletud küsitlus | |
| 577 | + | |
| 578 | +--- | |
| 579 | + | |
| 580 | +## 14. Staatilised failid ja frontend | |
| 581 | + | |
| 582 | +### CSS | |
| 583 | +- `wwwroot/css/site.css` — peamine kujundusfail | |
| 584 | +- `wwwroot/css/splitapp-design.css` — SplitApp-spetsiifilised stiilid | |
| 585 | +- Bootstrap 5 (teegi kaust) | |
| 586 | + | |
| 587 | +### JavaScript | |
| 588 | +- `wwwroot/js/site.js` — saidi skriptid | |
| 589 | +- `wwwroot/js/splitapp.js` — SplitApp-spetsiifilised funktsioonid (toast-teated, jagamismeetodi valik jne) | |
| 590 | + | |
| 591 | +### Teegid (wwwroot/lib/) | |
| 592 | +- Bootstrap 5, jQuery, Popper.js | |
| 593 | + | |
| 594 | +--- | |
| 595 | + | |
| 596 | +## 15. Projekti failid ja sõltuvused | |
| 597 | + | |
| 598 | +### NuGet paketid (WebApp) | |
| 599 | + | |
| 600 | +| Pakett | Versioon | Otstarve | | |
| 601 | +|--------|---------|----------| | |
| 602 | +| Asp.Versioning.Mvc.ApiExplorer | 8.1.1 | API versioonimine | | |
| 603 | +| Microsoft.AspNetCore.Authentication.JwtBearer | 10.0.5 | JWT tugi | | |
| 604 | +| Microsoft.AspNetCore.Identity.EntityFrameworkCore | 10.0.5 | Identity | | |
| 605 | +| Microsoft.AspNetCore.Identity.UI | 10.0.5 | Identity UI | | |
| 606 | +| Microsoft.EntityFrameworkCore.Tools | 10.0.5 | EF migratsioonid | | |
| 607 | +| Npgsql.EntityFrameworkCore.PostgreSQL | 10.0.1 | PostgreSQL tugi | | |
| 608 | +| Swashbuckle.AspNetCore | 10.1.7 | Swagger/OpenAPI | | |
| 609 | + | |
| 610 | +### Migratsioonid (5 tk) | |
| 611 | + | |
| 612 | +1. `20260328145416_Initial` — esialgne skeem | |
| 613 | +2. `20260328161224_AddBaseEntityTimestamps` — CreatedAt/UpdatedAt lisamine | |
| 614 | +3. `20260329141138_CurrencyNameToLangStr` — Currency.Name teisendamine LangStr JSON-iks | |
| 615 | +4. `20260402104505_RemoveUnusedBudgetCategoryTranslations` — puhastus | |
| 616 | +5. `20260410202112_BudgetCategoryNameToLangStr` — BudgetCategory.Name teisendamine LangStr JSON-iks | |
| 617 | + | |
| 618 | +--- | |
| 619 | + | |
| 620 | +## 15. Kaitsmise spikker (defense cheat sheet) | |
| 621 | + | |
| 622 | +Selle peatüki eesmärk on anda lühikesed, ausad vastused õpetaja tüüpilistele küsimustele. | |
| 623 | + | |
| 624 | +### Küsimus: "Mis arhitektuuri sa kasutasid?" | |
| 625 | + | |
| 626 | +**Vastus:** "Clean Architecture'it. Sõltuvused liiguvad sissepoole: `WebApp → App.BLL → App.Domain`, ning `App.DAL.EF` on plugin väljaspool, mis implementeerib Domain-interfejse. Repository- ja UoW-liidesed (`IAppUnitOfWork`, `ITripRepository` jt) elavad [App.Domain/Contracts/](SplitApp/App.Domain/Contracts/)-is. `App.BLL.csproj` ei viita `App.DAL.EF`-ile üldse — dependency inversion on tagatud Domain-interfejside kaudu. WebApp kontrollerid kasutavad ainult BLL teenuseid — ükski kontroller ei inject'i `IAppUnitOfWork`-i." | |
| 627 | + | |
| 628 | +### Küsimus: "Kuidas Clean Architecture sinu projektis välja näeb?" | |
| 629 | + | |
| 630 | +**Vastus:** "Kolm põhiomadust, mida saab verifitseerida: | |
| 631 | +1. **Interfejsid Domain-is:** `App.Domain/Contracts/IAppUnitOfWork.cs`, `ITripRepository.cs` jne — kokku 11 interfejsi | |
| 632 | +2. **DAL on plugin:** `App.DAL.EF/AppUnitOfWork.cs` implementeerib `App.Domain.Contracts.IAppUnitOfWork`-i. Sõltuvus liigub DAL → Domain (väljast sisse) | |
| 633 | +3. **WebApp ei näe DAL-i:** `grep IAppUnitOfWork WebApp/Controllers WebApp/ApiControllers WebApp/Areas` → 0 tulemust. DAL-i viidatakse ainult Program.cs-s extension method'i (`AddDalServices(connectionString)`) kaudu | |
| 634 | +4. **BLL ei sõltu DAL-ist:** `App.BLL.csproj` viitab ainult `App.Domain`-ile ja `App.DTO`-le" | |
| 635 | + | |
| 636 | +### Küsimus: "Kuidas IDOR kaitse töötab?" | |
| 637 | + | |
| 638 | +**Vastus:** "IDOR-loogika elab BLL teenustes (`ITripService`, `IExpenseService` jt). Iga meetod, mis tagastab või muudab reisi-andmeid, võtab konstruktoris vastu `Guid userId` parameetri ja kontrollib osaleja/organiseerija staatust teenuse sees. Näiteks `TripService.GetByIdWithDetailsAsync(tripId, userId)` kutsub esmalt `_uow.TripParticipants.IsParticipantAsync(tripId, userId)` — kui false, tagastab `null`. Kontroller tõlgib `null` → `NotFound()`/`Forbid()`. Nii ei saa kontroller kogemata kontrolli vahele jätta, sest kontrollerid ei pääse ligi UoW-le üldse — ainult teenustele." | |
| 639 | + | |
| 640 | +### Küsimus: "Kuidas settlement algoritm töötab?" | |
| 641 | + | |
| 642 | +**Vastus:** "Greedy algoritm kahe sorteeritud nimekirjaga. `CalculateBalancesAsync` arvutab iga osaleja netosaldo (makstud − peab maksma). `CalculateSettlementAsync` jagab need võlausaldajateks (positiivne saldo) ja võlgnikeks (negatiivne), sorteerib kahanevas järjekorras, ja two-pointer'iga paardab suurima võlausaldaja suurima võlgnikuga. See minimeerib maksete arvu. Lävi 0.01€ väldib ümardamisartefakte. Makse lifecycle: Pending → MarkedPaid (võlgnik märgib, ainult FromUser) → Confirmed (võlausaldaja kinnitab, ainult ToUser). Reisi lifecycle: Active → Finalizing (Finalize vajutusel) → Settled (automaatselt siis, kui viimane makse on kinnitatud — seda teeb `ConfirmPaymentAsync` plaani all-confirmed kontrollis). Kui kõik maksed Confirmed, plaani staatus Completed ja reis `Settled`. Tähtis detail: DAL on NoTracking-režiimis, seega iga mutatsioon vajab selget `Update()`-kutset; plaani uuendamine käib shallow base-repo kaudu, et mitte kaskaadida AppUser navigatsioonivaradesse (`ConcurrencyStamp` Identity ridu rikuks)." | |
| 643 | + | |
| 644 | +### Küsimus: "Miks mitte AutoMapper?" | |
| 645 | + | |
| 646 | +**Vastus:** "Kursuse `desc.md` nõudis manuaalseid mappereid. Lisaks on manuaalsed mapperid kiiremad (pole reflection'it), debugitavamad (saab breakpointi panna) ja tüübiturvalisemad (kompileerimisaeg error, mitte runtime). DTO struktuurid muutuvad harva, nii et käsitsi kirjutamise vaev on minimaalne." | |
| 647 | + | |
| 648 | +### Küsimus: "Kuidas LangStr töötab andmebaasis?" | |
| 649 | + | |
| 650 | +**Vastus:** "`LangStr` on `Dictionary<string, string>`, mis serialiseeritakse JSON-ina PostgreSQL-sse. Näiteks `Currency.Name` on andmebaasis `{\"en\":\"Euro\",\"et\":\"Euro\"}`. `LangStr.ToString()` tagastab kasutaja praeguse kultuuri tõlke, fallback'iga neutraalsele kultuurile ja seejärel vaikekultuurile. Admin vormis on kaks eraldi inputit (Name EN, Name ET), mida kontroller paneb kokku `LangStr` objektiks." | |
| 651 | + | |
| 652 | +### Küsimus: "Milliseid entiteete LangStr kasutab?" | |
| 653 | + | |
| 654 | +**Vastus:** "Kaks entiteeti: `Currency.Name` ja `BudgetCategory.Name`. Need on süsteemsed referentsandmed, mida admin haldab ja mida kõik kasutajad näevad. Kasutaja-loodud sisu (reisi nimed, kulu kirjeldused, soovinimekirja elemendid) LangStr-i ei kasuta — need on kasutaja enda tekst omas keeles. Nõue oli '*translations in DB*', mitte '*every field translated*'." | |
| 655 | + | |
| 656 | +### Küsimus: "Miks admin kontrollerites on Admin ViewModelid keerulised?" | |
| 657 | + | |
| 658 | +**Vastus:** "Teacher'i nõue oli `no viewbags/viewdata - use viewmodels`. Lõin kolm põhiklassi: | |
| 659 | +- `AdminPageViewModel` — baasklass `Title` omadusega; iga Index/Form VM pärib sellelt | |
| 660 | +- `AdminDetailsViewModel<T>` — geneeriline wrapper Details-vaadetele, et domeeni entiteet ei lekiks otse vaatesse | |
| 661 | +- `AdminDeleteViewModel<T>` — sama Delete jaoks | |
| 662 | + | |
| 663 | +Admin layout loeb `Title`-i läbi interface'i cast'i: `(Model as ITitledViewModel)?.Title`. Seetõttu on admin vaates **0** `ViewData`/`ViewBag` kasutust — grep-tööriist kinnitab." | |
| 664 | + | |
| 665 | +### Küsimus: "Kuidas admin Dashboard statistika arvutatakse?" | |
| 666 | + | |
| 667 | +**Vastus:** "Kogu agregatsiooniloogika (10+ metrikut, Top Active Trips, Biggest Expenses, User Activity 7d/30d, Activity Feed) elab `IAdminStatsService.GetDashboardStatsAsync()`-is ([App.BLL/Services/Admin/AdminStatsService.cs](SplitApp/App.BLL/Services/Admin/AdminStatsService.cs)). Teenus tagastab `AdminDashboardData` DTO, `DashboardController.Index()` mappib selle `AdminDashboardViewModel`-ile ja kuvab vaates. Kontroller ise on ~30 rida — kogu äriloogika on BLL-is, nagu Clean nõuab." | |
| 668 | + | |
| 669 | +### Küsimus: "Kuidas andmebaasi vahetada oleks, kui tahaksid?" | |
| 670 | + | |
| 671 | +**Vastus:** "Tänu Clean Architecture'ile väga lihtne. `App.Domain/Contracts/` sisaldab kõiki repository-interfejse, `App.DAL.EF` on nende implementatsioon EF Core + PostgreSQL peal. DB vahetuseks tuleks: | |
| 672 | +1. Luua uus projekt (nt `App.DAL.MongoDB`), mis implementeerib samu interfejse | |
| 673 | +2. Muuta `Program.cs` ühte rida: `builder.Services.AddMongoDalServices(...)` asemel praeguse `AddDalServices(...)` | |
| 674 | +3. Migreerida andmed | |
| 675 | + | |
| 676 | +`App.BLL`, `WebApp` ja `App.Domain` ei muutu — see on Clean-i põhivõit. `App.DAL.EF` on teadlikult plugin, mida saab asendada." | |
| 677 | + | |
| 678 | +### Küsimus: "Miks JWT refresh token rotatsioon on vajalik?" | |
| 679 | + | |
| 680 | +**Vastus:** "Turvalisuse pärast: kui rünnaja varastab vana refresh tokeni, ei saa ta seda kasutada, sest see on juba konkreetse kasutaja uue tokeniga asendatud. Meie implementatsioon: iga refresh-kutse genereerib uue access+refresh paari, vana refresh token märgitakse `PreviousToken`-iks ja aegub 1 minuti pärast (üleminekuperiood võrgukatkestuste jaoks)." | |
| 681 | + | |
| 682 | +### Küsimus: "Miks CI/CD lükkab ainult `main` harust?" | |
| 683 | + | |
| 684 | +**Vastus:** "Konfigureeritud [.gitlab-ci.yml](.gitlab-ci.yml)-s `only: - main`. See takistab juhuslikke feature-branchide deployment'e. Tootmiseks peab explicitly main-i mergima. Docker compose builditakse uuesti iga push'iga, migratsioonid rakendatakse automaatselt (env `DataInitialization__MigrateDatabase=true`) startup'i ajal." | |
| 685 | + | |
| 686 | +### Küsimus: "Miks 10 enam kui 10 entiteeti?" | |
| 687 | + | |
| 688 | +**Vastus:** "Ülesanne nõudis *min 10 meaningful*. Mul on 16, sest reisikulude domeen on loomulikult rikas: lisaks põhitükkidele (Trip, Expense, User) on vajalikud vote-tabelid (TripPollVote, TripWishlistVote), settlement'i kaks kihti (SettlementPlan → SettlementPayment'id), split-preset'i kaks kihti (SplitPreset → SplitPresetMember), eraldi split-kirjed iga kulu jaoks (ExpenseSplit). Ükski pole trivaalne join-tabel — kõigil on omadused (Amount, Percentage, IsInterested, jne)." | |
| 689 | + | |
| 690 | +### Küsimus: "Mis on kõige keerulisem osa projektis?" | |
| 691 | + | |
| 692 | +**Vastus:** "`SettlementService.CalculateSettlementAsync()` greedy algoritm koos valuutakonversiooniga. Mitu nüansi: | |
| 693 | +1. Iga kulu võib olla erinevas valuutas → `CurrencyConverter` normaliseerib reisi vaikevaluutasse | |
| 694 | +2. Ümardamisartefaktid (nt 33.33 + 33.33 + 33.34 = 100.00) — lõpliku osaleja summa on floor'itud, ülejääk 0.01 kaupa esimestele | |
| 695 | +3. Two-pointer sorted lists — võlausaldajad kahanevalt, võlgnikud tõusvalt (võlg = negatiivne) | |
| 696 | +4. Makse lifecycle kahepoolse kinnitusega (mark-paid → confirm), mitte lihtsalt 'done'" | |
| 697 | + | |
| 698 | +### Küsimus: "Miks mõni asi jääb Domain-is 'saastunud' (Display atribuudid Resources-ile)?" | |
| 699 | + | |
| 700 | +**Vastus:** "Teadlik pragmaatiline kompromiss. `App.Domain/*.cs` entiteetidel on jätkuvalt `[Display(ResourceType = typeof(App.Resources.Domain.Trip))]` atribuudid, mis seovad Domain-i Resources-iga. Täielikus Cleanis oleks need atribuudid DTO-des või ViewModelides. Ma teadlikult ei kolinud neid, sest see oleks katkestanud ModelState valideerimise ja nõudnud iga form'i re-testi. Kõik **muud** Clean-põhimõtted (interfejsid Domain-is, DAL plugin, BLL ↛ DAL, WebApp ↛ UoW) on rangelt järgitud." | |
| 701 | + | |
| 702 | +### Küsimus: "Kuidas sõltuvuse inversioon sinu projektis konkreetselt toimib?" | |
| 703 | + | |
| 704 | +**Vastus:** "Konkreetne näide. `App.BLL/Services/TripService.cs` deklareerib: | |
| 705 | +```csharp | |
| 706 | +using App.Domain.Contracts; // interfejs Domain-ist | |
| 707 | + | |
| 708 | +public class TripService : ITripService { | |
| 709 | + private readonly IAppUnitOfWork _uow; // Domain-interfejs | |
| 710 | + public TripService(IAppUnitOfWork uow) { _uow = uow; } | |
| 711 | +} | |
| 712 | +``` | |
| 713 | +BLL ei tea `App.DAL.EF`-ist midagi. Kompileerimise ajal pole `App.BLL.csproj`-s DAL-i viidet. DI-container ühendab käivitamisel `IAppUnitOfWork` → `AppUnitOfWork` (DAL-ist) tänu `Program.cs` `AddDalServices()` registreerimisele. See ongi dependency inversion — kõrgem kiht (BLL) sõltub abstraktsioonist (Domain), mitte konkreetsest implementatsioonist (DAL)." | |
| 714 | + | |
| 715 | +--- | |
| 716 | + | |
| 717 | +## 16. Mida võiks paremini teha | |
| 718 | + | |
| 719 | +Ausalt — kohad, kus projekt võiks olla parem: | |
| 720 | + | |
| 721 | +1. **Domain puhastamine** — `App.Domain/*.cs` entiteetidel on jätkuvalt `[Display(ResourceType = typeof(App.Resources.Domain.X))]` atribuudid. Täielikus Cleanis peaksid need olema DTO-del või ViewModelidel. Teadlik pragmaatiline kompromiss ModelState-valideerimise tõttu. | |
| 722 | +2. **LangStr laiem kasutus** — praegu ainult 2 entiteedis (`Currency.Name`, `BudgetCategory.Name`). Võiks laieneda `Trip.Name`, `TripPoll.Question`, `SplitPreset.Name` peale. | |
| 723 | +3. **Integratsioontestid** — ükshaaval tehtud manuaalne testimine; CI käigus võiks olla `dotnet test` koos in-memory andmebaasiga. Clean Architecture teeb testide kirjutamise lihtsamaks (teenuseid saab mockida läbi Domain-interfejside). | |
| 724 | +4. **Valuutakursid** — praegu hardcoded `CurrencyConverter`-is. Reaalses rakenduses peaks need tulema välisest API-st. | |
| 725 | +5. **Rate limiting** — puudub. API endpointid on kaitsmata DDoS-i eest. | |
| 726 | +6. **Logimine** — lihtne `Console.WriteLine` mitmes kohas (eriti `SetupAppData`). Structured logging Serilog-iga oleks parem. | |
| 727 | +7. **WebApp → DAL kompromissviide** — `WebApp.csproj` viitab endiselt `App.DAL.EF`-ile, et `Program.cs` saaks kutsuda `AddDalServices()`. 100% isolatsiooniks oleks vaja eraldi `App.DAL.EF.Bootstrap` projekti, mis on Cleani purist'i jaoks väärt, aga praktiliselt over-engineering. | |
| 728 | + | |
| 729 | +Need **ei ole puuduvad nõuded** — need on parandusvõimalused. |
added testing-plan.md +188 −0
| @@ -0,0 +1,188 @@ | ||
| 1 | +# Testing — Implemented State | |
| 2 | + | |
| 3 | +Aligned with the Akaver testing lecture (`courses.taltech.akaver.com/web-applications-with-csharp/lectures/testing`). | |
| 4 | + | |
| 5 | +**Status:** ✅ 44 tests, all green. Local-only via `dotnet test`. **No CI/CD changes** — VPS pipeline untouched. | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## Stack (loengust) | |
| 10 | + | |
| 11 | +| Tool | Version | Roll | | |
| 12 | +|---|---|---| | |
| 13 | +| **xUnit** | 2.9.3 | Test framework | | |
| 14 | +| **Moq** | 4.20.72 | `IAppUnitOfWork` + repo mocking BLL teenuste jaoks | | |
| 15 | +| **FluentAssertions** | 6.12.2 | Loetavad väited (`result.Should().NotBeNull()`) — pinnitud v6 (täielikult tasuta) | | |
| 16 | +| **Microsoft.EntityFrameworkCore.Sqlite** | 10.0.7 | SQLite in-memory DB DAL testidele (loeng eelistab seda EF InMemory provideri ees) | | |
| 17 | +| **coverlet.collector** | 6.0.4 | Code coverage (vaikimisi mitteaktiivne) | | |
| 18 | + | |
| 19 | +--- | |
| 20 | + | |
| 21 | +## Project structure | |
| 22 | + | |
| 23 | +``` | |
| 24 | +SplitApp/ | |
| 25 | +└── App.Tests/ ← üks test projekt, lisatud SplitApp.sln-i | |
| 26 | + ├── App.Tests.csproj ← references App.DAL.EF, App.BLL, App.Domain | |
| 27 | + ├── RepositoryTestBase.cs ← SQLite in-memory baasklass + SeedTripAsync helper | |
| 28 | + ├── SanityTest.cs ← 1 test | |
| 29 | + ├── Domain/ ← 8 testi | |
| 30 | + │ ├── LangStrTests.cs ← 7 (sealhulgas [Theory] 3 keelega) | |
| 31 | + │ └── TripValidationTests.cs ← 3 (IValidatableObject) | |
| 32 | + ├── DAL/ ← 9 testi (SQLite in-memory) | |
| 33 | + │ ├── TripRepositoryTests.cs ← 2 | |
| 34 | + │ ├── ExpenseRepositoryTests.cs ← 1 | |
| 35 | + │ ├── BudgetCategoryRepositoryTests.cs ← 1 | |
| 36 | + │ ├── TripParticipantRepositoryTests.cs ← 3 (IDOR-relevantne) | |
| 37 | + │ └── TripInvitationRepositoryTests.cs ← 2 | |
| 38 | + ├── BLL/ ← 13 testi (mocked UoW + helper) | |
| 39 | + │ ├── TripServiceTests.cs ← 5 (3 happy + 2 sad path) | |
| 40 | + │ ├── BudgetCategoryServiceTests.cs ← 2 (1 happy + 1 sad) | |
| 41 | + │ ├── ExpenseServiceTests.cs ← 2 (happy + edge case) | |
| 42 | + │ ├── SettlementServiceTests.cs ← 2 (edge cases) | |
| 43 | + │ ├── WishlistServiceTests.cs ← 2 (sad + happy) | |
| 44 | + │ └── CurrencyConverterTests.cs ← 5 (sealhulgas [Theory] 3 valuutapaariga) | |
| 45 | + └── Mappers/ ← 6 testi | |
| 46 | + ├── TripBllDtoFactoryTests.cs ← 2 (Create + round-trip) | |
| 47 | + ├── ExpenseBllDtoFactoryTests.cs ← 1 | |
| 48 | + ├── BudgetCategoryBllDtoFactoryTests.cs ← 2 | |
| 49 | + └── CurrencyBllDtoFactoryTests.cs ← 1 (LangStr säilumine) | |
| 50 | +``` | |
| 51 | + | |
| 52 | +--- | |
| 53 | + | |
| 54 | +## Test count summary | |
| 55 | + | |
| 56 | +| Kiht | Testid | Mida testib | | |
| 57 | +|---|---|---| | |
| 58 | +| Sanity | 1 | xUnit infra töötab | | |
| 59 | +| **Domain** | 8 | LangStr i18n value object + Trip `IValidatableObject` | | |
| 60 | +| **DAL** | 9 | Repositoorid päris EF + SQLite vastu | | |
| 61 | +| **BLL** | 13 | Teenuste äriloogika + IDOR + edge case-id + helper | | |
| 62 | +| **Mappers** | 6 | Domain ↔ BllDto skalaarsete väljade säilumine | | |
| 63 | +| **KOKKU** | **44** | | | |
| 64 | + | |
| 65 | +--- | |
| 66 | + | |
| 67 | +## Coverage matrix — kõik Onion-i ringid testitud | |
| 68 | + | |
| 69 | +``` | |
| 70 | +WebApp ──┐ | |
| 71 | + ├──► App.BLL ────────────► App.Domain ──► Base.Domain | |
| 72 | +App.DTO ─┘ ✓ TripService ✓ Trip + Validate() ✓ LangStr | |
| 73 | + ✓ ExpenseService ✓ Expense entity ✓ BaseEntity | |
| 74 | + ✓ BudgetCategoryService ✓ BudgetCategory | |
| 75 | + ✓ SettlementService ✓ TripParticipant | |
| 76 | + ✓ WishlistService ✓ TripInvitation | |
| 77 | + ✓ CurrencyConverter ✓ Currency | |
| 78 | + ✓ Mappers (4) | |
| 79 | + ▲ | |
| 80 | + │ | |
| 81 | +App.DAL.EF ──────┘ | |
| 82 | +✓ TripRepository (2) | |
| 83 | +✓ ExpenseRepository (1) | |
| 84 | +✓ BudgetCategoryRepository (1) | |
| 85 | +✓ TripParticipantRepository (3) | |
| 86 | +✓ TripInvitationRepository (2) | |
| 87 | +``` | |
| 88 | + | |
| 89 | +--- | |
| 90 | + | |
| 91 | +## Mitmekesisuse maatriks (loengu tehnikad) | |
| 92 | + | |
| 93 | +| Tehnika | Kus kasutatud | | |
| 94 | +|---|---| | |
| 95 | +| **`[Fact]`** | Kõik klassikalised testid | | |
| 96 | +| **`[Theory]` + `[InlineData]`** (loengust!) | `CurrencyConverterTests.Convert_KnownCurrencies` (3 valuutapaari), `LangStrTests.Translate_ReturnsCorrectValueForKnownCulture` (3 keelt) | | |
| 97 | +| **AAA pattern** | Kõikides testides `// Arrange / Act / Assert` kommentaarid | | |
| 98 | +| **Moq `Setup` + `Returns`** | `_participants.Setup(r => r.IsOrganizerAsync(...)).ReturnsAsync(true)` | | |
| 99 | +| **Moq `Verify` + `Times`** | `_uow.Verify(u => u.SaveChangesAsync(), Times.Never)` (kontrollib, et ei kutsutud) | | |
| 100 | +| **Moq `Callback`** | `WishlistService` test salvestab loodud entity koopia | | |
| 101 | +| **SQLite in-memory** | `RepositoryTestBase` SqliteConnection-iga — FK-d kehtivad | | |
| 102 | +| **FluentAssertions chain** | `.Should().ContainSingle().Which.Id.Should().Be(...)` | | |
| 103 | +| **`BeEquivalentTo` + `Excluding`** | Round-trip testides (mapper) | | |
| 104 | +| **Domain validation** | `Trip.Validate()` `IValidatableObject` | | |
| 105 | +| **i18n value object** | LangStr `Translate` fallback chain (regional → neutral → default) | | |
| 106 | + | |
| 107 | +--- | |
| 108 | + | |
| 109 | +## Sad path / IDOR / edge case katvus | |
| 110 | + | |
| 111 | +| Kategooria | Test | | |
| 112 | +|---|---| | |
| 113 | +| **IDOR negatiivne** | `TripServiceTests.GetByIdAsync_WhenUserIsNotParticipant_ReturnsNull` | | |
| 114 | +| **IDOR negatiivne** | `TripServiceTests.DeleteAsync_WhenUserIsNotOrganizer_ReturnsFalseAndDoesNotDelete` | | |
| 115 | +| **IDOR negatiivne** | `BudgetCategoryServiceTests.CreateAsync_WhenUserIsNotOrganizer_ReturnsForbiddenAndDoesNotAdd` | | |
| 116 | +| **IDOR negatiivne** | `WishlistServiceTests.GetByTripIdAsync_WhenUserIsNotParticipant_ReturnsEmptyList` | | |
| 117 | +| **IDOR DAL-tasandil** | `TripParticipantRepositoryTests.IsParticipantAsync_WhenUserHasLeft_ReturnsFalse` (IsActive=false) | | |
| 118 | +| **Edge case** | `ExpenseServiceTests.CreateExpenseWithSplitsAsync_EqualAll_WhenNoParticipants_CreatesExpenseButNoSplits` | | |
| 119 | +| **Edge case** | `SettlementServiceTests.CalculateBalancesAsync_WhenNoExpenses_ReturnsZeroBalanceForEachParticipant` | | |
| 120 | +| **Edge case** | `TripValidationTests.Validate_WhenEndDateEqualsStartDate_YieldsNoErrors` (sama päev OK) | | |
| 121 | +| **Edge case** | `CurrencyConverterTests.Convert_UnknownCurrency_FallsBackToOneToOne` | | |
| 122 | +| **Sad path** | `SettlementServiceTests.CalculateBalancesAsync_WhenTripDoesNotExist_ReturnsEmptyList` | | |
| 123 | +| **Sad path** | `TripInvitationRepositoryTests.GetByTokenAsync_WhenTokenDoesNotExist_ReturnsNull` | | |
| 124 | + | |
| 125 | +--- | |
| 126 | + | |
| 127 | +## Run commands | |
| 128 | + | |
| 129 | +```bash | |
| 130 | +cd SplitApp | |
| 131 | + | |
| 132 | +# Kõik testid | |
| 133 | +dotnet test | |
| 134 | + | |
| 135 | +# Kihtide kaupa | |
| 136 | +dotnet test --filter "FullyQualifiedName~Domain" # 8 | |
| 137 | +dotnet test --filter "FullyQualifiedName~DAL" # 9 | |
| 138 | +dotnet test --filter "FullyQualifiedName~BLL" # 13 | |
| 139 | +dotnet test --filter "FullyQualifiedName~Mappers" # 6 | |
| 140 | + | |
| 141 | +# Üks konkreetne klass | |
| 142 | +dotnet test --filter "FullyQualifiedName~TripServiceTests" | |
| 143 | + | |
| 144 | +# Watch mode (uuesti käivitub failimuutusel) | |
| 145 | +dotnet watch test --project SplitApp/App.Tests | |
| 146 | +``` | |
| 147 | + | |
| 148 | +--- | |
| 149 | + | |
| 150 | +## What is NOT tested (deliberate) | |
| 151 | + | |
| 152 | +- **HTTP/REST integration** (`WebApplicationFactory`) — JWT/HTTP testimine toimub frondist | |
| 153 | +- **`IdentityService`** — `UserManager<AppUser>` mock-imine on tülikas, vähene kasum | |
| 154 | +- **Admin-teenused** — CRUD passthrough, vähene kasum | |
| 155 | +- **EF Core ennast** — Microsoft-i kood, mitte sinu loogika | |
| 156 | +- **Scaffolded Identity Razor pages** — auto-generated | |
| 157 | +- **100% coverage** — loeng hoiatab selle eest, fookus äriloogikal | |
| 158 | + | |
| 159 | +--- | |
| 160 | + | |
| 161 | +## Defense-valmidus — õpetaja võimalikud küsimused | |
| 162 | + | |
| 163 | +| Küsimus | Vastus | | |
| 164 | +|---|---| | |
| 165 | +| **"Miks SQLite in-memory mitte EF InMemory provider?"** | Loeng eelistab seda — enforcer FK-d ja käitub nagu päris RDBMS | | |
| 166 | +| **"Miks Moq ainult interface-de jaoks?"** | DI muster — concrete klasside mock-imine on anti-pattern | | |
| 167 | +| **"Miks happy path + sad path mitte ainult happy?"** | Testimispüramiidi alus + IDOR on phase 2 nõue, mida tuleb tõestada | | |
| 168 | +| **"Miks `[Theory]` mitte ainult `[Fact]`?"** | Sama loogika, erinevad sisendid → DRY (lecture: parameterized tests) | | |
| 169 | +| **"Mis on AAA pattern?"** | Arrange (setup), Act (kutsu meetodit), Assert (kontrolli) — iga test järgib seda | | |
| 170 | +| **"Miks ei kasuta WebApplicationFactory?"** | HTTP-tasandi testid frondist; backend kontrollib äriloogikat unit-tasemel | | |
| 171 | +| **"Mis on round-trip test?"** | DTO → entity → DTO peab andma sama tulemuse — kontrollib mapperite sümmeetriat | | |
| 172 | +| **"Miks BLL testid kasutavad `Verify(..., Times.Never)`?"** | IDOR-i tõestamiseks — kui kasutajal pole õigust, ei tohi DB-sse Add-i kutsuda | | |
| 173 | + | |
| 174 | +--- | |
| 175 | + | |
| 176 | +## CI/CD note (deferred) | |
| 177 | + | |
| 178 | +`.gitlab-ci.yml` ei muudeta. Kui hiljem otsustad VPS-i ressursi anda: | |
| 179 | + | |
| 180 | +```yaml | |
| 181 | +test: | |
| 182 | + stage: test | |
| 183 | + image: mcr.microsoft.com/dotnet/sdk:10.0 | |
| 184 | + script: | |
| 185 | + - cd SplitApp && dotnet test --no-restore | |
| 186 | +``` | |
| 187 | + | |
| 188 | +Aga see on eraldi otsus, mitte selle töö osa. |