profileShare

rasmusjy / splitapp-backend-clean-onion

Read-only snapshot

No repository description.

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

Commit

Initial commit: SplitApp REST API, Clean/Onion architecture

commit 453a840

429 changed files with +39269 and −0

Jump to a changed file
  1. .dockerignore +20 −0
  2. .env.example +13 −0
  3. .github/workflows/deploy.yml +53 −0
  4. .gitignore +88 −0
  5. .gitlab-ci.yml +11 −0
  6. DEPLOY.md +139 −0
  7. Dockerfile +28 −0
  8. LICENSE +21 −0
  9. README.md +310 −0
  10. SplitApp/App.BLL/App.BLL.csproj +20 −0
  11. SplitApp/App.BLL/DTO/AppUserBllDto.cs +11 −0
  12. SplitApp/App.BLL/DTO/BalanceBllDto.cs +10 −0
  13. SplitApp/App.BLL/DTO/BudgetCategoryBllDto.cs +21 −0
  14. SplitApp/App.BLL/DTO/CurrencyBllDto.cs +14 −0
  15. SplitApp/App.BLL/DTO/ExpenseBllDto.cs +33 −0
  16. SplitApp/App.BLL/DTO/ExpenseSplitBllDto.cs +18 −0
  17. SplitApp/App.BLL/DTO/SettlementPaymentBllDto.cs +26 −0
  18. SplitApp/App.BLL/DTO/SettlementPlanBllDto.cs +25 −0
  19. SplitApp/App.BLL/DTO/SplitPresetBllDto.cs +37 −0
  20. SplitApp/App.BLL/DTO/TripBllDto.cs +34 −0
  21. SplitApp/App.BLL/DTO/TripInvitationBllDto.cs +25 −0
  22. SplitApp/App.BLL/DTO/TripParticipantBllDto.cs +25 −0
  23. SplitApp/App.BLL/DTO/TripPollBllDto.cs +25 −0
  24. SplitApp/App.BLL/DTO/TripPollOptionBllDto.cs +17 −0
  25. SplitApp/App.BLL/DTO/TripWishlistItemBllDto.cs +33 −0
  26. SplitApp/App.BLL/Helpers/CurrencyConverter.cs +30 −0
  27. SplitApp/App.BLL/Mappers/BudgetCategoryBllDtoFactory.cs +35 −0
  28. SplitApp/App.BLL/Mappers/CurrencyBllDtoFactory.cs +28 −0
  29. SplitApp/App.BLL/Mappers/ExpenseBllDtoFactory.cs +80 −0
  30. SplitApp/App.BLL/Mappers/InvitationBllDtoFactory.cs +42 −0
  31. SplitApp/App.BLL/Mappers/PollBllDtoFactory.cs +75 −0
  32. SplitApp/App.BLL/Mappers/SettlementBllDtoFactory.cs +89 −0
  33. SplitApp/App.BLL/Mappers/SplitPresetBllDtoFactory.cs +67 −0
  34. SplitApp/App.BLL/Mappers/TripBllDtoFactory.cs +105 −0
  35. SplitApp/App.BLL/Mappers/WishlistBllDtoFactory.cs +56 −0
  36. SplitApp/App.BLL/Services/Admin/AdminDashboardData.cs +66 −0
  37. SplitApp/App.BLL/Services/Admin/AdminStatsService.cs +199 −0
  38. SplitApp/App.BLL/Services/Admin/BudgetCategoryAdminService.cs +76 −0
  39. SplitApp/App.BLL/Services/Admin/CurrencyAdminService.cs +68 −0
  40. SplitApp/App.BLL/Services/Admin/ExpenseAdminService.cs +95 −0
  41. SplitApp/App.BLL/Services/Admin/IAdminStatsService.cs +6 −0
  42. SplitApp/App.BLL/Services/Admin/IBudgetCategoryAdminService.cs +14 −0
  43. SplitApp/App.BLL/Services/Admin/ICurrencyAdminService.cs +13 −0
  44. SplitApp/App.BLL/Services/Admin/IExpenseAdminService.cs +18 −0
  45. SplitApp/App.BLL/Services/Admin/IInvitationAdminService.cs +15 −0
  46. SplitApp/App.BLL/Services/Admin/IPollAdminService.cs +16 −0
  47. SplitApp/App.BLL/Services/Admin/ISettlementPaymentAdminService.cs +15 −0
  48. SplitApp/App.BLL/Services/Admin/ISettlementPlanAdminService.cs +16 −0
  49. SplitApp/App.BLL/Services/Admin/ISplitPresetAdminService.cs +14 −0
  50. SplitApp/App.BLL/Services/Admin/ITripAdminService.cs +14 −0
  51. SplitApp/App.BLL/Services/Admin/ITripParticipantAdminService.cs +16 −0
  52. SplitApp/App.BLL/Services/Admin/IWishlistAdminService.cs +16 −0
  53. SplitApp/App.BLL/Services/Admin/InvitationAdminService.cs +77 −0
  54. SplitApp/App.BLL/Services/Admin/PollAdminService.cs +77 −0
  55. SplitApp/App.BLL/Services/Admin/SettlementPaymentAdminService.cs +78 −0
  56. SplitApp/App.BLL/Services/Admin/SettlementPlanAdminService.cs +76 −0
  57. SplitApp/App.BLL/Services/Admin/SplitPresetAdminService.cs +61 −0
  58. SplitApp/App.BLL/Services/Admin/TripAdminService.cs +67 −0
  59. SplitApp/App.BLL/Services/Admin/TripParticipantAdminService.cs +86 −0
  60. SplitApp/App.BLL/Services/Admin/WishlistAdminService.cs +83 −0
  61. SplitApp/App.BLL/Services/BudgetCategoryService.cs +80 −0
  62. SplitApp/App.BLL/Services/ExpenseService.cs +344 −0
  63. SplitApp/App.BLL/Services/IBudgetCategoryService.cs +14 −0
  64. SplitApp/App.BLL/Services/IExpenseService.cs +40 −0
  65. SplitApp/App.BLL/Services/IInvitationService.cs +26 −0
  66. SplitApp/App.BLL/Services/IPollService.cs +27 −0
  67. SplitApp/App.BLL/Services/ISettlementService.cs +42 −0
  68. SplitApp/App.BLL/Services/ISplitPresetService.cs +16 −0
  69. SplitApp/App.BLL/Services/ITripService.cs +38 −0
  70. SplitApp/App.BLL/Services/IWishlistService.cs +16 −0
  71. SplitApp/App.BLL/Services/Identity/IIdentityService.cs +9 −0
  72. SplitApp/App.BLL/Services/Identity/IdentityService.cs +266 −0
  73. SplitApp/App.BLL/Services/Identity/IdentityServiceResult.cs +63 −0
  74. SplitApp/App.BLL/Services/InvitationService.cs +189 −0
  75. SplitApp/App.BLL/Services/PollService.cs +203 −0
  76. SplitApp/App.BLL/Services/SettlementService.cs +331 −0
  77. SplitApp/App.BLL/Services/SplitPresetService.cs +120 −0
  78. SplitApp/App.BLL/Services/TripService.cs +236 −0
  79. SplitApp/App.BLL/Services/WishlistService.cs +145 −0
  80. SplitApp/App.DAL.EF/App.DAL.EF.csproj +20 −0
  81. SplitApp/App.DAL.EF/AppDbContext.cs +127 −0
  82. SplitApp/App.DAL.EF/AppUnitOfWork.cs +46 −0
  83. SplitApp/App.DAL.EF/Migrations/20260328145416_Initial.Designer.cs +1267 −0
  84. SplitApp/App.DAL.EF/Migrations/20260328145416_Initial.cs +961 −0
  85. SplitApp/App.DAL.EF/Migrations/20260328161224_AddBaseEntityTimestamps.Designer.cs +1375 −0
  86. SplitApp/App.DAL.EF/Migrations/20260328161224_AddBaseEntityTimestamps.cs +415 −0
  87. SplitApp/App.DAL.EF/Migrations/20260329141138_CurrencyNameToLangStr.Designer.cs +1375 −0
  88. SplitApp/App.DAL.EF/Migrations/20260329141138_CurrencyNameToLangStr.cs +38 −0
  89. SplitApp/App.DAL.EF/Migrations/20260402104505_RemoveUnusedBudgetCategoryTranslations.Designer.cs +1330 −0
  90. SplitApp/App.DAL.EF/Migrations/20260402104505_RemoveUnusedBudgetCategoryTranslations.cs +49 −0
  91. SplitApp/App.DAL.EF/Migrations/20260410202112_BudgetCategoryNameToLangStr.Designer.cs +1330 −0
  92. SplitApp/App.DAL.EF/Migrations/20260410202112_BudgetCategoryNameToLangStr.cs +38 −0
  93. SplitApp/App.DAL.EF/Migrations/AppDbContextModelSnapshot.cs +1327 −0
  94. SplitApp/App.DAL.EF/Repositories/BaseRepository.cs +28 −0
  95. SplitApp/App.DAL.EF/Repositories/BudgetCategoryRepository.cs +36 −0
  96. SplitApp/App.DAL.EF/Repositories/ExpenseRepository.cs +55 −0
  97. SplitApp/App.DAL.EF/Repositories/RefreshTokenRepository.cs +42 −0
  98. SplitApp/App.DAL.EF/Repositories/SettlementPaymentRepository.cs +30 −0
  99. SplitApp/App.DAL.EF/Repositories/SettlementPlanRepository.cs +55 −0
  100. SplitApp/App.DAL.EF/Repositories/SplitPresetRepository.cs +43 −0
  101. SplitApp/App.DAL.EF/Repositories/TripInvitationRepository.cs +42 −0
  102. SplitApp/App.DAL.EF/Repositories/TripParticipantRepository.cs +50 −0
  103. SplitApp/App.DAL.EF/Repositories/TripPollRepository.cs +52 −0
  104. SplitApp/App.DAL.EF/Repositories/TripRepository.cs +153 −0
  105. SplitApp/App.DAL.EF/Repositories/TripWishlistItemRepository.cs +40 −0
  106. SplitApp/App.DAL.EF/Repositories/UserRepository.cs +32 −0
  107. SplitApp/App.DAL.EF/Seeding/AppDataInit.cs +642 −0
  108. SplitApp/App.DAL.EF/Seeding/InitialData.cs +65 −0
  109. SplitApp/App.DAL.EF/ServiceCollectionExtensions.cs +33 −0
  110. SplitApp/App.DAL.EF/UtcDateTimeConverter.cs +14 −0
  111. SplitApp/App.DTO/App.DTO.csproj +14 −0
  112. SplitApp/App.DTO/Mappers/BudgetCategoryMapper.cs +21 −0
  113. SplitApp/App.DTO/Mappers/CurrencyMapper.cs +18 −0
  114. SplitApp/App.DTO/Mappers/ExpenseMapper.cs +35 −0
  115. SplitApp/App.DTO/Mappers/InvitationMapper.cs +21 −0
  116. SplitApp/App.DTO/Mappers/PollMapper.cs +29 −0
  117. SplitApp/App.DTO/Mappers/SettlementMapper.cs +36 −0
  118. SplitApp/App.DTO/Mappers/SplitPresetMapper.cs +27 −0
  119. SplitApp/App.DTO/Mappers/TripMapper.cs +52 −0
  120. SplitApp/App.DTO/Mappers/WishlistMapper.cs +29 −0
  121. SplitApp/App.DTO/v1/BalanceDto.cs +8 −0
  122. SplitApp/App.DTO/v1/BudgetCategoryCreateDto.cs +10 −0
  123. SplitApp/App.DTO/v1/BudgetCategoryDto.cs +12 −0
  124. SplitApp/App.DTO/v1/CurrencyDto.cs +9 −0
  125. SplitApp/App.DTO/v1/ExpenseCreateDto.cs +14 −0
  126. SplitApp/App.DTO/v1/ExpenseDto.cs +20 −0
  127. SplitApp/App.DTO/v1/ExpenseSplitCreateDto.cs +8 −0
  128. SplitApp/App.DTO/v1/ExpenseSplitDto.cs +10 −0
  129. SplitApp/App.DTO/v1/Identity/JWTResponse.cs +9 −0
  130. SplitApp/App.DTO/v1/Identity/LoginInfo.cs +7 −0
  131. SplitApp/App.DTO/v1/Identity/LogoutInfo.cs +6 −0
  132. SplitApp/App.DTO/v1/Identity/RegisterInfo.cs +9 −0
  133. SplitApp/App.DTO/v1/Identity/TokenRefreshInfo.cs +7 −0
  134. SplitApp/App.DTO/v1/InvitationCreateDto.cs +6 −0
  135. SplitApp/App.DTO/v1/InvitationDto.cs +12 −0
  136. SplitApp/App.DTO/v1/PollCreateDto.cs +10 −0
  137. SplitApp/App.DTO/v1/PollDto.cs +13 −0
  138. SplitApp/App.DTO/v1/PollOptionDto.cs +10 −0
  139. SplitApp/App.DTO/v1/RestApiErrorResponse.cs +9 −0
  140. SplitApp/App.DTO/v1/SettlementPaymentDto.cs +14 −0
  141. SplitApp/App.DTO/v1/SettlementPlanDto.cs +11 −0
  142. SplitApp/App.DTO/v1/SettlementSummaryDto.cs +7 −0
  143. SplitApp/App.DTO/v1/SplitPresetCreateDto.cs +16 −0
  144. SplitApp/App.DTO/v1/SplitPresetDto.cs +11 −0
  145. SplitApp/App.DTO/v1/SplitPresetMemberDto.cs +10 −0
  146. SplitApp/App.DTO/v1/TripCreateDto.cs +11 −0
  147. SplitApp/App.DTO/v1/TripDto.cs +18 −0
  148. SplitApp/App.DTO/v1/TripParticipantDto.cs +14 −0
  149. SplitApp/App.DTO/v1/TripUpdateDto.cs +13 −0
  150. SplitApp/App.DTO/v1/WishlistItemCreateDto.cs +13 −0
  151. SplitApp/App.DTO/v1/WishlistItemDto.cs +20 −0
  152. SplitApp/App.Domain/App.Domain.csproj +19 −0
  153. SplitApp/App.Domain/BudgetCategory.cs +25 −0
  154. SplitApp/App.Domain/Contracts/IAppUnitOfWork.cs +20 −0
  155. SplitApp/App.Domain/Contracts/IBudgetCategoryRepository.cs +8 −0
  156. SplitApp/App.Domain/Contracts/IExpenseRepository.cs +9 −0
  157. SplitApp/App.Domain/Contracts/IRefreshTokenRepository.cs +12 −0
  158. SplitApp/App.Domain/Contracts/ISettlementPaymentRepository.cs +7 −0
  159. SplitApp/App.Domain/Contracts/ISettlementPlanRepository.cs +14 −0
  160. SplitApp/App.Domain/Contracts/ISplitPresetRepository.cs +8 −0
  161. SplitApp/App.Domain/Contracts/ITripInvitationRepository.cs +9 −0
  162. SplitApp/App.Domain/Contracts/ITripParticipantRepository.cs +10 −0
  163. SplitApp/App.Domain/Contracts/ITripPollRepository.cs +9 −0
  164. SplitApp/App.Domain/Contracts/ITripRepository.cs +9 −0
  165. SplitApp/App.Domain/Contracts/ITripWishlistItemRepository.cs +8 −0
  166. SplitApp/App.Domain/Contracts/IUserRepository.cs +11 −0
  167. SplitApp/App.Domain/Currency.cs +19 −0
  168. SplitApp/App.Domain/EInvitationStatus.cs +10 −0
  169. SplitApp/App.Domain/EParticipantRole.cs +7 −0
  170. SplitApp/App.Domain/EPaymentStatus.cs +8 −0
  171. SplitApp/App.Domain/ESettlementStatus.cs +8 −0
  172. SplitApp/App.Domain/ESplitMethod.cs +9 −0
  173. SplitApp/App.Domain/ETripStatus.cs +9 −0
  174. SplitApp/App.Domain/EWishlistCategory.cs +9 −0
  175. SplitApp/App.Domain/EWishlistPriority.cs +8 −0
  176. SplitApp/App.Domain/Expense.cs +35 −0
  177. SplitApp/App.Domain/ExpenseSplit.cs +16 −0
  178. SplitApp/App.Domain/Identity/AppRefreshToken.cs +20 −0
  179. SplitApp/App.Domain/Identity/AppRole.cs +8 −0
  180. SplitApp/App.Domain/Identity/AppUser.cs +17 −0
  181. SplitApp/App.Domain/SettlementPayment.cs +29 −0
  182. SplitApp/App.Domain/SettlementPlan.cs +25 −0
  183. SplitApp/App.Domain/SplitPreset.cs +21 −0
  184. SplitApp/App.Domain/SplitPresetMember.cs +16 −0
  185. SplitApp/App.Domain/Trip.cs +54 −0
  186. SplitApp/App.Domain/TripInvitation.cs +22 −0
  187. SplitApp/App.Domain/TripParticipant.cs +30 −0
  188. SplitApp/App.Domain/TripPoll.cs +29 −0
  189. SplitApp/App.Domain/TripPollOption.cs +19 −0
  190. SplitApp/App.Domain/TripPollVote.cs +13 −0
  191. SplitApp/App.Domain/TripWishlistItem.cs +47 −0
  192. SplitApp/App.Domain/TripWishlistVote.cs +15 −0
  193. SplitApp/App.Resources/App.Resources.csproj +132 −0
  194. SplitApp/App.Resources/Common.Designer.cs +72 −0
  195. SplitApp/App.Resources/Common.et.resx +46 −0
  196. SplitApp/App.Resources/Common.resx +46 −0
  197. SplitApp/App.Resources/Domain/BudgetCategory.Designer.cs +72 −0
  198. SplitApp/App.Resources/Domain/BudgetCategory.et.resx +46 −0
  199. SplitApp/App.Resources/Domain/BudgetCategory.resx +46 −0
  200. SplitApp/App.Resources/Domain/Currency.Designer.cs +66 −0
  201. SplitApp/App.Resources/Domain/Currency.et.resx +45 −0
  202. SplitApp/App.Resources/Domain/Currency.resx +45 −0
  203. SplitApp/App.Resources/Domain/Enums.Designer.cs +216 −0
  204. SplitApp/App.Resources/Domain/Enums.et.resx +70 −0
  205. SplitApp/App.Resources/Domain/Enums.resx +70 −0
  206. SplitApp/App.Resources/Domain/Expense.Designer.cs +90 −0
  207. SplitApp/App.Resources/Domain/Expense.et.resx +49 −0
  208. SplitApp/App.Resources/Domain/Expense.resx +49 −0
  209. SplitApp/App.Resources/Domain/SettlementPayment.Designer.cs +72 −0
  210. SplitApp/App.Resources/Domain/SettlementPayment.et.resx +46 −0
  211. SplitApp/App.Resources/Domain/SettlementPayment.resx +46 −0
  212. SplitApp/App.Resources/Domain/SettlementPlan.Designer.cs +66 −0
  213. SplitApp/App.Resources/Domain/SettlementPlan.et.resx +45 −0
  214. SplitApp/App.Resources/Domain/SettlementPlan.resx +45 −0
  215. SplitApp/App.Resources/Domain/Trip.Designer.cs +96 −0
  216. SplitApp/App.Resources/Domain/Trip.et.resx +50 −0
  217. SplitApp/App.Resources/Domain/Trip.resx +50 −0
  218. SplitApp/App.Resources/Domain/TripParticipant.Designer.cs +78 −0
  219. SplitApp/App.Resources/Domain/TripParticipant.et.resx +47 −0
  220. SplitApp/App.Resources/Domain/TripParticipant.resx +47 −0
  221. SplitApp/App.Resources/Domain/TripPoll.Designer.cs +72 −0
  222. SplitApp/App.Resources/Domain/TripPoll.et.resx +46 −0
  223. SplitApp/App.Resources/Domain/TripPoll.resx +46 −0
  224. SplitApp/App.Resources/Domain/TripPollOption.Designer.cs +60 −0
  225. SplitApp/App.Resources/Domain/TripPollOption.et.resx +44 −0
  226. SplitApp/App.Resources/Domain/TripPollOption.resx +44 −0
  227. SplitApp/App.Resources/Domain/TripWishlistItem.Designer.cs +102 −0
  228. SplitApp/App.Resources/Domain/TripWishlistItem.et.resx +51 −0
  229. SplitApp/App.Resources/Domain/TripWishlistItem.resx +51 −0
  230. SplitApp/App.Resources/Views/Shared.Designer.cs +240 −0
  231. SplitApp/App.Resources/Views/Shared.et.resx +516 −0
  232. SplitApp/App.Resources/Views/Shared.resx +516 −0
  233. SplitApp/App.Tests/App.Tests.csproj +32 −0
  234. SplitApp/App.Tests/BLL/BudgetCategoryServiceTests.cs +75 −0
  235. SplitApp/App.Tests/BLL/CurrencyConverterTests.cs +31 −0
  236. SplitApp/App.Tests/BLL/ExpenseServiceTests.cs +84 −0
  237. SplitApp/App.Tests/BLL/SettlementServiceTests.cs +70 −0
  238. SplitApp/App.Tests/BLL/TripServiceTests.cs +111 −0
  239. SplitApp/App.Tests/BLL/WishlistServiceTests.cs +72 −0
  240. SplitApp/App.Tests/DAL/BudgetCategoryRepositoryTests.cs +31 −0
  241. SplitApp/App.Tests/DAL/ExpenseRepositoryTests.cs +36 −0
  242. SplitApp/App.Tests/DAL/TripInvitationRepositoryTests.cs +44 −0
  243. SplitApp/App.Tests/DAL/TripParticipantRepositoryTests.cs +56 −0
  244. SplitApp/App.Tests/DAL/TripRepositoryTests.cs +41 −0
  245. SplitApp/App.Tests/Domain/LangStrTests.cs +58 −0
  246. SplitApp/App.Tests/Domain/TripValidationTests.cs +46 −0
  247. SplitApp/App.Tests/E2E/MvcPagesE2ETests.cs +52 −0
  248. SplitApp/App.Tests/GlobalTestInit.cs +24 −0
  249. SplitApp/App.Tests/Integration/AccountApiIntegrationTests.cs +111 −0
  250. SplitApp/App.Tests/Integration/WebApiTestFactory.cs +100 −0
  251. SplitApp/App.Tests/Mappers/BudgetCategoryBllDtoFactoryTests.cs +56 −0
  252. SplitApp/App.Tests/Mappers/CurrencyBllDtoFactoryTests.cs +33 −0
  253. SplitApp/App.Tests/Mappers/ExpenseBllDtoFactoryTests.cs +37 −0
  254. SplitApp/App.Tests/Mappers/TripBllDtoFactoryTests.cs +65 −0
  255. SplitApp/App.Tests/RepositoryTestBase.cs +71 −0
  256. SplitApp/App.Tests/SanityTest.cs +12 −0
  257. SplitApp/Base.Contracts/Base.Contracts.csproj +9 −0
  258. SplitApp/Base.Contracts/IBaseEntity.cs +6 −0
  259. SplitApp/Base.Contracts/IBaseRepository.cs +11 −0
  260. SplitApp/Base.Contracts/IUnitOfWork.cs +6 −0
  261. SplitApp/Base.Domain/Base.Domain.csproj +13 −0
  262. SplitApp/Base.Domain/BaseEntity.cs +10 −0
  263. SplitApp/Base.Domain/LangStr.cs +80 −0
  264. SplitApp/Base.Helpers/Base.Helpers.csproj +13 −0
  265. SplitApp/Base.Helpers/IdentityHelpers.cs +59 −0
  266. SplitApp/Directory.Build.Props +7 −0
  267. SplitApp/SplitApp.sln +157 −0
  268. SplitApp/WebApp/ApiControllers/BudgetCategoriesController.cs +131 −0
  269. SplitApp/WebApp/ApiControllers/CurrenciesController.cs +35 −0
  270. SplitApp/WebApp/ApiControllers/ExpensesController.cs +187 −0
  271. SplitApp/WebApp/ApiControllers/Identity/AccountController.cs +145 −0
  272. SplitApp/WebApp/ApiControllers/InvitationsController.cs +137 −0
  273. SplitApp/WebApp/ApiControllers/PollsController.cs +181 −0
  274. SplitApp/WebApp/ApiControllers/SettlementsController.cs +153 −0
  275. SplitApp/WebApp/ApiControllers/SplitPresetsController.cs +144 −0
  276. SplitApp/WebApp/ApiControllers/TripsController.cs +243 −0
  277. SplitApp/WebApp/ApiControllers/WishlistController.cs +188 −0
  278. SplitApp/WebApp/Areas/Admin/Controllers/BudgetCategoriesController.cs +143 −0
  279. SplitApp/WebApp/Areas/Admin/Controllers/CurrenciesController.cs +130 −0
  280. SplitApp/WebApp/Areas/Admin/Controllers/DashboardController.cs +88 −0
  281. SplitApp/WebApp/Areas/Admin/Controllers/ExpensesController.cs +145 −0
  282. SplitApp/WebApp/Areas/Admin/Controllers/InvitationsController.cs +154 −0
  283. SplitApp/WebApp/Areas/Admin/Controllers/PollsController.cs +125 −0
  284. SplitApp/WebApp/Areas/Admin/Controllers/SettlementPaymentsController.cs +149 −0
  285. SplitApp/WebApp/Areas/Admin/Controllers/SettlementPlansController.cs +183 −0
  286. SplitApp/WebApp/Areas/Admin/Controllers/SplitPresetsController.cs +111 −0
  287. SplitApp/WebApp/Areas/Admin/Controllers/TripParticipantsController.cs +184 −0
  288. SplitApp/WebApp/Areas/Admin/Controllers/TripsController.cs +136 −0
  289. SplitApp/WebApp/Areas/Admin/Controllers/UsersController.cs +197 −0
  290. SplitApp/WebApp/Areas/Admin/Controllers/WishlistController.cs +125 −0
  291. SplitApp/WebApp/Areas/Admin/Models/AdminViewModels.cs +287 −0
  292. SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Create.cshtml +55 −0
  293. SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Delete.cshtml +26 −0
  294. SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Details.cshtml +29 −0
  295. SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Edit.cshtml +56 −0
  296. SplitApp/WebApp/Areas/Admin/Views/BudgetCategories/Index.cshtml +78 −0
  297. SplitApp/WebApp/Areas/Admin/Views/Currencies/Create.cshtml +45 −0
  298. SplitApp/WebApp/Areas/Admin/Views/Currencies/Delete.cshtml +26 −0
  299. SplitApp/WebApp/Areas/Admin/Views/Currencies/Details.cshtml +23 −0
  300. SplitApp/WebApp/Areas/Admin/Views/Currencies/Edit.cshtml +46 −0
  301. SplitApp/WebApp/Areas/Admin/Views/Currencies/Index.cshtml +67 −0
  302. SplitApp/WebApp/Areas/Admin/Views/Dashboard/Index.cshtml +373 −0
  303. SplitApp/WebApp/Areas/Admin/Views/Expenses/Create.cshtml +71 −0
  304. SplitApp/WebApp/Areas/Admin/Views/Expenses/Delete.cshtml +32 −0
  305. SplitApp/WebApp/Areas/Admin/Views/Expenses/Details.cshtml +38 −0
  306. SplitApp/WebApp/Areas/Admin/Views/Expenses/Edit.cshtml +72 −0
  307. SplitApp/WebApp/Areas/Admin/Views/Expenses/Index.cshtml +82 −0
  308. SplitApp/WebApp/Areas/Admin/Views/Invitations/Create.cshtml +58 −0
  309. SplitApp/WebApp/Areas/Admin/Views/Invitations/Delete.cshtml +40 −0
  310. SplitApp/WebApp/Areas/Admin/Views/Invitations/Details.cshtml +41 −0
  311. SplitApp/WebApp/Areas/Admin/Views/Invitations/Edit.cshtml +59 −0
  312. SplitApp/WebApp/Areas/Admin/Views/Invitations/Index.cshtml +67 −0
  313. SplitApp/WebApp/Areas/Admin/Views/Polls/Create.cshtml +48 −0
  314. SplitApp/WebApp/Areas/Admin/Views/Polls/Delete.cshtml +14 −0
  315. SplitApp/WebApp/Areas/Admin/Views/Polls/Details.cshtml +38 −0
  316. SplitApp/WebApp/Areas/Admin/Views/Polls/Edit.cshtml +45 −0
  317. SplitApp/WebApp/Areas/Admin/Views/Polls/Index.cshtml +75 −0
  318. SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Create.cshtml +55 −0
  319. SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Delete.cshtml +40 −0
  320. SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Details.cshtml +44 −0
  321. SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Edit.cshtml +56 −0
  322. SplitApp/WebApp/Areas/Admin/Views/SettlementPayments/Index.cshtml +69 −0
  323. SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Create.cshtml +52 −0
  324. SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Delete.cshtml +29 −0
  325. SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Details.cshtml +29 −0
  326. SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Edit.cshtml +53 −0
  327. SplitApp/WebApp/Areas/Admin/Views/SettlementPlans/Index.cshtml +75 −0
  328. SplitApp/WebApp/Areas/Admin/Views/Shared/_Layout.cshtml +128 −0
  329. SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Create.cshtml +52 −0
  330. SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Delete.cshtml +29 −0
  331. SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Details.cshtml +51 −0
  332. SplitApp/WebApp/Areas/Admin/Views/SplitPresets/Index.cshtml +66 −0
  333. SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Create.cshtml +53 −0
  334. SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Delete.cshtml +29 −0
  335. SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Details.cshtml +35 −0
  336. SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Edit.cshtml +54 −0
  337. SplitApp/WebApp/Areas/Admin/Views/TripParticipants/Index.cshtml +80 −0
  338. SplitApp/WebApp/Areas/Admin/Views/Trips/Create.cshtml +62 −0
  339. SplitApp/WebApp/Areas/Admin/Views/Trips/Delete.cshtml +29 −0
  340. SplitApp/WebApp/Areas/Admin/Views/Trips/Details.cshtml +38 −0
  341. SplitApp/WebApp/Areas/Admin/Views/Trips/Edit.cshtml +64 −0
  342. SplitApp/WebApp/Areas/Admin/Views/Trips/Index.cshtml +71 −0
  343. SplitApp/WebApp/Areas/Admin/Views/Users/Delete.cshtml +44 −0
  344. SplitApp/WebApp/Areas/Admin/Views/Users/Details.cshtml +44 −0
  345. SplitApp/WebApp/Areas/Admin/Views/Users/Edit.cshtml +44 −0
  346. SplitApp/WebApp/Areas/Admin/Views/Users/EditRoles.cshtml +29 −0
  347. SplitApp/WebApp/Areas/Admin/Views/Users/Index.cshtml +46 −0
  348. SplitApp/WebApp/Areas/Admin/Views/Wishlist/Create.cshtml +64 −0
  349. SplitApp/WebApp/Areas/Admin/Views/Wishlist/Delete.cshtml +14 −0
  350. SplitApp/WebApp/Areas/Admin/Views/Wishlist/Details.cshtml +36 −0
  351. SplitApp/WebApp/Areas/Admin/Views/Wishlist/Edit.cshtml +57 −0
  352. SplitApp/WebApp/Areas/Admin/Views/Wishlist/Index.cshtml +73 −0
  353. SplitApp/WebApp/Areas/Admin/Views/_ViewImports.cshtml +8 −0
  354. SplitApp/WebApp/Areas/Admin/Views/_ViewStart.cshtml +3 −0
  355. SplitApp/WebApp/Areas/Identity/Pages/Account/Register.cshtml +59 −0
  356. SplitApp/WebApp/Areas/Identity/Pages/Account/Register.cshtml.cs +93 −0
  357. SplitApp/WebApp/Areas/Identity/Pages/_ViewImports.cshtml +5 −0
  358. SplitApp/WebApp/Areas/Identity/Pages/_ViewStart.cshtml +3 −0
  359. SplitApp/WebApp/ConfigureSwaggerOptions.cs +64 −0
  360. SplitApp/WebApp/Controllers/BudgetController.cs +208 −0
  361. SplitApp/WebApp/Controllers/ExpensesController.cs +266 −0
  362. SplitApp/WebApp/Controllers/HomeController.cs +35 −0
  363. SplitApp/WebApp/Controllers/MembersController.cs +177 −0
  364. SplitApp/WebApp/Controllers/PollsClientController.cs +144 −0
  365. SplitApp/WebApp/Controllers/SettlementController.cs +190 −0
  366. SplitApp/WebApp/Controllers/TripsController.cs +258 −0
  367. SplitApp/WebApp/Controllers/WishlistClientController.cs +277 −0
  368. SplitApp/WebApp/Helpers/CurrencyConverter.cs +30 −0
  369. SplitApp/WebApp/Helpers/EnumHelper.cs +15 −0
  370. SplitApp/WebApp/InvariantDecimalModelBinderProvider.cs +56 −0
  371. SplitApp/WebApp/Models/ErrorViewModel.cs +8 −0
  372. SplitApp/WebApp/Program.cs +295 −0
  373. SplitApp/WebApp/Properties/launchSettings.json +23 −0
  374. SplitApp/WebApp/Views/Budget/CreateCategory.cshtml +71 −0
  375. SplitApp/WebApp/Views/Budget/DeleteCategory.cshtml +51 −0
  376. SplitApp/WebApp/Views/Budget/EditCategory.cshtml +72 −0
  377. SplitApp/WebApp/Views/Budget/Index.cshtml +155 −0
  378. SplitApp/WebApp/Views/Expenses/Create.cshtml +365 −0
  379. SplitApp/WebApp/Views/Expenses/Delete.cshtml +51 −0
  380. SplitApp/WebApp/Views/Expenses/Edit.cshtml +84 −0
  381. SplitApp/WebApp/Views/Expenses/Index.cshtml +111 −0
  382. SplitApp/WebApp/Views/Home/Index.cshtml +116 −0
  383. SplitApp/WebApp/Views/Home/Privacy.cshtml +6 −0
  384. SplitApp/WebApp/Views/Members/AcceptInvitation.cshtml +60 −0
  385. SplitApp/WebApp/Views/Members/Index.cshtml +125 −0
  386. SplitApp/WebApp/Views/Members/InvitationInvalid.cshtml +24 −0
  387. SplitApp/WebApp/Views/Members/Invite.cshtml +34 −0
  388. SplitApp/WebApp/Views/Members/InviteGenerated.cshtml +40 −0
  389. SplitApp/WebApp/Views/PollsClient/Create.cshtml +83 −0
  390. SplitApp/WebApp/Views/PollsClient/Details.cshtml +126 −0
  391. SplitApp/WebApp/Views/PollsClient/Index.cshtml +99 −0
  392. SplitApp/WebApp/Views/Settlement/Index.cshtml +232 −0
  393. SplitApp/WebApp/Views/Shared/Error.cshtml +29 −0
  394. SplitApp/WebApp/Views/Shared/_LanguageSelection.cshtml +24 −0
  395. SplitApp/WebApp/Views/Shared/_Layout.cshtml +116 −0
  396. SplitApp/WebApp/Views/Shared/_Layout.cshtml.css +48 −0
  397. SplitApp/WebApp/Views/Shared/_LoginPartial.cshtml +50 −0
  398. SplitApp/WebApp/Views/Shared/_ValidationScriptsPartial.cshtml +2 −0
  399. SplitApp/WebApp/Views/Trips/Create.cshtml +82 −0
  400. SplitApp/WebApp/Views/Trips/Delete.cshtml +56 −0
  401. SplitApp/WebApp/Views/Trips/Details.cshtml +291 −0
  402. SplitApp/WebApp/Views/Trips/Edit.cshtml +87 −0
  403. SplitApp/WebApp/Views/Trips/Index.cshtml +104 −0
  404. SplitApp/WebApp/Views/WishlistClient/Create.cshtml +85 −0
  405. SplitApp/WebApp/Views/WishlistClient/Delete.cshtml +45 −0
  406. SplitApp/WebApp/Views/WishlistClient/Edit.cshtml +77 −0
  407. SplitApp/WebApp/Views/WishlistClient/Index.cshtml +147 −0
  408. SplitApp/WebApp/Views/_ViewImports.cshtml +8 −0
  409. SplitApp/WebApp/Views/_ViewStart.cshtml +3 −0
  410. SplitApp/WebApp/WebApp.csproj +32 −0
  411. SplitApp/WebApp/appsettings.json +27 −0
  412. SplitApp/WebApp/wwwroot/css/admin.css +439 −0
  413. SplitApp/WebApp/wwwroot/css/site.css +31 −0
  414. SplitApp/WebApp/wwwroot/css/splitapp-design.css +1605 −0
  415. SplitApp/WebApp/wwwroot/favicon.ico +0 −0
  416. SplitApp/WebApp/wwwroot/js/site.js +4 −0
  417. SplitApp/WebApp/wwwroot/js/splitapp.js +323 −0
  418. SplitApp/WebApp/wwwroot/lib/bootstrap/LICENSE +22 −0
  419. SplitApp/WebApp/wwwroot/lib/jquery-validation-unobtrusive/LICENSE.txt +23 −0
  420. SplitApp/WebApp/wwwroot/lib/jquery-validation/LICENSE.md +22 −0
  421. SplitApp/WebApp/wwwroot/lib/jquery/LICENSE.txt +21 −0
  422. architecture.md +137 −0
  423. arhitektuur.md +181 −0
  424. docker-compose.prod.yml +57 −0
  425. docker-compose.yml +27 −0
  426. docs/Project_proposal_Rasmus_Jürgenson.pdf +0 −0
  427. docs/grouptravel.png +0 −0
  428. explanation.md +729 −0
  429. 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 &#252;letada {1} t&#228;hem&#228;rki.</value></data>
44 + <data name="ErrorStringLengthMinMax" xml:space="preserve"><value>{0} peab olema {2} kuni {1} t&#228;hem&#228;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&#228;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&#252;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&#245;rdselt (k&#245;ik)</value></data>
47 + <data name="ESplitMethod_EqualSubset" xml:space="preserve"><value>V&#245;rdselt (valik)</value></data>
48 + <data name="ESplitMethod_ExactAmounts" xml:space="preserve"><value>T&#228;psed summad</value></data>
49 + <data name="ESplitMethod_Percentages" xml:space="preserve"><value>Protsendid</value></data>
50 + <data name="EParticipantRole_Organizer" xml:space="preserve"><value>Korraldaja</value></data>
51 + <data name="EParticipantRole_Participant" xml:space="preserve"><value>Osaleja</value></data>
52 + <data name="EInvitationStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
53 + <data name="EInvitationStatus_Accepted" xml:space="preserve"><value>Vastu v&#245;etud</value></data>
54 + <data name="EInvitationStatus_Declined" xml:space="preserve"><value>Keeldutud</value></data>
55 + <data name="EInvitationStatus_Expired" xml:space="preserve"><value>Aegunud</value></data>
56 + <data name="EInvitationStatus_Revoked" xml:space="preserve"><value>T&#252;histatud</value></data>
57 + <data name="EPaymentStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
58 + <data name="EPaymentStatus_MarkedPaid" xml:space="preserve"><value>M&#228;rgitud makstuks</value></data>
59 + <data name="EPaymentStatus_Confirmed" xml:space="preserve"><value>Kinnitatud</value></data>
60 + <data name="ESettlementStatus_Pending" xml:space="preserve"><value>Ootel</value></data>
61 + <data name="ESettlementStatus_InProgress" xml:space="preserve"><value>Pooleli</value></data>
62 + <data name="ESettlementStatus_Completed" xml:space="preserve"><value>L&#245;petatud</value></data>
63 + <data name="EWishlistCategory_Place" xml:space="preserve"><value>Koht</value></data>
64 + <data name="EWishlistCategory_Activity" xml:space="preserve"><value>Tegevus</value></data>
65 + <data name="EWishlistCategory_Restaurant" xml:space="preserve"><value>Restoran</value></data>
66 + <data name="EWishlistCategory_Other" xml:space="preserve"><value>Muu</value></data>
67 + <data name="EWishlistPriority_MustDo" xml:space="preserve"><value>Kohustuslik</value></data>
68 + <data name="EWishlistPriority_NiceToHave" xml:space="preserve"><value>Soovituslik</value></data>
69 + <data name="EWishlistPriority_Optional" xml:space="preserve"><value>Valikuline</value></data>
70 +</root>
added SplitApp/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&#228;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&#245;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&#252;&#252;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&#252;simus</value></data>
43 + <data name="AllowMultipleVotes" xml:space="preserve"><value>Luba mitu h&#228;&#228;lt</value></data>
44 + <data name="IsAnonymous" xml:space="preserve"><value>Anonuumne h&#228;&#228;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&#228;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&#228;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&#252;hista</value></data>
46 + <data name="Delete" xml:space="preserve"><value>Kustuta</value></data>
47 + <data name="Edit" xml:space="preserve"><value>Muuda</value></data>
48 + <data name="Create" xml:space="preserve"><value>Loo</value></data>
49 + <data name="Back" xml:space="preserve"><value>Tagasi</value></data>
50 + <data name="Confirm" xml:space="preserve"><value>Kinnita</value></data>
51 + <data name="Search" xml:space="preserve"><value>Otsi</value></data>
52 + <data name="Actions" xml:space="preserve"><value>Tegevused</value></data>
53 + <data name="Loading" xml:space="preserve"><value>Laadimine...</value></data>
54 + <data name="NoData" xml:space="preserve"><value>Andmed puuduvad</value></data>
55 + <data name="LogIn" xml:space="preserve"><value>Logi sisse</value></data>
56 + <data name="LogOut" xml:space="preserve"><value>Logi v&#228;lja</value></data>
57 + <data name="Register" xml:space="preserve"><value>Registreeru</value></data>
58 + <data name="Trips" xml:space="preserve"><value>Reisid</value></data>
59 + <data name="Expenses" xml:space="preserve"><value>Kulud</value></data>
60 + <data name="Budget" xml:space="preserve"><value>Eelarve</value></data>
61 + <data name="Members" xml:space="preserve"><value>Liikmed</value></data>
62 + <data name="Wishlist" xml:space="preserve"><value>Soovinimekiri</value></data>
63 + <data name="Polls" xml:space="preserve"><value>K&#252;sitlused</value></data>
64 + <data name="Settlement" xml:space="preserve"><value>Arveldus</value></data>
65 + <data name="Categories" xml:space="preserve"><value>Kategooriad</value></data>
66 + <data name="Dashboard" xml:space="preserve"><value>T&#246;&#246;laud</value></data>
67 + <data name="AreYouSure" xml:space="preserve"><value>Kas oled kindel?</value></data>
68 + <data name="DeleteConfirm" xml:space="preserve"><value>Kas soovid kustutada?</value></data>
69 + <data name="Details" xml:space="preserve"><value>Detailid</value></data>
70 + <data name="Name" xml:space="preserve"><value>Nimi</value></data>
71 + <data name="Description" xml:space="preserve"><value>Kirjeldus</value></data>
72 + <data name="Amount" xml:space="preserve"><value>Summa</value></data>
73 + <data name="Date" xml:space="preserve"><value>Kuup&#228;ev</value></data>
74 + <data name="Status" xml:space="preserve"><value>Olek</value></data>
75 + <data name="Admin" xml:space="preserve"><value>Admin</value></data>
76 +
77 + <!-- ========== COMMON UI ========== -->
78 + <data name="Home" xml:space="preserve"><value>Avaleht</value></data>
79 + <data name="Hello" xml:space="preserve"><value>Tere</value></data>
80 + <data name="Welcome" xml:space="preserve"><value>Tere tulemast</value></data>
81 + <data name="Total" xml:space="preserve"><value>Kokku</value></data>
82 + <data name="Email" xml:space="preserve"><value>E-post</value></data>
83 + <data name="Joined" xml:space="preserve"><value>Liitunud</value></data>
84 + <data name="Role" xml:space="preserve"><value>Roll</value></data>
85 + <data name="Summary" xml:space="preserve"><value>Kokkuv&#245;te</value></data>
86 + <data name="Dates" xml:space="preserve"><value>Kuup&#228;evad</value></data>
87 + <data name="Currency" xml:space="preserve"><value>Valuuta</value></data>
88 + <data name="Currencies" xml:space="preserve"><value>Valuutad</value></data>
89 + <data name="Destination" xml:space="preserve"><value>Sihtkoht</value></data>
90 + <data name="Participants" xml:space="preserve"><value>Osalejad</value></data>
91 + <data name="Code" xml:space="preserve"><value>Kood</value></data>
92 + <data name="Symbol" xml:space="preserve"><value>S&#252;mbol</value></data>
93 + <data name="Select" xml:space="preserve"><value>Vali</value></data>
94 + <data name="Copy" xml:space="preserve"><value>Kopeeri</value></data>
95 + <data name="Split" xml:space="preserve"><value>Jaotus</value></data>
96 + <data name="Member" xml:space="preserve"><value>Liige</value></data>
97 + <data name="Title" xml:space="preserve"><value>Pealkiri</value></data>
98 + <data name="Question" xml:space="preserve"><value>K&#252;simus</value></data>
99 + <data name="Option" xml:space="preserve"><value>Valik</value></data>
100 + <data name="Options" xml:space="preserve"><value>Valikud</value></data>
101 + <data name="Priority" xml:space="preserve"><value>Prioriteet</value></data>
102 + <data name="Location" xml:space="preserve"><value>Asukoht</value></data>
103 + <data name="URL" xml:space="preserve"><value>URL</value></data>
104 + <data name="User" xml:space="preserve"><value>Kasutaja</value></data>
105 + <data name="Trip" xml:space="preserve"><value>Reis</value></data>
106 + <data name="Close" xml:space="preserve"><value>Sulge</value></data>
107 + <data name="Yes" xml:space="preserve"><value>Jah</value></data>
108 + <data name="No" xml:space="preserve"><value>Ei</value></data>
109 + <data name="Submit" xml:space="preserve"><value>Esita</value></data>
110 + <data name="Add" xml:space="preserve"><value>Lisa</value></data>
111 + <data name="Remove" xml:space="preserve"><value>Eemalda</value></data>
112 + <data name="Unknown" xml:space="preserve"><value>Tundmatu</value></data>
113 + <data name="or" xml:space="preserve"><value>v&#245;i</value></data>
114 + <data name="of" xml:space="preserve"><value>st</value></data>
115 + <data name="Category" xml:space="preserve"><value>Kategooria</value></data>
116 + <data name="Expires" xml:space="preserve"><value>Aegub</value></data>
117 + <data name="Nickname" xml:space="preserve"><value>H&#252;&#252;dnimi</value></data>
118 +
119 + <!-- ========== TRIP ========== -->
120 + <data name="My Trips" xml:space="preserve"><value>Minu reisid</value></data>
121 + <data name="Create Trip" xml:space="preserve"><value>Loo reis</value></data>
122 + <data name="Edit Trip" xml:space="preserve"><value>Muuda reisi</value></data>
123 + <data name="Delete Trip" xml:space="preserve"><value>Kustuta reis</value></data>
124 + <data name="Trip Information" xml:space="preserve"><value>Reisi info</value></data>
125 + <data name="Back to Trips" xml:space="preserve"><value>Tagasi reiside juurde</value></data>
126 + <data name="Back to Trip" xml:space="preserve"><value>Tagasi reisi juurde</value></data>
127 + <data name="No trips yet" xml:space="preserve"><value>Reisid puuduvad</value></data>
128 + <data name="Create your first trip to get started!" xml:space="preserve"><value>Loo oma esimene reis alustamiseks!</value></data>
129 + <data name="View Details" xml:space="preserve"><value>Vaata detaile</value></data>
130 + <data name="View All" xml:space="preserve"><value>Vaata k&#245;iki</value></data>
131 + <data name="Go to My Trips" xml:space="preserve"><value>Mine minu reiside juurde</value></data>
132 + <data name="Start Date" xml:space="preserve"><value>Alguskuup&#228;ev</value></data>
133 + <data name="End Date" xml:space="preserve"><value>L&#245;ppkuup&#228;ev</value></data>
134 + <data name="Default Currency" xml:space="preserve"><value>Vaikevaluuta</value></data>
135 + <data name="Select currency..." xml:space="preserve"><value>Vali valuuta...</value></data>
136 + <data name="Are you sure you want to delete this trip? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle reisi kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
137 +
138 + <!-- ========== EXPENSE ========== -->
139 + <data name="Add Expense" xml:space="preserve"><value>Lisa kulu</value></data>
140 + <data name="Edit Expense" xml:space="preserve"><value>Muuda kulu</value></data>
141 + <data name="Delete Expense" xml:space="preserve"><value>Kustuta kulu</value></data>
142 + <data name="Paid By" xml:space="preserve"><value>Maksis</value></data>
143 + <data name="Who Paid" xml:space="preserve"><value>Kes maksis</value></data>
144 + <data name="Split Method" xml:space="preserve"><value>Jagamismeetod</value></data>
145 + <data name="Select Participants" xml:space="preserve"><value>Vali osalejad</value></data>
146 + <data name="No category" xml:space="preserve"><value>Kategooriata</value></data>
147 + <data name="Trip default" xml:space="preserve"><value>Reisi vaikimisi</value></data>
148 + <data name="No expenses yet" xml:space="preserve"><value>Kulusid pole veel</value></data>
149 + <data name="Add your first expense to start tracking." xml:space="preserve"><value>Lisa oma esimene kulu j&#228;lgimise alustamiseks.</value></data>
150 + <data name="Recent Expenses" xml:space="preserve"><value>Hiljutised kulud</value></data>
151 + <data name="Expense" xml:space="preserve"><value>Kulu</value></data>
152 + <data name="Total Expenses" xml:space="preserve"><value>Kulud kokku</value></data>
153 + <data name="Are you sure you want to delete this expense? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle kulu kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
154 + <data name="Estimated Cost" xml:space="preserve"><value>Hinnanguline maksumus</value></data>
155 +
156 + <!-- ========== SPLIT METHODS ========== -->
157 + <data name="Equal (all)" xml:space="preserve"><value>V&#245;rdselt (k&#245;ik)</value></data>
158 + <data name="Equal (subset)" xml:space="preserve"><value>V&#245;rdselt (valik)</value></data>
159 + <data name="Exact amounts" xml:space="preserve"><value>T&#228;psed summad</value></data>
160 + <data name="Percentages" xml:space="preserve"><value>Protsendid</value></data>
161 +
162 + <!-- ========== MEMBERS ========== -->
163 + <data name="Current Members" xml:space="preserve"><value>Praegused liikmed</value></data>
164 + <data name="Pending Invitations" xml:space="preserve"><value>Ootel kutsed</value></data>
165 + <data name="Invite Member" xml:space="preserve"><value>Kutsu liige</value></data>
166 + <data name="Generate Invitation Link" xml:space="preserve"><value>Genereeri kutse link</value></data>
167 + <data name="Back to Members" xml:space="preserve"><value>Tagasi liikmete juurde</value></data>
168 + <data name="Invited by" xml:space="preserve"><value>Kutsuja</value></data>
169 + <data name="Are you sure you want to remove this member?" xml:space="preserve"><value>Kas soovid selle liikme eemaldada?</value></data>
170 +
171 + <!-- ========== SETTLEMENT ========== -->
172 + <data name="Balances" xml:space="preserve"><value>Saldod</value></data>
173 + <data name="Settlement Plan" xml:space="preserve"><value>Arveldusplaan</value></data>
174 + <data name="Total Paid" xml:space="preserve"><value>Kokku makstud</value></data>
175 + <data name="Total Owed" xml:space="preserve"><value>Kokku v&#245;lgnetav</value></data>
176 + <data name="Net Balance" xml:space="preserve"><value>Neto saldo</value></data>
177 + <data name="From" xml:space="preserve"><value>Kellelt</value></data>
178 + <data name="To" xml:space="preserve"><value>Kellele</value></data>
179 + <data name="Calculate Settlement" xml:space="preserve"><value>Arvuta arveldus</value></data>
180 + <data name="Mark Paid" xml:space="preserve"><value>M&#228;rgi makstuks</value></data>
181 + <data name="Confirm Receipt" xml:space="preserve"><value>Kinnita kviitung</value></data>
182 + <data name="No settlement plan generated yet. Click 'Calculate Settlement' to generate one." xml:space="preserve"><value>Arveldusplaani pole veel loodud. Kliki 'Arvuta arveldus' selle loomiseks.</value></data>
183 +
184 + <!-- ========== BUDGET ========== -->
185 + <data name="Total Planned" xml:space="preserve"><value>Kokku planeeritud</value></data>
186 + <data name="Total Spent" xml:space="preserve"><value>Kokku kulutatud</value></data>
187 + <data name="Remaining" xml:space="preserve"><value>J&#228;&#228;k</value></data>
188 + <data name="No budget categories yet" xml:space="preserve"><value>Eelarve kategooriad puuduvad</value></data>
189 + <data name="Add categories to track your trip budget." xml:space="preserve"><value>Lisa kategooriaid oma reisieelarve j&#228;lgimiseks.</value></data>
190 + <data name="Add Category" xml:space="preserve"><value>Lisa kategooria</value></data>
191 + <data name="Add Budget Category" xml:space="preserve"><value>Lisa eelarve kategooria</value></data>
192 + <data name="Edit Budget Category" xml:space="preserve"><value>Muuda eelarve kategooriat</value></data>
193 + <data name="Delete Budget Category" xml:space="preserve"><value>Kustuta eelarve kategooria</value></data>
194 + <data name="Category Name" xml:space="preserve"><value>Kategooria nimi</value></data>
195 + <data name="Icon Name" xml:space="preserve"><value>Ikooni nimi</value></data>
196 + <data name="Bootstrap Icons name (optional)" xml:space="preserve"><value>Bootstrap Icons nimi (valikuline)</value></data>
197 + <data name="Planned Amount" xml:space="preserve"><value>Planeeritud summa</value></data>
198 + <data name="Display Order" xml:space="preserve"><value>Kuvamisj&#228;rjekord</value></data>
199 + <data name="Are you sure you want to delete this budget category? This action cannot be undone." xml:space="preserve"><value>Kas soovid selle eelarve kategooria kustutada? Seda tegevust ei saa tagasi v&#245;tta.</value></data>
200 +
201 + <!-- ========== WISHLIST ========== -->
202 + <data name="Link" xml:space="preserve"><value>Link</value></data>
203 + <data name="Added by" xml:space="preserve"><value>Lisas</value></data>
204 + <data name="Vote" xml:space="preserve"><value>H&#228;&#228;leta</value></data>
205 + <data name="Voted" xml:space="preserve"><value>H&#228;&#228;letatud</value></data>
206 + <data name="Mark Done" xml:space="preserve"><value>M&#228;rgi tehtuks</value></data>
207 + <data name="Completed" xml:space="preserve"><value>Tehtud</value></data>
208 + <data name="Add Item" xml:space="preserve"><value>Lisa element</value></data>
209 + <data name="Add Wishlist Item" xml:space="preserve"><value>Lisa soovinimekirja element</value></data>
210 + <data name="No wishlist items yet" xml:space="preserve"><value>Soovinimekirja elemendid puuduvad</value></data>
211 + <data name="Add places, activities, or restaurants you want to visit." xml:space="preserve"><value>Lisa kohti, tegevusi v&#245;i restorane, mida soovid k&#252;lastada.</value></data>
212 +
213 + <!-- ========== POLLS ========== -->
214 + <data name="Create Poll" xml:space="preserve"><value>Loo k&#252;sitlus</value></data>
215 + <data name="Open" xml:space="preserve"><value>Avatud</value></data>
216 + <data name="Closed" xml:space="preserve"><value>Suletud</value></data>
217 + <data name="Created by" xml:space="preserve"><value>Looja</value></data>
218 + <data name="View Results" xml:space="preserve"><value>Vaata tulemusi</value></data>
219 + <data name="Close Poll" xml:space="preserve"><value>Sulge k&#252;sitlus</value></data>
220 + <data name="Back to Polls" xml:space="preserve"><value>Tagasi k&#252;sitluste juurde</value></data>
221 + <data name="Poll Results" xml:space="preserve"><value>K&#252;sitluse tulemused</value></data>
222 + <data name="No polls yet" xml:space="preserve"><value>K&#252;sitlused puuduvad</value></data>
223 + <data name="Create a poll to help your group make decisions." xml:space="preserve"><value>Loo k&#252;sitlus, et aidata grupil otsuseid teha.</value></data>
224 + <data name="votes" xml:space="preserve"><value>h&#228;&#228;lt</value></data>
225 + <data name="Multiple votes allowed" xml:space="preserve"><value>Mitu h&#228;&#228;lt lubatud</value></data>
226 + <data name="Allow multiple votes" xml:space="preserve"><value>Luba mitu h&#228;&#228;lt</value></data>
227 + <data name="Anonymous voting" xml:space="preserve"><value>Anonuumne h&#228;&#228;letamine</value></data>
228 + <data name="At least 2 options are required. Up to 5 options supported." xml:space="preserve"><value>Vajalik on v&#228;hemalt 2 valikut. Toetatud kuni 5 valikut.</value></data>
229 + <data name="total votes" xml:space="preserve"><value>h&#228;&#228;lt kokku</value></data>
230 + <data name="Your vote" xml:space="preserve"><value>Sinu h&#228;&#228;l</value></data>
231 + <data name="Winner" xml:space="preserve"><value>V&#245;itja</value></data>
232 + <data name="Unvote" xml:space="preserve"><value>T&#252;hista h&#228;&#228;l</value></data>
233 +
234 + <!-- ========== ADMIN ========== -->
235 + <data name="AdminDashboard" xml:space="preserve"><value>Administreerimislaud</value></data>
236 + <data name="ManageEntities" xml:space="preserve"><value>Halda andmeid</value></data>
237 + <data name="ManageTrips" xml:space="preserve"><value>Halda reise</value></data>
238 + <data name="ManageExpenses" xml:space="preserve"><value>Halda kulusid</value></data>
239 + <data name="ManageCategories" xml:space="preserve"><value>Halda kategooriaid</value></data>
240 + <data name="ManageCurrencies" xml:space="preserve"><value>Halda valuutasid</value></data>
241 + <data name="ManageSettlements" xml:space="preserve"><value>Halda arveldusi</value></data>
242 + <data name="ManageParticipants" xml:space="preserve"><value>Halda osalejaid</value></data>
243 + <data name="ManageWishlist" xml:space="preserve"><value>Halda soovinimekirja</value></data>
244 + <data name="ManagePolls" xml:space="preserve"><value>Halda k&#252;sitlusi</value></data>
245 + <data name="Users" xml:space="preserve"><value>Kasutajad</value></data>
246 + <data name="Invitations" xml:space="preserve"><value>Kutsed</value></data>
247 +
248 + <!-- ========== ADMIN ENTITY FIELD KEYS ========== -->
249 + <data name="StartDate" xml:space="preserve"><value>Alguskuup&#228;ev</value></data>
250 + <data name="EndDate" xml:space="preserve"><value>L&#245;ppkuup&#228;ev</value></data>
251 + <data name="CreatedBy" xml:space="preserve"><value>Looja</value></data>
252 + <data name="DefaultCurrency" xml:space="preserve"><value>Vaikevaluuta</value></data>
253 + <data name="ExpenseDate" xml:space="preserve"><value>Kulu kuup&#228;ev</value></data>
254 + <data name="PaidByUser" xml:space="preserve"><value>Maksis</value></data>
255 + <data name="SplitMethod" xml:space="preserve"><value>Jagamismeetod</value></data>
256 + <data name="BudgetCategory" xml:space="preserve"><value>Eelarve kategooria</value></data>
257 + <data name="BudgetCategories" xml:space="preserve"><value>Eelarve kategooriad</value></data>
258 + <data name="PlannedAmount" xml:space="preserve"><value>Planeeritud summa</value></data>
259 + <data name="DisplayOrder" xml:space="preserve"><value>Kuvamisj&#228;rjekord</value></data>
260 + <data name="IconName" xml:space="preserve"><value>Ikooni nimi</value></data>
261 + <data name="SettlementPlans" xml:space="preserve"><value>Arveldusplaanid</value></data>
262 + <data name="SettlementPlan" xml:space="preserve"><value>Arveldusplaan</value></data>
263 + <data name="TotalAmount" xml:space="preserve"><value>Kogusumma</value></data>
264 + <data name="CreatedByUser" xml:space="preserve"><value>Looja</value></data>
265 + <data name="CompletedAt" xml:space="preserve"><value>L&#245;petatud</value></data>
266 + <data name="TripParticipants" xml:space="preserve"><value>Reisi osalejad</value></data>
267 + <data name="TripParticipant" xml:space="preserve"><value>Reisi osaleja</value></data>
268 + <data name="JoinedAt" xml:space="preserve"><value>Liitumisaeg</value></data>
269 + <data name="LeftAt" xml:space="preserve"><value>Lahkumisaeg</value></data>
270 + <data name="IsActive" xml:space="preserve"><value>Aktiivne</value></data>
271 +
272 + <!-- ========== ENUM VALUES ========== -->
273 + <data name="Active" xml:space="preserve"><value>Aktiivne</value></data>
274 + <data name="Settled" xml:space="preserve"><value>Arveldatud</value></data>
275 + <data name="Archived" xml:space="preserve"><value>Arhiveeritud</value></data>
276 + <data name="Pending" xml:space="preserve"><value>Ootel</value></data>
277 + <data name="Accepted" xml:space="preserve"><value>Vastu v&#245;etud</value></data>
278 + <data name="Declined" xml:space="preserve"><value>Keeldutud</value></data>
279 + <data name="Expired" xml:space="preserve"><value>Aegunud</value></data>
280 + <data name="Revoked" xml:space="preserve"><value>T&#252;histatud</value></data>
281 + <data name="Organizer" xml:space="preserve"><value>Korraldaja</value></data>
282 + <data name="Participant" xml:space="preserve"><value>Osaleja</value></data>
283 + <data name="MustDo" xml:space="preserve"><value>Kohustuslik</value></data>
284 + <data name="NiceToHave" xml:space="preserve"><value>Soovituslik</value></data>
285 + <data name="Optional" xml:space="preserve"><value>Valikuline</value></data>
286 + <data name="Place" xml:space="preserve"><value>Koht</value></data>
287 + <data name="Activity" xml:space="preserve"><value>Tegevus</value></data>
288 + <data name="Restaurant" xml:space="preserve"><value>Restoran</value></data>
289 + <data name="Other" xml:space="preserve"><value>Muu</value></data>
290 + <data name="InProgress" xml:space="preserve"><value>Pooleli</value></data>
291 + <data name="MarkedPaid" xml:space="preserve"><value>M&#228;rgitud makstuks</value></data>
292 + <data name="Confirmed" xml:space="preserve"><value>Kinnitatud</value></data>
293 +
294 + <!-- ========== HOME PAGE ========== -->
295 + <data name="AppTagline" xml:space="preserve"><value>R&#252;hma reisikulude haldamine lihtsaks tehtud.</value></data>
296 + <data name="AppDescription" xml:space="preserve"><value>J&#228;lgi kulusid, jaga kulusid, halda eelarveid ja arvlda v&#245;lgu oma reisigrupiga.</value></data>
297 +
298 + <!-- ========== INVITATION ========== -->
299 + <data name="Accept Invitation" xml:space="preserve"><value>V&#245;ta kutse vastu</value></data>
300 + <data name="Invitation Generated" xml:space="preserve"><value>Kutse genereeritud</value></data>
301 + <data name="Invalid Invitation" xml:space="preserve"><value>Kehtetu kutse</value></data>
302 + <data name="You've Been Invited!" xml:space="preserve"><value>Sind on kutsutud!</value></data>
303 + <data name="By accepting, you will join this trip as a participant." xml:space="preserve"><value>Vastu v&#245;ttes liitud selle reisiga osalejana.</value></data>
304 + <data name="This invitation is no longer valid." xml:space="preserve"><value>See kutse ei ole enam kehtiv.</value></data>
305 + <data name="Invitation link has been generated successfully!" xml:space="preserve"><value>Kutse link on edukalt genereeritud!</value></data>
306 + <data name="Share this link with the person you want to invite:" xml:space="preserve"><value>Jaga seda linki isikuga, keda soovid kutsuda:</value></data>
307 + <data name="This link will expire in 7 days." xml:space="preserve"><value>See link aegub 7 p&#228;eva p&#228;rast.</value></data>
308 + <data name="The invitation link will expire after 7 days." xml:space="preserve"><value>Kutse link aegub 7 p&#228;eva p&#228;rast.</value></data>
309 + <data name="Generate an invitation link that you can share with someone to join this trip." xml:space="preserve"><value>Genereeri kutse link, mida saad jagada kellegagi, kes soovib reisiga liituda.</value></data>
310 +
311 + <!-- ========== ADDITIONAL KEYS ========== -->
312 + <data name="AddedBy" xml:space="preserve"><value>Lisas</value></data>
313 + <data name="AllowMultipleVotes" xml:space="preserve"><value>Luba mitu h&#228;&#228;lt</value></data>
314 + <data name="Back to List" xml:space="preserve"><value>Tagasi nimekirja</value></data>
315 + <data name="Budget Categories" xml:space="preserve"><value>Eelarve kategooriad</value></data>
316 + <data name="ClosedAt" xml:space="preserve"><value>Suletud</value></data>
317 + <data name="CreatePoll" xml:space="preserve"><value>Loo k&#252;sitlus</value></data>
318 + <data name="DeletePoll" xml:space="preserve"><value>Kustuta k&#252;sitlus</value></data>
319 + <data name="EditPoll" xml:space="preserve"><value>Muuda k&#252;sitlust</value></data>
320 + <data name="IsAnonymous" xml:space="preserve"><value>Anonuumne</value></data>
321 + <data name="Manage Categories" xml:space="preserve"><value>Halda kategooriaid</value></data>
322 + <data name="Manage Currencies" xml:space="preserve"><value>Halda valuutasid</value></data>
323 + <data name="Manage Entities" xml:space="preserve"><value>Halda andmeid</value></data>
324 + <data name="Manage Expenses" xml:space="preserve"><value>Halda kulusid</value></data>
325 + <data name="Manage Participants" xml:space="preserve"><value>Halda osalejaid</value></data>
326 + <data name="Manage Polls" xml:space="preserve"><value>Halda k&#252;sitlusi</value></data>
327 + <data name="Manage Settlements" xml:space="preserve"><value>Halda arveldusi</value></data>
328 + <data name="Manage Trips" xml:space="preserve"><value>Halda reise</value></data>
329 + <data name="Manage Wishlist" xml:space="preserve"><value>Halda soovinimekirja</value></data>
330 + <data name="PollDetails" xml:space="preserve"><value>K&#252;sitluse detailid</value></data>
331 + <data name="Settlements" xml:space="preserve"><value>Arveldused</value></data>
332 + <data name="Text" xml:space="preserve"><value>Tekst</value></data>
333 + <data name="Toggle navigation" xml:space="preserve"><value>L&#252;lita navigatsioon</value></data>
334 + <data name="Wishlist Items" xml:space="preserve"><value>Soovinimekirja elemendid</value></data>
335 + <data name="WishlistItem" xml:space="preserve"><value>Soovinimekirja element</value></data>
336 + <data name="WishlistItemDetails" xml:space="preserve"><value>Soovinimekirja elemendi detailid</value></data>
337 +
338 + <!-- ========== NEW KEYS ========== -->
339 + <data name="Add expenses as you go. Choose how to split — we handle the math." xml:space="preserve"><value>Lisa kulusid jooksvalt. Vali jagamisviis &#x2014; meie teeme matemaatika.</value></data>
340 + <data name="Admin Panel" xml:space="preserve"><value>Administreerimispaneel</value></data>
341 + <data name="All settled up!" xml:space="preserve"><value>K&#245;ik arveldatud!</value></data>
342 + <data name="An error occurred while processing your request." xml:space="preserve"><value>Teie p&#228;ringu t&#246;&#246;tlemisel tekkis viga.</value></data>
343 + <data name="Anonymous" xml:space="preserve"><value>Anon&#252;&#252;mne</value></data>
344 + <data name="At least 2 required" xml:space="preserve"><value>V&#228;hemalt 2 n&#245;utud</value></data>
345 + <data name="Back to App" xml:space="preserve"><value>Tagasi rakendusse</value></data>
346 + <data name="Back to Home" xml:space="preserve"><value>Tagasi avalehele</value></data>
347 + <data name="Budget Progress" xml:space="preserve"><value>Eelarve edenemine</value></data>
348 + <data name="Budget Used" xml:space="preserve"><value>Eelarve kasutatud</value></data>
349 + <data name="Closed at" xml:space="preserve"><value>Suletud</value></data>
350 + <data name="Create Free Account" xml:space="preserve"><value>Loo tasuta konto</value></data>
351 + <data name="Create a Trip" xml:space="preserve"><value>Loo reis</value></data>
352 + <data name="Create polls, build wishlists, and vote together to make group planning effortless." xml:space="preserve"><value>Loo k&#252;sitlusi, koosta soovinimekirju ja h&#228;&#228;leta koos, et grupiplaneerimine oleks vaevatu.</value></data>
353 + <data name="Done" xml:space="preserve"><value>Tehtud</value></data>
354 + <data name="Entities" xml:space="preserve"><value>Andmed</value></data>
355 + <data name="Entity Management" xml:space="preserve"><value>Andmete haldus</value></data>
356 + <data name="Error" xml:space="preserve"><value>Viga</value></data>
357 + <data name="Everything you need for group travel" xml:space="preserve"><value>K&#245;ik, mida vajad grupireisiks</value></data>
358 + <data name="From splitting dinner bills to planning activities, SplitApp handles it all." xml:space="preserve"><value>&#213;htus&#246;&#246;giarve jagamisest tegevuste planeerimiseni &#x2014; SplitApp teeb k&#245;ik &#228;ra.</value></data>
359 + <data name="Get Started" xml:space="preserve"><value>Alusta</value></data>
360 + <data name="Group Decisions" xml:space="preserve"><value>Grupiotsused</value></data>
361 + <data name="Help your group make decisions together" xml:space="preserve"><value>Aita grupil koos otsuseid teha</value></data>
362 + <data name="How it works" xml:space="preserve"><value>Kuidas see t&#246;&#246;tab</value></data>
363 + <data name="Icon" xml:space="preserve"><value>Ikoon</value></data>
364 + <data name="Invite" xml:space="preserve"><value>Kutsu</value></data>
365 + <data name="Join thousands of travelers who split smarter." xml:space="preserve"><value>Liitu tuhandete reisijatega, kes jagavad targemalt.</value></data>
366 + <data name="Learn More" xml:space="preserve"><value>Loe lisaks</value></data>
367 + <data name="Link Generated!" xml:space="preserve"><value>Link genereeritud!</value></data>
368 + <data name="Log in" xml:space="preserve"><value>Logi sisse</value></data>
369 + <data name="Manage Wishlist Items" xml:space="preserve"><value>Halda soovinimekirja elemente</value></data>
370 + <data name="Multi-Vote" xml:space="preserve"><value>Mitu h&#228;&#228;lt</value></data>
371 + <data name="Multiple votes" xml:space="preserve"><value>Mitu h&#228;&#228;lt</value></data>
372 + <data name="New Category" xml:space="preserve"><value>Uus kategooria</value></data>
373 + <data name="New Currency" xml:space="preserve"><value>Uus valuuta</value></data>
374 + <data name="New Expense" xml:space="preserve"><value>Uus kulu</value></data>
375 + <data name="New Trip" xml:space="preserve"><value>Uus reis</value></data>
376 + <data name="No description" xml:space="preserve"><value>Kirjeldus puudub</value></data>
377 + <data name="No payments needed — everyone is even." xml:space="preserve"><value>Makseid pole vaja &#x2014; k&#245;ik on tasas.</value></data>
378 + <data name="No users yet" xml:space="preserve"><value>Kasutajaid pole veel</value></data>
379 + <data name="Not friendships." xml:space="preserve"><value>Mitte s&#245;prussuhteid.</value></data>
380 + <data name="Optimized settlement calculates the minimum payments needed. Confirm with two-sided verification." xml:space="preserve"><value>Optimeeritud arveldus arvutab vajalike maksete miinimumi. Kinnita kahepoolse kinnitusega.</value></data>
381 + <data name="Over budget!" xml:space="preserve"><value>Eelarve &#252;letatud!</value></data>
382 + <data name="Overall Progress" xml:space="preserve"><value>&#220;ldine edenemine</value></data>
383 + <data name="Overview" xml:space="preserve"><value>&#220;levaade</value></data>
384 + <data name="Plan trips, track expenses, split costs, and settle debts with your travel group — all in one place." xml:space="preserve"><value>Planeeri reise, j&#228;lgi kulusid, jaga kulusid ja arvlda v&#245;lgu oma reisigrupiga &#x2014; k&#245;ik &#252;hes kohas.</value></data>
385 + <data name="Planned" xml:space="preserve"><value>Planeeritud</value></data>
386 + <data name="Poll" xml:space="preserve"><value>K&#252;sitlus</value></data>
387 + <data name="Privacy Policy" xml:space="preserve"><value>Privaatsuspoliitika</value></data>
388 + <data name="Profile" xml:space="preserve"><value>Profiil</value></data>
389 + <data name="Quick Links" xml:space="preserve"><value>Kiirlingid</value></data>
390 + <data name="Ready to plan your next trip?" xml:space="preserve"><value>Valmis planeerima oma j&#228;rgmist reisi?</value></data>
391 + <data name="Recalculate" xml:space="preserve"><value>Arvuta uuesti</value></data>
392 + <data name="Recalculate after new expenses" xml:space="preserve"><value>Arvuta uuesti p&#228;rast uusi kulusid</value></data>
393 + <data name="Recent Trips" xml:space="preserve"><value>Hiljutised reisid</value></data>
394 + <data name="Recent Users" xml:space="preserve"><value>Hiljutised kasutajad</value></data>
395 + <data name="Request ID" xml:space="preserve"><value>P&#228;ringu ID</value></data>
396 + <data name="Search..." xml:space="preserve"><value>Otsi...</value></data>
397 + <data name="See who owes whom and settle with minimal payments." xml:space="preserve"><value>Vaata, kes kellele v&#245;lgneb, ja arvlda minimaalsete maksetega.</value></data>
398 + <data name="Set up your next adventure" xml:space="preserve"><value>Seadista oma j&#228;rgmine seiklus</value></data>
399 + <data name="Set up your trip and invite friends with a simple shareable link." xml:space="preserve"><value>Seadista oma reis ja kutsu s&#245;brad lihtsa jagatava lingiga.</value></data>
400 + <data name="Settle Up" xml:space="preserve"><value>Arvlda</value></data>
401 + <data name="Share what you want to experience" xml:space="preserve"><value>Jaga, mida soovid kogeda</value></data>
402 + <data name="Sign up" xml:space="preserve"><value>Registreeru</value></data>
403 + <data name="Something went wrong" xml:space="preserve"><value>Midagi l&#228;ks valesti</value></data>
404 + <data name="Split Any Way" xml:space="preserve"><value>Jaga kulusid</value></data>
405 + <data name="Split equally, by exact amounts, or percentages. Save presets for recurring groups." xml:space="preserve"><value>Jaga v&#245;rdselt, t&#228;psete summade v&#245;i protsentidega. Salvesta eelseaded korduvate gruppide jaoks.</value></data>
406 + <data name="Split expenses." xml:space="preserve"><value>Jaga kulusid.</value></data>
407 + <data name="Start tracking your group expenses." xml:space="preserve"><value>Alusta oma grupi kulude j&#228;lgimist.</value></data>
408 + <data name="System Administration" xml:space="preserve"><value>S&#252;steemihaldus</value></data>
409 + <data name="Total Trips" xml:space="preserve"><value>Reise kokku</value></data>
410 + <data name="Track &amp; Split" xml:space="preserve"><value>J&#228;lgi ja jaga</value></data>
411 + <data name="Track a group expense" xml:space="preserve"><value>J&#228;lgi grupi kulu</value></data>
412 + <data name="Use this page to detail your site's privacy policy." xml:space="preserve"><value>Kasuta seda lehte oma saidi privaatsuspoliitika kirjeldamiseks.</value></data>
413 + <data name="View Link" xml:space="preserve"><value>Vaata linki</value></data>
414 + <data name="What should the group decide?" xml:space="preserve"><value>Mida peaks grupp otsustama?</value></data>
415 + <data name="What's the plan?" xml:space="preserve"><value>Mis on plaan?</value></data>
416 + <data name="Why do you want to go here?" xml:space="preserve"><value>Miks soovid sinna minna?</value></data>
417 + <data name="Wishlist Item" xml:space="preserve"><value>Soovinimekirja element</value></data>
418 + <data name="Your Balance" xml:space="preserve"><value>Sinu saldo</value></data>
419 + <data name="e.g. Group dinner, taxi, museum tickets..." xml:space="preserve"><value>nt Grupi&#245;htus&#246;&#246;k, takso, muuseumi piletid...</value></data>
420 + <data name="e.g. Old Town, City Center" xml:space="preserve"><value>nt Vanalinn, Kesklinn</value></data>
421 + <data name="e.g. Sagrada Familia, Local tapas bar" xml:space="preserve"><value>nt Sagrada Familia, Kohalik tapas baar</value></data>
422 + <data name="in progress" xml:space="preserve"><value>pooleli</value></data>
423 + <data name="member(s)" xml:space="preserve"><value>liige/liikmed</value></data>
424 + <data name="payment(s) marked paid, awaiting confirmation" xml:space="preserve"><value>makse/maksed m&#228;rgitud makstuks, ootab kinnitust</value></data>
425 + <data name="pending invitation(s)" xml:space="preserve"><value>ootel kutse/kutsed</value></data>
426 + <data name="settlement payment(s) awaiting action" xml:space="preserve"><value>arvelduse makse/maksed ootavad tegevust</value></data>
427 + <data name="trip(s)" xml:space="preserve"><value>reis(i)</value></data>
428 +
429 + <data name="All" xml:space="preserve"><value>Kõik</value></data>
430 +
431 + <!-- ========== SPLIT PRESETS ========== -->
432 + <data name="Apply Preset" xml:space="preserve"><value>Rakenda mall</value></data>
433 + <data name="Select a preset..." xml:space="preserve"><value>Vali mall...</value></data>
434 + <data name="Apply" xml:space="preserve"><value>Rakenda</value></data>
435 + <data name="Save current split as preset" xml:space="preserve"><value>Salvesta praegune jaotus mallina</value></data>
436 + <data name="Preset name, e.g. Hotel group" xml:space="preserve"><value>Malli nimi, nt Hotelli grupp</value></data>
437 +
438 + <!-- ========== WISHLIST EDIT/DELETE ========== -->
439 + <data name="Edit Wishlist Item" xml:space="preserve"><value>Muuda soovinimekirja elementi</value></data>
440 + <data name="Delete Wishlist Item" xml:space="preserve"><value>Kustuta soovinimekirja element</value></data>
441 +
442 + <!-- ========== ADMIN USER MANAGEMENT ========== -->
443 + <data name="User Management" xml:space="preserve"><value>Kasutajate haldus</value></data>
444 + <data name="Edit Roles" xml:space="preserve"><value>Muuda rolle</value></data>
445 + <data name="System" xml:space="preserve"><value>S&#252;steem</value></data>
446 + <data name="More" xml:space="preserve"><value>Veel</value></data>
447 + <data name="Payments" xml:space="preserve"><value>Maksed</value></data>
448 + <data name="Split Presets" xml:space="preserve"><value>Jaotuse mallid</value></data>
449 + <data name="Revoke" xml:space="preserve"><value>T&#252;hista</value></data>
450 +
451 + <!-- ADMIN REBUILD ADDITIONS -->
452 + <data name="Core" xml:space="preserve"><value>Põhi</value></data>
453 + <data name="Back to site" xml:space="preserve"><value>Tagasi saidile</value></data>
454 + <data name="System overview and real-time statistics" xml:space="preserve"><value>Süsteemi ülevaade ja reaalajas statistika</value></data>
455 + <data name="payments awaiting action" xml:space="preserve"><value>makset ootavad tegevust</value></data>
456 + <data name="payments awaiting confirmation" xml:space="preserve"><value>makset ootavad kinnitust</value></data>
457 + <data name="Total Users" xml:space="preserve"><value>Kasutajaid kokku</value></data>
458 + <data name="Total amount" xml:space="preserve"><value>Kogusumma</value></data>
459 + <data name="Settlement plans" xml:space="preserve"><value>Arvelduste plaanid</value></data>
460 + <data name="Pending payments" xml:space="preserve"><value>Ootel maksed</value></data>
461 + <data name="Trip status breakdown" xml:space="preserve"><value>Reiside staatuse jaotus</value></data>
462 + <data name="Settlement status" xml:space="preserve"><value>Arvelduse staatus</value></data>
463 + <data name="Top active trips" xml:space="preserve"><value>Populaarseimad aktiivsed reisid</value></data>
464 + <data name="Biggest expenses" xml:space="preserve"><value>Suurimad kulud</value></data>
465 + <data name="No active trips yet" xml:space="preserve"><value>Aktiivseid reise veel pole</value></data>
466 + <data name="by" xml:space="preserve"><value>tegija</value></data>
467 + <data name="User activity" xml:space="preserve"><value>Kasutajate aktiivsus</value></data>
468 + <data name="Active 7d" xml:space="preserve"><value>Aktiivne 7p</value></data>
469 + <data name="Active 30d" xml:space="preserve"><value>Aktiivne 30p</value></data>
470 + <data name="Most active users" xml:space="preserve"><value>Kõige aktiivsemad kasutajad</value></data>
471 + <data name="No activity yet" xml:space="preserve"><value>Aktiivsust veel pole</value></data>
472 + <data name="Recent activity" xml:space="preserve"><value>Hiljutine aktiivsus</value></data>
473 + <data name="events" xml:space="preserve"><value>sündmust</value></data>
474 + <data name="No recent activity" xml:space="preserve"><value>Hiljutist aktiivsust pole</value></data>
475 + <data name="Quick actions" xml:space="preserve"><value>Kiirtegevused</value></data>
476 + <data name="Trip &quot;{0}&quot; created" xml:space="preserve"><value>Reis „{0}" loodi</value></data>
477 + <data name="Expense {0:0.00} added to &quot;{1}&quot;" xml:space="preserve"><value>Kulu {0:0.00} lisatud reisile „{1}"</value></data>
478 + <data name="Settlement plan for &quot;{0}&quot; ({1})" xml:space="preserve"><value>Arveldusplaan reisile „{0}" ({1})</value></data>
479 + <data name="Trip details" xml:space="preserve"><value>Reisi andmed</value></data>
480 + <data name="Expense details" xml:space="preserve"><value>Kulu andmed</value></data>
481 + <data name="Budget Category details" xml:space="preserve"><value>Eelarvekategooria andmed</value></data>
482 + <data name="New budget category" xml:space="preserve"><value>Uus eelarvekategooria</value></data>
483 + <data name="Currency details" xml:space="preserve"><value>Valuuta andmed</value></data>
484 + <data name="Edit currency" xml:space="preserve"><value>Muuda valuutat</value></data>
485 + <data name="Delete currency" xml:space="preserve"><value>Kustuta valuuta</value></data>
486 + <data name="Poll details" xml:space="preserve"><value>Hääletuse andmed</value></data>
487 + <data name="New poll" xml:space="preserve"><value>Uus hääletus</value></data>
488 + <data name="Edit poll" xml:space="preserve"><value>Muuda hääletust</value></data>
489 + <data name="Delete poll" xml:space="preserve"><value>Kustuta hääletus</value></data>
490 + <data name="Wishlist item details" xml:space="preserve"><value>Soovinimekirja andmed</value></data>
491 + <data name="New wishlist item" xml:space="preserve"><value>Uus soov</value></data>
492 + <data name="Invitation details" xml:space="preserve"><value>Kutse andmed</value></data>
493 + <data name="Settlement plan details" xml:space="preserve"><value>Arveldusplaani andmed</value></data>
494 + <data name="New settlement plan" xml:space="preserve"><value>Uus arveldusplaan</value></data>
495 + <data name="Edit settlement plan" xml:space="preserve"><value>Muuda arveldusplaani</value></data>
496 + <data name="Delete settlement plan" xml:space="preserve"><value>Kustuta arveldusplaan</value></data>
497 + <data name="Settlement payment details" xml:space="preserve"><value>Arveldusmakse andmed</value></data>
498 + <data name="Split preset details" xml:space="preserve"><value>Jaotuse eelseadistuse andmed</value></data>
499 + <data name="Trip participant details" xml:space="preserve"><value>Reisi osaleja andmed</value></data>
500 + <data name="New trip participant" xml:space="preserve"><value>Uus reisi osaleja</value></data>
501 + <data name="Edit trip participant" xml:space="preserve"><value>Muuda reisi osalejat</value></data>
502 + <data name="Delete trip participant" xml:space="preserve"><value>Kustuta reisi osaleja</value></data>
503 + <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 &amp; Split" xml:space="preserve"><value>Track &amp; Split</value></data>
411 + <data name="Track a group expense" xml:space="preserve"><value>Track a group expense</value></data>
412 + <data name="Use this page to detail your site's privacy policy." xml:space="preserve"><value>Use this page to detail your site's privacy policy.</value></data>
413 + <data name="View Link" xml:space="preserve"><value>View Link</value></data>
414 + <data name="What should the group decide?" xml:space="preserve"><value>What should the group decide?</value></data>
415 + <data name="What's the plan?" xml:space="preserve"><value>What's the plan?</value></data>
416 + <data name="Why do you want to go here?" xml:space="preserve"><value>Why do you want to go here?</value></data>
417 + <data name="Wishlist Item" xml:space="preserve"><value>Wishlist Item</value></data>
418 + <data name="Your Balance" xml:space="preserve"><value>Your Balance</value></data>
419 + <data name="e.g. Group dinner, taxi, museum tickets..." xml:space="preserve"><value>e.g. Group dinner, taxi, museum tickets...</value></data>
420 + <data name="e.g. Old Town, City Center" xml:space="preserve"><value>e.g. Old Town, City Center</value></data>
421 + <data name="e.g. Sagrada Familia, Local tapas bar" xml:space="preserve"><value>e.g. Sagrada Familia, Local tapas bar</value></data>
422 + <data name="in progress" xml:space="preserve"><value>in progress</value></data>
423 + <data name="member(s)" xml:space="preserve"><value>member(s)</value></data>
424 + <data name="payment(s) marked paid, awaiting confirmation" xml:space="preserve"><value>payment(s) marked paid, awaiting confirmation</value></data>
425 + <data name="pending invitation(s)" xml:space="preserve"><value>pending invitation(s)</value></data>
426 + <data name="settlement payment(s) awaiting action" xml:space="preserve"><value>settlement payment(s) awaiting action</value></data>
427 + <data name="trip(s)" xml:space="preserve"><value>trip(s)</value></data>
428 +
429 + <data name="All" xml:space="preserve"><value>All</value></data>
430 +
431 + <!-- ========== SPLIT PRESETS ========== -->
432 + <data name="Apply Preset" xml:space="preserve"><value>Apply Preset</value></data>
433 + <data name="Select a preset..." xml:space="preserve"><value>Select a preset...</value></data>
434 + <data name="Apply" xml:space="preserve"><value>Apply</value></data>
435 + <data name="Save current split as preset" xml:space="preserve"><value>Save current split as preset</value></data>
436 + <data name="Preset name, e.g. Hotel group" xml:space="preserve"><value>Preset name, e.g. Hotel group</value></data>
437 +
438 + <!-- ========== WISHLIST EDIT/DELETE ========== -->
439 + <data name="Edit Wishlist Item" xml:space="preserve"><value>Edit Wishlist Item</value></data>
440 + <data name="Delete Wishlist Item" xml:space="preserve"><value>Delete Wishlist Item</value></data>
441 +
442 + <!-- ========== ADMIN USER MANAGEMENT ========== -->
443 + <data name="User Management" xml:space="preserve"><value>User Management</value></data>
444 + <data name="Edit Roles" xml:space="preserve"><value>Edit Roles</value></data>
445 + <data name="System" xml:space="preserve"><value>System</value></data>
446 + <data name="More" xml:space="preserve"><value>More</value></data>
447 + <data name="Payments" xml:space="preserve"><value>Payments</value></data>
448 + <data name="Split Presets" xml:space="preserve"><value>Split Presets</value></data>
449 + <data name="Revoke" xml:space="preserve"><value>Revoke</value></data>
450 +
451 + <!-- ADMIN REBUILD ADDITIONS -->
452 + <data name="Core" xml:space="preserve"><value>Core</value></data>
453 + <data name="Back to site" xml:space="preserve"><value>Back to site</value></data>
454 + <data name="System overview and real-time statistics" xml:space="preserve"><value>System overview and real-time statistics</value></data>
455 + <data name="payments awaiting action" xml:space="preserve"><value>payments awaiting action</value></data>
456 + <data name="payments awaiting confirmation" xml:space="preserve"><value>payments awaiting confirmation</value></data>
457 + <data name="Total Users" xml:space="preserve"><value>Total Users</value></data>
458 + <data name="Total amount" xml:space="preserve"><value>Total amount</value></data>
459 + <data name="Settlement plans" xml:space="preserve"><value>Settlement plans</value></data>
460 + <data name="Pending payments" xml:space="preserve"><value>Pending payments</value></data>
461 + <data name="Trip status breakdown" xml:space="preserve"><value>Trip status breakdown</value></data>
462 + <data name="Settlement status" xml:space="preserve"><value>Settlement status</value></data>
463 + <data name="Top active trips" xml:space="preserve"><value>Top active trips</value></data>
464 + <data name="Biggest expenses" xml:space="preserve"><value>Biggest expenses</value></data>
465 + <data name="No active trips yet" xml:space="preserve"><value>No active trips yet</value></data>
466 + <data name="by" xml:space="preserve"><value>by</value></data>
467 + <data name="User activity" xml:space="preserve"><value>User activity</value></data>
468 + <data name="Active 7d" xml:space="preserve"><value>Active 7d</value></data>
469 + <data name="Active 30d" xml:space="preserve"><value>Active 30d</value></data>
470 + <data name="Most active users" xml:space="preserve"><value>Most active users</value></data>
471 + <data name="No activity yet" xml:space="preserve"><value>No activity yet</value></data>
472 + <data name="Recent activity" xml:space="preserve"><value>Recent activity</value></data>
473 + <data name="events" xml:space="preserve"><value>events</value></data>
474 + <data name="No recent activity" xml:space="preserve"><value>No recent activity</value></data>
475 + <data name="Quick actions" xml:space="preserve"><value>Quick actions</value></data>
476 + <data name="Trip &quot;{0}&quot; created" xml:space="preserve"><value>Trip &quot;{0}&quot; created</value></data>
477 + <data name="Expense {0:0.00} added to &quot;{1}&quot;" xml:space="preserve"><value>Expense {0:0.00} added to &quot;{1}&quot;</value></data>
478 + <data name="Settlement plan for &quot;{0}&quot; ({1})" xml:space="preserve"><value>Settlement plan for &quot;{0}&quot; ({1})</value></data>
479 + <data name="Trip details" xml:space="preserve"><value>Trip details</value></data>
480 + <data name="Expense details" xml:space="preserve"><value>Expense details</value></data>
481 + <data name="Budget Category details" xml:space="preserve"><value>Budget Category details</value></data>
482 + <data name="New budget category" xml:space="preserve"><value>New budget category</value></data>
483 + <data name="Currency details" xml:space="preserve"><value>Currency details</value></data>
484 + <data name="Edit currency" xml:space="preserve"><value>Edit currency</value></data>
485 + <data name="Delete currency" xml:space="preserve"><value>Delete currency</value></data>
486 + <data name="Poll details" xml:space="preserve"><value>Poll details</value></data>
487 + <data name="New poll" xml:space="preserve"><value>New poll</value></data>
488 + <data name="Edit poll" xml:space="preserve"><value>Edit poll</value></data>
489 + <data name="Delete poll" xml:space="preserve"><value>Delete poll</value></data>
490 + <data name="Wishlist item details" xml:space="preserve"><value>Wishlist item details</value></data>
491 + <data name="New wishlist item" xml:space="preserve"><value>New wishlist item</value></data>
492 + <data name="Invitation details" xml:space="preserve"><value>Invitation details</value></data>
493 + <data name="Settlement plan details" xml:space="preserve"><value>Settlement plan details</value></data>
494 + <data name="New settlement plan" xml:space="preserve"><value>New settlement plan</value></data>
495 + <data name="Edit settlement plan" xml:space="preserve"><value>Edit settlement plan</value></data>
496 + <data name="Delete settlement plan" xml:space="preserve"><value>Delete settlement plan</value></data>
497 + <data name="Settlement payment details" xml:space="preserve"><value>Settlement payment details</value></data>
498 + <data name="Split preset details" xml:space="preserve"><value>Split preset details</value></data>
499 + <data name="Trip participant details" xml:space="preserve"><value>Trip participant details</value></data>
500 + <data name="New trip participant" xml:space="preserve"><value>New trip participant</value></data>
501 + <data name="Edit trip participant" xml:space="preserve"><value>Edit trip participant</value></data>
502 + <data name="Delete trip participant" xml:space="preserve"><value>Delete trip participant</value></data>
503 + <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 &copy; 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 + &middot; @Localizer["Paid by"] @(Model.PaidByUser != null ? $"{Model.PaidByUser.FirstName} {Model.PaidByUser.LastName}" : "?")
32 + @if (Model.BudgetCategory != null)
33 + {
34 + <span>&middot; @Model.BudgetCategory.Name</span>
35 + }
36 + </div>
37 + </div>
38 + </div>
39 +
40 + <form asp-action="Delete">
41 + <input type="hidden" asp-for="Id" />
42 + <div class="d-flex gap-3">
43 + <button type="submit" class="sa-btn sa-btn-danger flex-grow-1">
44 + <i class="bi bi-trash3"></i> @Localizer["Delete"]
45 + </button>
46 + <a asp-action="Index" asp-route-tripId="@tripId" class="sa-btn sa-btn-ghost">@Localizer["Cancel"]</a>
47 + </div>
48 + </form>
49 + </div>
50 + </div>
51 +</div>
added SplitApp/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 + &middot; @expense.ExpenseDate.ToString("MMM dd, yyyy")
65 + @if (expense.BudgetCategory != null)
66 + {
67 + <span>&middot;</span>
68 + <span class="sa-badge sa-badge-secondary" style="font-size: 0.65rem; padding: 1px 6px;">@expense.BudgetCategory.Name</span>
69 + }
70 + </div>
71 + </div>
72 + <div class="d-flex align-items-center gap-3">
73 + <div class="text-end">
74 + @if (isDifferentCurrency)
75 + {
76 + <div class="sa-expense-amount">@(CurrencyConverter.Convert(expense.Amount, expense.Currency!.Code, Model.DefaultCurrencyCode).ToString("N2"))</div>
77 + <div style="font-size: 0.7rem; color: var(--sa-gray-400);">@expense.Amount.ToString("N2") @expense.Currency.Code</div>
78 + }
79 + else
80 + {
81 + <div class="sa-expense-amount">@expense.Amount.ToString("N2")</div>
82 + @if (expense.Currency != null)
83 + {
84 + <div style="font-size: 0.75rem; color: var(--sa-gray-400);">@expense.Currency.Code</div>
85 + }
86 + }
87 + </div>
88 + @if (Model.TripStatus == "Active")
89 + {
90 + <div class="sa-expense-actions">
91 + <a asp-action="Edit" asp-route-id="@expense.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Edit"]">
92 + <i class="bi bi-pencil"></i>
93 + </a>
94 + <a asp-action="Delete" asp-route-id="@expense.Id" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm" title="@Localizer["Delete"]" style="color: var(--sa-danger);">
95 + <i class="bi bi-trash3"></i>
96 + </a>
97 + </div>
98 + }
99 + </div>
100 + </div>
101 + }
102 + </div>
103 +}
104 +
105 +@if (Model.TripStatus == "Active")
106 +{
107 + <!-- Mobile FAB -->
108 + <a asp-action="Create" asp-route-tripId="@Model.TripId" class="sa-fab sa-hide-desktop" title="@Localizer["Add Expense"]">
109 + <i class="bi bi-plus-lg"></i>
110 + </a>
111 +}
added SplitApp/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 &middot; @Model.Count @Localizer["member(s)"]</p>
18 + </div>
19 + <div class="d-flex gap-2">
20 + @if (isOrganizer)
21 + {
22 + <a asp-action="Invite" asp-route-tripId="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.2); color: #fff; border: 1.5px solid rgba(255,255,255,0.4);">
23 + <i class="bi bi-person-plus-fill"></i> @Localizer["Invite"]
24 + </a>
25 + }
26 + <a asp-controller="Trips" asp-action="Details" asp-route-id="@tripId" class="sa-btn sa-btn-sm" style="background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);">
27 + <i class="bi bi-arrow-left"></i>
28 + </a>
29 + </div>
30 + </div>
31 +</div>
32 +
33 +<!-- Members List -->
34 +<div class="sa-card-static mb-4">
35 + <div class="sa-card-header">
36 + <i class="bi bi-people-fill me-2"></i>@Localizer["Current Members"]
37 + </div>
38 + <div>
39 + @{ var pIdx = 0; }
40 + @foreach (var participant in Model)
41 + {
42 + var name = participant.User != null ? $"{participant.User.FirstName} {participant.User.LastName}" : Localizer["Unknown"].Value;
43 + var initial = participant.User != null ? $"{participant.User.FirstName?[0]}{participant.User.LastName?[0]}" : "?";
44 + var email = participant.User?.Email ?? "-";
45 +
46 + <div class="d-flex align-items-center gap-3 px-4 py-3" style="border-bottom: 1px solid var(--sa-gray-100);">
47 + <span class="sa-avatar sa-avatar-@((pIdx % 8) + 1)" style="border: none;">@initial</span>
48 + <div style="flex: 1; min-width: 0;">
49 + <div style="font-weight: 600;">
50 + @name
51 + @if (!string.IsNullOrEmpty(participant.Nickname))
52 + {
53 + <small class="sa-text-muted">(@participant.Nickname)</small>
54 + }
55 + </div>
56 + <div style="font-size: 0.85rem; color: var(--sa-gray-500);">@email</div>
57 + </div>
58 + <div class="d-flex align-items-center gap-2">
59 + @if (participant.Role == 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 + &middot; @totalVotes @Localizer["votes"]
70 + &middot; @(poll.Options?.Count ?? 0) @Localizer["options"]
71 + @if (poll.AllowMultipleVotes)
72 + {
73 + <span>&middot; <i class="bi bi-check2-all me-1"></i>@Localizer["Multiple votes"]</span>
74 + }
75 + </div>
76 +
77 + <a asp-action="Details" asp-route-id="@poll.Id"
78 + class="sa-btn sa-btn-sm sa-btn-pill @(isClosed ? "sa-btn-ghost" : "sa-btn-secondary")">
79 + @if (isClosed)
80 + {
81 + <i class="bi bi-eye"></i> @Localizer["View Results"]
82 + }
83 + else
84 + {
85 + <i class="bi bi-check2-square"></i> @Localizer["Vote"]
86 + }
87 + </a>
88 + </div>
89 + </div>
90 + idx++;
91 + }
92 + </div>
93 + </div>
94 + </div>
95 +}
96 +
97 +<a asp-action="Create" asp-route-tripId="@tripId" class="sa-fab sa-hide-desktop" title="@Localizer["Create Poll"]">
98 + <i class="bi bi-plus-lg"></i>
99 +</a>
added SplitApp/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">&copy; 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 + &middot; @expense.ExpenseDate.ToString("MMM dd")
256 + </div>
257 + </div>
258 + <div class="sa-expense-amount">
259 + @expense.Amount.ToString("N2")
260 + </div>
261 + </div>
262 + }
263 + </div>
264 + </div>
265 +}
266 +else
267 +{
268 + <div class="sa-card-static mb-4">
269 + <div class="sa-empty" style="padding: var(--sa-space-8) var(--sa-space-4);">
270 + <div class="sa-empty-icon" style="font-size: 2.5rem;"><i class="bi bi-receipt"></i></div>
271 + <div class="sa-empty-title" style="font-size: 1rem;">@Localizer["No expenses yet"]</div>
272 + <p class="sa-empty-text" style="font-size: 0.875rem;">@Localizer["Start tracking your group expenses."]</p>
273 + <a asp-controller="Expenses" asp-action="Create" asp-route-tripId="@tripId" class="sa-btn sa-btn-primary sa-btn-sm sa-btn-pill">
274 + <i class="bi bi-plus-lg"></i> @Localizer["Add Expense"]
275 + </a>
276 + </div>
277 + </div>
278 +}
279 +
280 +<!-- Description -->
281 +@if (!string.IsNullOrEmpty(Model.Description))
282 +{
283 + <div class="sa-card-static">
284 + <div class="sa-card-header">
285 + <i class="bi bi-text-paragraph me-2"></i>@Localizer["Description"]
286 + </div>
287 + <div class="sa-card-body">
288 + <p style="margin: 0; color: var(--sa-gray-600); line-height: 1.7;">@Model.Description</p>
289 + </div>
290 + </div>
291 +}
added SplitApp/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.