Refactor. Add data layer
This commit is contained in:
1 parent
3b7e4e6ec5
commit
b6b794f0b4
82 files changed
+2921
-2802
No files matched your search
@@ -1,15 +1,12 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace BudgetApp.Data;
|
namespace BudgetApp.Data;
|
||||||
|
|
||||||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
|
: IdentityDbContext<ApplicationUser>(options)
|
||||||
{
|
{
|
||||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
|
||||||
: base(options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public DbSet<Account> Accounts => Set<Account>();
|
public DbSet<Account> Accounts => Set<Account>();
|
||||||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
public DbSet<Transaction> Transactions => Set<Transaction>();
|
||||||
public DbSet<Category> Categories => Set<Category>();
|
public DbSet<Category> Categories => Set<Category>();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="BudgetApp.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace BudgetApp.Data;
|
namespace BudgetApp.Data.Entities;
|
||||||
|
|
||||||
public class Account
|
public class Account
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
namespace BudgetApp.Data;
|
namespace BudgetApp.Data.Entities;
|
||||||
|
|
||||||
public class ApplicationUser : IdentityUser
|
public class ApplicationUser : IdentityUser
|
||||||
{
|
{
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace BudgetApp.Data;
|
namespace BudgetApp.Data.Entities;
|
||||||
|
|
||||||
public class Category
|
public class Category
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
public enum RecurrenceType
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Daily,
|
||||||
|
Weekly,
|
||||||
|
Monthly,
|
||||||
|
Yearly
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace BudgetApp.Data;
|
namespace BudgetApp.Data.Entities;
|
||||||
|
|
||||||
public class Salary
|
public class Salary
|
||||||
{
|
{
|
||||||
@@ -1,21 +1,6 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace BudgetApp.Data;
|
namespace BudgetApp.Data.Entities;
|
||||||
|
|
||||||
public enum TransactionType
|
|
||||||
{
|
|
||||||
Income,
|
|
||||||
Expense
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RecurrenceType
|
|
||||||
{
|
|
||||||
None,
|
|
||||||
Daily,
|
|
||||||
Weekly,
|
|
||||||
Monthly,
|
|
||||||
Yearly
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Transaction
|
public class Transaction
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
public enum TransactionType
|
||||||
|
{
|
||||||
|
Income,
|
||||||
|
Expense
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Interfaces;
|
||||||
|
|
||||||
|
public interface IAccountRepository
|
||||||
|
{
|
||||||
|
Task<List<Account>> GetAccountsAsync(string userId);
|
||||||
|
Task<Account?> GetAccountAsync(int id, string userId);
|
||||||
|
Task CreateAccountAsync(Account account);
|
||||||
|
Task UpdateAccountAsync(Account account);
|
||||||
|
Task UpdateBalanceAsync(int id, string userId, decimal newBalance);
|
||||||
|
Task UpdateAccountDetailsAsync(int id, string userId, string name, string color, string currencyCode);
|
||||||
|
Task DeleteAccountAsync(int id, string userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Interfaces;
|
||||||
|
|
||||||
|
public interface ICategoryRepository
|
||||||
|
{
|
||||||
|
Task<List<Category>> GetCategoriesAsync(string userId);
|
||||||
|
Task EnsureDefaultCategoriesAsync(string userId);
|
||||||
|
Task CreateCategoryAsync(Category category);
|
||||||
|
Task UpdateCategoryAsync(int id, string userId, string name, string icon);
|
||||||
|
Task DeleteCategoryAsync(int id, string userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Interfaces;
|
||||||
|
|
||||||
|
public interface ISalaryRepository
|
||||||
|
{
|
||||||
|
Task<List<Salary>> GetSalariesAsync(string userId);
|
||||||
|
Task CreateSalaryAsync(Salary salary, DateTime windowEnd);
|
||||||
|
Task DeleteSalaryAsync(int id, string userId);
|
||||||
|
Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd);
|
||||||
|
Task<DateTime?> GetNextPaydateAsync(int salaryId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Interfaces;
|
||||||
|
|
||||||
|
public interface ITransactionRepository
|
||||||
|
{
|
||||||
|
Task<List<Transaction>> GetTransactionsAsync(string userId);
|
||||||
|
Task<List<Transaction>> GetRecentTransactionsAsync(string userId, int count = 5);
|
||||||
|
Task<List<Transaction>> GetUpcomingTransactionsAsync(string userId, int count = 5);
|
||||||
|
Task<List<Transaction>> GetTransactionsInRangeAsync(string userId, DateTime from, DateTime to);
|
||||||
|
Task<List<Transaction>> GetTransactionsByAccountAsync(int accountId, string userId);
|
||||||
|
Task CreateTransactionAsync(Transaction transaction);
|
||||||
|
Task UpdateTransactionAsync(int id, string userId, string description, decimal amount, DateTime date,
|
||||||
|
TransactionType type, int accountId, int? categoryId,
|
||||||
|
RecurrenceType recurrenceType = RecurrenceType.None, DateTime? recurrenceEndDate = null);
|
||||||
|
Task DeleteTransactionAsync(int id, string userId);
|
||||||
|
Task DeleteTransactionSeriesAsync(int id, string userId);
|
||||||
|
Task MarkAsPaidAsync(int id, string userId, bool isPaid = true);
|
||||||
|
Task MaterializeRecurringTransactionsAsync(string userId, DateTime? windowEnd = null);
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
namespace BudgetApp.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20260301160737_AddCurrencyCodeAndCategoryIcon")]
|
[Migration("20260301160737_AddCurrencyCodeAndCategoryIcon")]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCurrencyCodeAndCategoryIcon : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Icon",
|
||||||
|
table: "Categories",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 10,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CurrencyCode",
|
||||||
|
table: "Accounts",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 3,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Icon",
|
||||||
|
table: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CurrencyCode",
|
||||||
|
table: "Accounts");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
namespace BudgetApp.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20260305181148_AddIsDefaultToCategory")]
|
[Migration("20260305181148_AddIsDefaultToCategory")]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIsDefaultToCategory : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsDefault",
|
||||||
|
table: "Categories",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsDefault",
|
||||||
|
table: "Categories");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
namespace BudgetApp.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20260305164332_AddIsPaidToTransaction")]
|
[Migration("20260305164332_AddIsPaidToTransaction")]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIsPaidToTransaction : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsPaid",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsPaid",
|
||||||
|
table: "Transactions");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
namespace BudgetApp.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20260301155815_AddRecurringTransactions")]
|
[Migration("20260301155815_AddRecurringTransactions")]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRecurringTransactions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ParentTransactionId",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "RecurrenceEndDate",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "RecurrenceType",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transactions_ParentTransactionId",
|
||||||
|
table: "Transactions",
|
||||||
|
column: "ParentTransactionId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Transactions_Transactions_ParentTransactionId",
|
||||||
|
table: "Transactions",
|
||||||
|
column: "ParentTransactionId",
|
||||||
|
principalTable: "Transactions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Transactions_Transactions_ParentTransactionId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Transactions_ParentTransactionId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentTransactionId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RecurrenceEndDate",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RecurrenceType",
|
||||||
|
table: "Transactions");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
namespace BudgetApp.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20260305175841_AddSalary")]
|
[Migration("20260305175841_AddSalary")]
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSalary : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SalaryId",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Salaries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||||
|
RecurrenceType = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
StartDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
CategoryId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||||
|
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Salaries", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Salaries_Accounts_AccountId",
|
||||||
|
column: x => x.AccountId,
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Salaries_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Salaries_Categories_CategoryId",
|
||||||
|
column: x => x.CategoryId,
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "Id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transactions_SalaryId",
|
||||||
|
table: "Transactions",
|
||||||
|
column: "SalaryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Salaries_AccountId",
|
||||||
|
table: "Salaries",
|
||||||
|
column: "AccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Salaries_CategoryId",
|
||||||
|
table: "Salaries",
|
||||||
|
column: "CategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Salaries_UserId",
|
||||||
|
table: "Salaries",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Transactions_Salaries_SalaryId",
|
||||||
|
table: "Transactions",
|
||||||
|
column: "SalaryId",
|
||||||
|
principalTable: "Salaries",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Transactions_Salaries_SalaryId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Salaries");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Transactions_SalaryId",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SalaryId",
|
||||||
|
table: "Transactions");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
namespace BudgetApp.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
namespace BudgetApp.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(ApplicationDbContext))]
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
[Migration("20260301152724_InitialCreate")]
|
[Migration("20260301152724_InitialCreate")]
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Data.Migrations;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AspNetRoles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||||
|
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||||
|
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AspNetUsers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||||
|
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||||
|
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||||
|
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||||
|
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
||||||
|
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||||
|
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AspNetRoleClaims",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
RoleId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
||||||
|
column: x => x.RoleId,
|
||||||
|
principalTable: "AspNetRoles",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Accounts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||||
|
CurrentBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||||
|
Color = table.Column<string>(type: "TEXT", maxLength: 7, nullable: false),
|
||||||
|
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Accounts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Accounts_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AspNetUserClaims",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AspNetUserLogins",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AspNetUserRoles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
RoleId = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
||||||
|
column: x => x.RoleId,
|
||||||
|
principalTable: "AspNetRoles",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AspNetUserTokens",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||||
|
Value = table.Column<string>(type: "TEXT", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Categories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
||||||
|
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Categories", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Categories_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Transactions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true),
|
||||||
|
Description = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||||
|
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||||
|
Date = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||||
|
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
CategoryId = table.Column<int>(type: "INTEGER", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Transactions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Transactions_Accounts_AccountId",
|
||||||
|
column: x => x.AccountId,
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Transactions_Categories_CategoryId",
|
||||||
|
column: x => x.CategoryId,
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Accounts_UserId",
|
||||||
|
table: "Accounts",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetRoleClaims_RoleId",
|
||||||
|
table: "AspNetRoleClaims",
|
||||||
|
column: "RoleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "RoleNameIndex",
|
||||||
|
table: "AspNetRoles",
|
||||||
|
column: "NormalizedName",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetUserClaims_UserId",
|
||||||
|
table: "AspNetUserClaims",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetUserLogins_UserId",
|
||||||
|
table: "AspNetUserLogins",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetUserRoles_RoleId",
|
||||||
|
table: "AspNetUserRoles",
|
||||||
|
column: "RoleId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "EmailIndex",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "NormalizedEmail");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UserNameIndex",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "NormalizedUserName",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Categories_UserId",
|
||||||
|
table: "Categories",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transactions_AccountId",
|
||||||
|
table: "Transactions",
|
||||||
|
column: "AccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Transactions_CategoryId",
|
||||||
|
table: "Transactions",
|
||||||
|
column: "CategoryId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AspNetRoleClaims");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AspNetUserClaims");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AspNetUserLogins");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AspNetUserRoles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AspNetUserTokens");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AspNetRoles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Accounts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-18
@@ -1,15 +1,14 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Data.Interfaces;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using BudgetApp.Data;
|
|
||||||
|
|
||||||
namespace BudgetApp.Services;
|
namespace BudgetApp.Data.Repositories;
|
||||||
|
|
||||||
public class AccountService(ApplicationDbContext db)
|
public class AccountRepository(ApplicationDbContext db) : IAccountRepository
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db = db;
|
|
||||||
|
|
||||||
public async Task<List<Account>> GetAccountsAsync(string userId)
|
public async Task<List<Account>> GetAccountsAsync(string userId)
|
||||||
{
|
{
|
||||||
return await _db.Accounts
|
return await db.Accounts
|
||||||
.Where(a => a.UserId == userId)
|
.Where(a => a.UserId == userId)
|
||||||
.OrderBy(a => a.Name)
|
.OrderBy(a => a.Name)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -17,54 +16,54 @@ public class AccountService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task<Account?> GetAccountAsync(int id, string userId)
|
public async Task<Account?> GetAccountAsync(int id, string userId)
|
||||||
{
|
{
|
||||||
return await _db.Accounts
|
return await db.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
|
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateAccountAsync(Account account)
|
public async Task CreateAccountAsync(Account account)
|
||||||
{
|
{
|
||||||
_db.Accounts.Add(account);
|
db.Accounts.Add(account);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAccountAsync(Account account)
|
public async Task UpdateAccountAsync(Account account)
|
||||||
{
|
{
|
||||||
_db.Accounts.Update(account);
|
db.Accounts.Update(account);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateBalanceAsync(int id, string userId, decimal newBalance)
|
public async Task UpdateBalanceAsync(int id, string userId, decimal newBalance)
|
||||||
{
|
{
|
||||||
var account = await _db.Accounts
|
var account = await db.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
|
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
|
||||||
if (account is not null)
|
if (account is not null)
|
||||||
{
|
{
|
||||||
account.CurrentBalance = newBalance;
|
account.CurrentBalance = newBalance;
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateAccountDetailsAsync(int id, string userId, string name, string color, string currencyCode)
|
public async Task UpdateAccountDetailsAsync(int id, string userId, string name, string color, string currencyCode)
|
||||||
{
|
{
|
||||||
var account = await _db.Accounts
|
var account = await db.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
|
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
|
||||||
if (account is not null)
|
if (account is not null)
|
||||||
{
|
{
|
||||||
account.Name = name;
|
account.Name = name;
|
||||||
account.Color = color;
|
account.Color = color;
|
||||||
account.CurrencyCode = currencyCode;
|
account.CurrencyCode = currencyCode;
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAccountAsync(int id, string userId)
|
public async Task DeleteAccountAsync(int id, string userId)
|
||||||
{
|
{
|
||||||
var account = await _db.Accounts
|
var account = await db.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
|
.FirstOrDefaultAsync(a => a.Id == id && a.UserId == userId);
|
||||||
if (account is not null)
|
if (account is not null)
|
||||||
{
|
{
|
||||||
_db.Accounts.Remove(account);
|
db.Accounts.Remove(account);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+16
-17
@@ -1,12 +1,11 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Data.Interfaces;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using BudgetApp.Data;
|
|
||||||
|
|
||||||
namespace BudgetApp.Services;
|
namespace BudgetApp.Data.Repositories;
|
||||||
|
|
||||||
public class CategoryService(ApplicationDbContext db)
|
public class CategoryRepository(ApplicationDbContext db) : ICategoryRepository
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db = db;
|
|
||||||
|
|
||||||
private static readonly (string Name, string Icon)[] DefaultCategories =
|
private static readonly (string Name, string Icon)[] DefaultCategories =
|
||||||
[
|
[
|
||||||
("Groceries", "🛒"),
|
("Groceries", "🛒"),
|
||||||
@@ -31,7 +30,7 @@ public class CategoryService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task<List<Category>> GetCategoriesAsync(string userId)
|
public async Task<List<Category>> GetCategoriesAsync(string userId)
|
||||||
{
|
{
|
||||||
return await _db.Categories
|
return await db.Categories
|
||||||
.Where(c => c.UserId == userId)
|
.Where(c => c.UserId == userId)
|
||||||
.OrderBy(c => c.Name)
|
.OrderBy(c => c.Name)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -39,7 +38,7 @@ public class CategoryService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task EnsureDefaultCategoriesAsync(string userId)
|
public async Task EnsureDefaultCategoriesAsync(string userId)
|
||||||
{
|
{
|
||||||
var existing = await _db.Categories
|
var existing = await db.Categories
|
||||||
.Where(c => c.UserId == userId)
|
.Where(c => c.UserId == userId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -53,8 +52,8 @@ public class CategoryService(ApplicationDbContext db)
|
|||||||
UserId = userId
|
UserId = userId
|
||||||
});
|
});
|
||||||
|
|
||||||
_db.Categories.AddRange(defaults);
|
db.Categories.AddRange(defaults);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,38 +77,38 @@ public class CategoryService(ApplicationDbContext db)
|
|||||||
|
|
||||||
if (updated)
|
if (updated)
|
||||||
{
|
{
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateCategoryAsync(Category category)
|
public async Task CreateCategoryAsync(Category category)
|
||||||
{
|
{
|
||||||
_db.Categories.Add(category);
|
db.Categories.Add(category);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateCategoryAsync(int id, string userId, string name, string icon)
|
public async Task UpdateCategoryAsync(int id, string userId, string name, string icon)
|
||||||
{
|
{
|
||||||
var category = await _db.Categories
|
var category = await db.Categories
|
||||||
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
|
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
|
||||||
if (category is not null)
|
if (category is not null)
|
||||||
{
|
{
|
||||||
if (category.IsDefault) return;
|
if (category.IsDefault) return;
|
||||||
category.Name = name;
|
category.Name = name;
|
||||||
category.Icon = icon;
|
category.Icon = icon;
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteCategoryAsync(int id, string userId)
|
public async Task DeleteCategoryAsync(int id, string userId)
|
||||||
{
|
{
|
||||||
var category = await _db.Categories
|
var category = await db.Categories
|
||||||
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
|
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
|
||||||
if (category is not null)
|
if (category is not null)
|
||||||
{
|
{
|
||||||
if (category.IsDefault) return;
|
if (category.IsDefault) return;
|
||||||
_db.Categories.Remove(category);
|
db.Categories.Remove(category);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+18
-19
@@ -1,15 +1,14 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Data.Interfaces;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using BudgetApp.Data;
|
|
||||||
|
|
||||||
namespace BudgetApp.Services;
|
namespace BudgetApp.Data.Repositories;
|
||||||
|
|
||||||
public class SalaryService(ApplicationDbContext db)
|
public class SalaryRepository(ApplicationDbContext db) : ISalaryRepository
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db = db;
|
|
||||||
|
|
||||||
public async Task<List<Salary>> GetSalariesAsync(string userId)
|
public async Task<List<Salary>> GetSalariesAsync(string userId)
|
||||||
{
|
{
|
||||||
return await _db.Salaries
|
return await db.Salaries
|
||||||
.Include(s => s.Account)
|
.Include(s => s.Account)
|
||||||
.Include(s => s.Category)
|
.Include(s => s.Category)
|
||||||
.Where(s => s.UserId == userId)
|
.Where(s => s.UserId == userId)
|
||||||
@@ -19,35 +18,35 @@ public class SalaryService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task CreateSalaryAsync(Salary salary, DateTime windowEnd)
|
public async Task CreateSalaryAsync(Salary salary, DateTime windowEnd)
|
||||||
{
|
{
|
||||||
var salaryCategoryId = await _db.Categories
|
var salaryCategoryId = await db.Categories
|
||||||
.Where(c => c.UserId == salary.UserId && c.Name == "Salary")
|
.Where(c => c.UserId == salary.UserId && c.Name == "Salary")
|
||||||
.Select(c => (int?)c.Id)
|
.Select(c => (int?)c.Id)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
salary.CategoryId = salaryCategoryId;
|
salary.CategoryId = salaryCategoryId;
|
||||||
|
|
||||||
_db.Salaries.Add(salary);
|
db.Salaries.Add(salary);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await MaterializeSalaryTransactionsAsync(salary.UserId, windowEnd);
|
await MaterializeSalaryTransactionsAsync(salary.UserId, windowEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteSalaryAsync(int id, string userId)
|
public async Task DeleteSalaryAsync(int id, string userId)
|
||||||
{
|
{
|
||||||
var exists = await _db.Salaries.AnyAsync(s => s.Id == id && s.UserId == userId);
|
var exists = await db.Salaries.AnyAsync(s => s.Id == id && s.UserId == userId);
|
||||||
if (!exists) return;
|
if (!exists) return;
|
||||||
|
|
||||||
await using var transaction = await _db.Database.BeginTransactionAsync();
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
// Delete future unpaid transactions generated by this salary
|
// Delete future unpaid transactions generated by this salary
|
||||||
await _db.Transactions
|
await db.Transactions
|
||||||
.Where(t => t.SalaryId == id && t.Date.Date > DateTime.Today && !t.IsPaid)
|
.Where(t => t.SalaryId == id && t.Date.Date > DateTime.Today && !t.IsPaid)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
// Unlink past / paid transactions so they stay in history without the salary reference
|
// Unlink past / paid transactions so they stay in history without the salary reference
|
||||||
await _db.Transactions
|
await db.Transactions
|
||||||
.Where(t => t.SalaryId == id)
|
.Where(t => t.SalaryId == id)
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SalaryId, (int?)null));
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SalaryId, (int?)null));
|
||||||
|
|
||||||
await _db.Salaries
|
await db.Salaries
|
||||||
.Where(s => s.Id == id && s.UserId == userId)
|
.Where(s => s.Id == id && s.UserId == userId)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
@@ -56,7 +55,7 @@ public class SalaryService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd)
|
public async Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd)
|
||||||
{
|
{
|
||||||
var salaries = await _db.Salaries
|
var salaries = await db.Salaries
|
||||||
.Where(s => s.UserId == userId)
|
.Where(s => s.UserId == userId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ public class SalaryService(ApplicationDbContext db)
|
|||||||
|
|
||||||
foreach (var salary in salaries)
|
foreach (var salary in salaries)
|
||||||
{
|
{
|
||||||
var latestDate = await _db.Transactions
|
var latestDate = await db.Transactions
|
||||||
.Where(t => t.SalaryId == salary.Id)
|
.Where(t => t.SalaryId == salary.Id)
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
.Select(t => (DateTime?)t.Date)
|
.Select(t => (DateTime?)t.Date)
|
||||||
@@ -93,14 +92,14 @@ public class SalaryService(ApplicationDbContext db)
|
|||||||
|
|
||||||
if (newTransactions.Count > 0)
|
if (newTransactions.Count > 0)
|
||||||
{
|
{
|
||||||
_db.Transactions.AddRange(newTransactions);
|
db.Transactions.AddRange(newTransactions);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<DateTime?> GetNextPaydateAsync(int salaryId)
|
public async Task<DateTime?> GetNextPaydateAsync(int salaryId)
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await db.Transactions
|
||||||
.Where(t => t.SalaryId == salaryId && t.Date.Date >= DateTime.Today)
|
.Where(t => t.SalaryId == salaryId && t.Date.Date >= DateTime.Today)
|
||||||
.OrderBy(t => t.Date)
|
.OrderBy(t => t.Date)
|
||||||
.Select(t => (DateTime?)t.Date)
|
.Select(t => (DateTime?)t.Date)
|
||||||
+38
-46
@@ -1,15 +1,14 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Data.Interfaces;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using BudgetApp.Data;
|
|
||||||
|
|
||||||
namespace BudgetApp.Services;
|
namespace BudgetApp.Data.Repositories;
|
||||||
|
|
||||||
public class TransactionService(ApplicationDbContext db)
|
public class TransactionRepository(ApplicationDbContext db) : ITransactionRepository
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db = db;
|
|
||||||
|
|
||||||
public async Task<List<Transaction>> GetTransactionsAsync(string userId)
|
public async Task<List<Transaction>> GetTransactionsAsync(string userId)
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.Category)
|
.Include(t => t.Category)
|
||||||
.Include(t => t.ParentTransaction)
|
.Include(t => t.ParentTransaction)
|
||||||
@@ -20,7 +19,7 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task<List<Transaction>> GetRecentTransactionsAsync(string userId, int count = 5)
|
public async Task<List<Transaction>> GetRecentTransactionsAsync(string userId, int count = 5)
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.Category)
|
.Include(t => t.Category)
|
||||||
.Include(t => t.ParentTransaction)
|
.Include(t => t.ParentTransaction)
|
||||||
@@ -32,7 +31,7 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task<List<Transaction>> GetUpcomingTransactionsAsync(string userId, int count = 5)
|
public async Task<List<Transaction>> GetUpcomingTransactionsAsync(string userId, int count = 5)
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.Category)
|
.Include(t => t.Category)
|
||||||
.Include(t => t.ParentTransaction)
|
.Include(t => t.ParentTransaction)
|
||||||
@@ -44,7 +43,7 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task<List<Transaction>> GetTransactionsInRangeAsync(string userId, DateTime from, DateTime to)
|
public async Task<List<Transaction>> GetTransactionsInRangeAsync(string userId, DateTime from, DateTime to)
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Include(t => t.Category)
|
.Include(t => t.Category)
|
||||||
.Include(t => t.ParentTransaction)
|
.Include(t => t.ParentTransaction)
|
||||||
@@ -55,7 +54,7 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
|
|
||||||
public async Task<List<Transaction>> GetTransactionsByAccountAsync(int accountId, string userId)
|
public async Task<List<Transaction>> GetTransactionsByAccountAsync(int accountId, string userId)
|
||||||
{
|
{
|
||||||
return await _db.Transactions
|
return await db.Transactions
|
||||||
.Include(t => t.Category)
|
.Include(t => t.Category)
|
||||||
.Where(t => t.AccountId == accountId && t.Account.UserId == userId)
|
.Where(t => t.AccountId == accountId && t.Account.UserId == userId)
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
@@ -74,8 +73,8 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_db.Transactions.Add(transaction);
|
db.Transactions.Add(transaction);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Immediately materialize child occurrences for recurring transactions
|
// Immediately materialize child occurrences for recurring transactions
|
||||||
if (transaction.RecurrenceType != RecurrenceType.None)
|
if (transaction.RecurrenceType != RecurrenceType.None)
|
||||||
@@ -102,15 +101,17 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
|
|
||||||
if (newTransactions.Count > 0)
|
if (newTransactions.Count > 0)
|
||||||
{
|
{
|
||||||
_db.Transactions.AddRange(newTransactions);
|
db.Transactions.AddRange(newTransactions);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateTransactionAsync(int id, string userId, string description, decimal amount, DateTime date, TransactionType type, int accountId, int? categoryId, RecurrenceType recurrenceType = RecurrenceType.None, DateTime? recurrenceEndDate = null)
|
public async Task UpdateTransactionAsync(int id, string userId, string description, decimal amount,
|
||||||
|
DateTime date, TransactionType type, int accountId, int? categoryId,
|
||||||
|
RecurrenceType recurrenceType = RecurrenceType.None, DateTime? recurrenceEndDate = null)
|
||||||
{
|
{
|
||||||
var transaction = await _db.Transactions
|
var transaction = await db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
||||||
|
|
||||||
@@ -128,11 +129,10 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
|
|
||||||
if (isPartOfSeries)
|
if (isPartOfSeries)
|
||||||
{
|
{
|
||||||
// Cascade edits to all future occurrences in the series
|
|
||||||
var parentId = transaction.ParentTransactionId ?? transaction.Id;
|
var parentId = transaction.ParentTransactionId ?? transaction.Id;
|
||||||
var cutoffDate = transaction.Date.Date;
|
var cutoffDate = transaction.Date.Date;
|
||||||
|
|
||||||
var futureSiblings = await _db.Transactions
|
var futureSiblings = await db.Transactions
|
||||||
.Where(t => (t.Id == parentId || t.ParentTransactionId == parentId)
|
.Where(t => (t.Id == parentId || t.ParentTransactionId == parentId)
|
||||||
&& t.Id != id
|
&& t.Id != id
|
||||||
&& t.Date.Date >= cutoffDate)
|
&& t.Date.Date >= cutoffDate)
|
||||||
@@ -149,92 +149,84 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Standalone transaction — preserve existing recurrence state (None)
|
|
||||||
transaction.RecurrenceType = recurrenceType;
|
transaction.RecurrenceType = recurrenceType;
|
||||||
transaction.RecurrenceEndDate = recurrenceEndDate;
|
transaction.RecurrenceEndDate = recurrenceEndDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteTransactionAsync(int id, string userId)
|
public async Task DeleteTransactionAsync(int id, string userId)
|
||||||
{
|
{
|
||||||
var transaction = await _db.Transactions
|
var transaction = await db.Transactions
|
||||||
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
||||||
|
|
||||||
if (transaction is not null)
|
if (transaction is not null)
|
||||||
{
|
{
|
||||||
_db.Transactions.Remove(transaction);
|
db.Transactions.Remove(transaction);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteTransactionSeriesAsync(int id, string userId)
|
public async Task DeleteTransactionSeriesAsync(int id, string userId)
|
||||||
{
|
{
|
||||||
var transaction = await _db.Transactions
|
var transaction = await db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
||||||
|
|
||||||
if (transaction is null) return;
|
if (transaction is null) return;
|
||||||
|
|
||||||
// Resolve the parent id: if this is a child, use its ParentTransactionId; otherwise use its own Id
|
|
||||||
var parentId = transaction.ParentTransactionId ?? transaction.Id;
|
var parentId = transaction.ParentTransactionId ?? transaction.Id;
|
||||||
|
|
||||||
// Use the clicked transaction's date as the cutoff — delete this one and everything after it
|
|
||||||
var cutoffDate = transaction.Date.Date;
|
var cutoffDate = transaction.Date.Date;
|
||||||
|
|
||||||
var allChildren = await _db.Transactions
|
var allChildren = await db.Transactions
|
||||||
.Where(t => t.ParentTransactionId == parentId)
|
.Where(t => t.ParentTransactionId == parentId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var toDelete = allChildren.Where(c => c.Date.Date >= cutoffDate).ToList();
|
var toDelete = allChildren.Where(c => c.Date.Date >= cutoffDate).ToList();
|
||||||
var toKeep = allChildren.Where(c => c.Date.Date < cutoffDate).ToList();
|
var toKeep = allChildren.Where(c => c.Date.Date < cutoffDate).ToList();
|
||||||
|
|
||||||
_db.Transactions.RemoveRange(toDelete);
|
db.Transactions.RemoveRange(toDelete);
|
||||||
|
|
||||||
// Handle the parent
|
|
||||||
var parent = parentId == transaction.Id
|
var parent = parentId == transaction.Id
|
||||||
? transaction
|
? transaction
|
||||||
: await _db.Transactions
|
: await db.Transactions
|
||||||
.FirstOrDefaultAsync(t => t.Id == parentId && t.Account.UserId == userId);
|
.FirstOrDefaultAsync(t => t.Id == parentId && t.Account.UserId == userId);
|
||||||
|
|
||||||
if (parent is not null)
|
if (parent is not null)
|
||||||
{
|
{
|
||||||
if (parent.Date.Date >= cutoffDate)
|
if (parent.Date.Date >= cutoffDate)
|
||||||
{
|
{
|
||||||
// Parent is also at/after the cutoff — detach earlier children so
|
|
||||||
// the DB-level ON DELETE SET NULL doesn't clear their link, then delete
|
|
||||||
foreach (var child in toKeep)
|
foreach (var child in toKeep)
|
||||||
{
|
{
|
||||||
child.ParentTransactionId = null;
|
child.ParentTransactionId = null;
|
||||||
}
|
}
|
||||||
_db.Transactions.Remove(parent);
|
db.Transactions.Remove(parent);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Parent is before the cutoff — cap recurrence so materialization stops
|
|
||||||
parent.RecurrenceEndDate = cutoffDate.AddDays(-1);
|
parent.RecurrenceEndDate = cutoffDate.AddDays(-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MarkAsPaidAsync(int id, string userId, bool isPaid = true)
|
public async Task MarkAsPaidAsync(int id, string userId, bool isPaid = true)
|
||||||
{
|
{
|
||||||
var transaction = await _db.Transactions
|
var transaction = await db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
|
||||||
|
|
||||||
if (transaction is null) return;
|
if (transaction is null) return;
|
||||||
|
|
||||||
transaction.IsPaid = isPaid;
|
transaction.IsPaid = isPaid;
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MaterializeRecurringTransactionsAsync(string userId, DateTime? windowEnd = null)
|
public async Task MaterializeRecurringTransactionsAsync(string userId, DateTime? windowEnd = null)
|
||||||
{
|
{
|
||||||
var recurringParents = await _db.Transactions
|
var recurringParents = await db.Transactions
|
||||||
.Include(t => t.Account)
|
.Include(t => t.Account)
|
||||||
.Where(t => t.Account.UserId == userId
|
.Where(t => t.Account.UserId == userId
|
||||||
&& t.RecurrenceType != RecurrenceType.None
|
&& t.RecurrenceType != RecurrenceType.None
|
||||||
@@ -251,7 +243,7 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
? parent.RecurrenceEndDate.Value
|
? parent.RecurrenceEndDate.Value
|
||||||
: horizon;
|
: horizon;
|
||||||
|
|
||||||
var latestChildDate = await _db.Transactions
|
var latestChildDate = await db.Transactions
|
||||||
.Where(t => t.ParentTransactionId == parent.Id)
|
.Where(t => t.ParentTransactionId == parent.Id)
|
||||||
.OrderByDescending(t => t.Date)
|
.OrderByDescending(t => t.Date)
|
||||||
.Select(t => (DateTime?)t.Date)
|
.Select(t => (DateTime?)t.Date)
|
||||||
@@ -279,17 +271,17 @@ public class TransactionService(ApplicationDbContext db)
|
|||||||
|
|
||||||
if (newTransactions.Count > 0)
|
if (newTransactions.Count > 0)
|
||||||
{
|
{
|
||||||
_db.Transactions.AddRange(newTransactions);
|
db.Transactions.AddRange(newTransactions);
|
||||||
await _db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static DateTime GetNextOccurrence(DateTime current, RecurrenceType recurrence) => recurrence switch
|
internal static DateTime GetNextOccurrence(DateTime current, RecurrenceType recurrence) => recurrence switch
|
||||||
{
|
{
|
||||||
RecurrenceType.Daily => current.AddDays(1),
|
RecurrenceType.Daily => current.AddDays(1),
|
||||||
RecurrenceType.Weekly => current.AddDays(7),
|
RecurrenceType.Weekly => current.AddDays(7),
|
||||||
RecurrenceType.Monthly => current.AddMonths(1),
|
RecurrenceType.Monthly => current.AddMonths(1),
|
||||||
RecurrenceType.Yearly => current.AddYears(1),
|
RecurrenceType.Yearly => current.AddYears(1),
|
||||||
_ => DateTime.MaxValue
|
_ => DateTime.MaxValue
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\BudgetApp\BudgetApp.csproj" />
|
<ProjectReference Include="..\BudgetApp\BudgetApp.csproj" />
|
||||||
|
<ProjectReference Include="..\BudgetApp.Data\BudgetApp.Data.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
namespace BudgetApp.Tests.Helpers;
|
namespace BudgetApp.Tests.Helpers;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using BudgetApp.Data;
|
using BudgetApp.Data.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace BudgetApp.Tests.Models;
|
namespace BudgetApp.Tests.Models;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using BudgetApp.Data;
|
using BudgetApp.Data.Entities;
|
||||||
using BudgetApp.Services;
|
using BudgetApp.Data.Repositories;
|
||||||
using BudgetApp.Tests.Helpers;
|
using BudgetApp.Tests.Helpers;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
|
|
||||||
var result = await service.GetAccountsAsync(TestDbContextFactory.TestUserId);
|
var result = await service.GetAccountsAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
|
|
||||||
var result = await service.GetAccountsAsync(TestDbContextFactory.TestUserId);
|
var result = await service.GetAccountsAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
|
|
||||||
var result = await service.GetAccountAsync(accountId, TestDbContextFactory.TestUserId);
|
var result = await service.GetAccountAsync(accountId, TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
|
|
||||||
var result = await service.GetAccountAsync(accountId, TestDbContextFactory.OtherUserId);
|
var result = await service.GetAccountAsync(accountId, TestDbContextFactory.OtherUserId);
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ public class AccountServiceTests
|
|||||||
{
|
{
|
||||||
using var factory = new TestDbContextFactory();
|
using var factory = new TestDbContextFactory();
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
|
|
||||||
var account = new Account { Name = "New", UserId = TestDbContextFactory.TestUserId, CurrencyCode = "GBP" };
|
var account = new Account { Name = "New", UserId = TestDbContextFactory.TestUserId, CurrencyCode = "GBP" };
|
||||||
await service.CreateAccountAsync(account);
|
await service.CreateAccountAsync(account);
|
||||||
@@ -120,7 +120,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
await service.UpdateBalanceAsync(accountId, TestDbContextFactory.TestUserId, 250m);
|
await service.UpdateBalanceAsync(accountId, TestDbContextFactory.TestUserId, 250m);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -142,7 +142,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
await service.UpdateBalanceAsync(accountId, TestDbContextFactory.OtherUserId, 999m);
|
await service.UpdateBalanceAsync(accountId, TestDbContextFactory.OtherUserId, 999m);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -163,7 +163,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
await service.UpdateAccountDetailsAsync(accountId, TestDbContextFactory.TestUserId, "New", "#fff", "USD");
|
await service.UpdateAccountDetailsAsync(accountId, TestDbContextFactory.TestUserId, "New", "#fff", "USD");
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -187,7 +187,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
await service.UpdateAccountDetailsAsync(accountId, TestDbContextFactory.OtherUserId, "Hacked", "#fff", "USD");
|
await service.UpdateAccountDetailsAsync(accountId, TestDbContextFactory.OtherUserId, "Hacked", "#fff", "USD");
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -208,7 +208,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
await service.DeleteAccountAsync(accountId, TestDbContextFactory.TestUserId);
|
await service.DeleteAccountAsync(accountId, TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -229,7 +229,7 @@ public class AccountServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new AccountService(context);
|
var service = new AccountRepository(context);
|
||||||
await service.DeleteAccountAsync(accountId, TestDbContextFactory.OtherUserId);
|
await service.DeleteAccountAsync(accountId, TestDbContextFactory.OtherUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using BudgetApp.Data;
|
using BudgetApp.Data.Entities;
|
||||||
using BudgetApp.Services;
|
using BudgetApp.Data.Repositories;
|
||||||
using BudgetApp.Tests.Helpers;
|
using BudgetApp.Tests.Helpers;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -21,7 +21,7 @@ public class CategoryServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
|
|
||||||
var result = await service.GetCategoriesAsync(TestDbContextFactory.TestUserId);
|
var result = await service.GetCategoriesAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ public class CategoryServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
|
|
||||||
var result = await service.GetCategoriesAsync(TestDbContextFactory.TestUserId);
|
var result = await service.GetCategoriesAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ public class CategoryServiceTests
|
|||||||
{
|
{
|
||||||
using var factory = new TestDbContextFactory();
|
using var factory = new TestDbContextFactory();
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
|
|
||||||
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
@@ -77,13 +77,13 @@ public class CategoryServiceTests
|
|||||||
|
|
||||||
using (var ctx = factory.CreateContext())
|
using (var ctx = factory.CreateContext())
|
||||||
{
|
{
|
||||||
var service = new CategoryService(ctx);
|
var service = new CategoryRepository(ctx);
|
||||||
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var ctx = factory.CreateContext())
|
using (var ctx = factory.CreateContext())
|
||||||
{
|
{
|
||||||
var service = new CategoryService(ctx);
|
var service = new CategoryRepository(ctx);
|
||||||
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ public class CategoryServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -137,7 +137,7 @@ public class CategoryServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -154,13 +154,13 @@ public class CategoryServiceTests
|
|||||||
|
|
||||||
using (var ctx = factory.CreateContext())
|
using (var ctx = factory.CreateContext())
|
||||||
{
|
{
|
||||||
var service = new CategoryService(ctx);
|
var service = new CategoryRepository(ctx);
|
||||||
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
using (var ctx = factory.CreateContext())
|
using (var ctx = factory.CreateContext())
|
||||||
{
|
{
|
||||||
var service = new CategoryService(ctx);
|
var service = new CategoryRepository(ctx);
|
||||||
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.OtherUserId);
|
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.OtherUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ public class CategoryServiceTests
|
|||||||
{
|
{
|
||||||
using var factory = new TestDbContextFactory();
|
using var factory = new TestDbContextFactory();
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
|
|
||||||
await service.CreateCategoryAsync(new Category
|
await service.CreateCategoryAsync(new Category
|
||||||
{
|
{
|
||||||
@@ -201,7 +201,7 @@ public class CategoryServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
await service.UpdateCategoryAsync(categoryId, TestDbContextFactory.TestUserId, "New", "🎯");
|
await service.UpdateCategoryAsync(categoryId, TestDbContextFactory.TestUserId, "New", "🎯");
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -224,7 +224,7 @@ public class CategoryServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
await service.UpdateCategoryAsync(categoryId, TestDbContextFactory.OtherUserId, "Hacked", "💀");
|
await service.UpdateCategoryAsync(categoryId, TestDbContextFactory.OtherUserId, "Hacked", "💀");
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -245,7 +245,7 @@ public class CategoryServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
await service.DeleteCategoryAsync(categoryId, TestDbContextFactory.TestUserId);
|
await service.DeleteCategoryAsync(categoryId, TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -266,7 +266,7 @@ public class CategoryServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new CategoryService(context);
|
var service = new CategoryRepository(context);
|
||||||
await service.DeleteCategoryAsync(categoryId, TestDbContextFactory.OtherUserId);
|
await service.DeleteCategoryAsync(categoryId, TestDbContextFactory.OtherUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using BudgetApp.Data;
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Data.Repositories;
|
||||||
using BudgetApp.Services;
|
using BudgetApp.Services;
|
||||||
using BudgetApp.Tests.Helpers;
|
using BudgetApp.Tests.Helpers;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace BudgetApp.Tests.Services;
|
namespace BudgetApp.Tests.Services;
|
||||||
@@ -181,9 +181,9 @@ public class DashboardServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var transactionService = new TransactionService(context);
|
var transactionService = new TransactionRepository(context);
|
||||||
var accountService = new AccountService(context);
|
var accountService = new AccountRepository(context);
|
||||||
var categoryService = new CategoryService(context);
|
var categoryService = new CategoryRepository(context);
|
||||||
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
|
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
|
||||||
|
|
||||||
var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId);
|
var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId);
|
||||||
@@ -214,9 +214,9 @@ public class DashboardServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var transactionService = new TransactionService(context);
|
var transactionService = new TransactionRepository(context);
|
||||||
var accountService = new AccountService(context);
|
var accountService = new AccountRepository(context);
|
||||||
var categoryService = new CategoryService(context);
|
var categoryService = new CategoryRepository(context);
|
||||||
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
|
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
|
||||||
|
|
||||||
var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId);
|
var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId);
|
||||||
@@ -243,9 +243,9 @@ public class DashboardServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var transactionService = new TransactionService(context);
|
var transactionService = new TransactionRepository(context);
|
||||||
var accountService = new AccountService(context);
|
var accountService = new AccountRepository(context);
|
||||||
var categoryService = new CategoryService(context);
|
var categoryService = new CategoryRepository(context);
|
||||||
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
|
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
|
||||||
|
|
||||||
var from = DateTime.Today.AddDays(-10);
|
var from = DateTime.Today.AddDays(-10);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
using BudgetApp.Services;
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Data.Repositories;
|
||||||
using BudgetApp.Tests.Helpers;
|
using BudgetApp.Tests.Helpers;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -32,7 +33,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
|
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
@@ -55,7 +56,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
|
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
@@ -85,7 +86,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
|
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
@@ -113,7 +114,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetRecentTransactionsAsync(TestDbContextFactory.TestUserId, 10);
|
var result = await service.GetRecentTransactionsAsync(TestDbContextFactory.TestUserId, 10);
|
||||||
|
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
@@ -141,7 +142,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetRecentTransactionsAsync(TestDbContextFactory.TestUserId, 3);
|
var result = await service.GetRecentTransactionsAsync(TestDbContextFactory.TestUserId, 3);
|
||||||
|
|
||||||
Assert.Equal(3, result.Count);
|
Assert.Equal(3, result.Count);
|
||||||
@@ -166,7 +167,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetUpcomingTransactionsAsync(TestDbContextFactory.TestUserId, 10);
|
var result = await service.GetUpcomingTransactionsAsync(TestDbContextFactory.TestUserId, 10);
|
||||||
|
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
@@ -197,7 +198,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetTransactionsInRangeAsync(TestDbContextFactory.TestUserId, from, to);
|
var result = await service.GetTransactionsInRangeAsync(TestDbContextFactory.TestUserId, from, to);
|
||||||
|
|
||||||
Assert.Equal(3, result.Count);
|
Assert.Equal(3, result.Count);
|
||||||
@@ -229,7 +230,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetTransactionsByAccountAsync(accountId, TestDbContextFactory.TestUserId);
|
var result = await service.GetTransactionsByAccountAsync(accountId, TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
Assert.Single(result);
|
Assert.Single(result);
|
||||||
@@ -250,7 +251,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
var result = await service.GetTransactionsByAccountAsync(accountId, TestDbContextFactory.OtherUserId);
|
var result = await service.GetTransactionsByAccountAsync(accountId, TestDbContextFactory.OtherUserId);
|
||||||
|
|
||||||
Assert.Empty(result);
|
Assert.Empty(result);
|
||||||
@@ -271,7 +272,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.CreateTransactionAsync(new Transaction
|
await service.CreateTransactionAsync(new Transaction
|
||||||
{
|
{
|
||||||
Description = "Groceries",
|
Description = "Groceries",
|
||||||
@@ -296,7 +297,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.CreateTransactionAsync(new Transaction
|
await service.CreateTransactionAsync(new Transaction
|
||||||
{
|
{
|
||||||
Description = "One-off",
|
Description = "One-off",
|
||||||
@@ -323,7 +324,7 @@ public class TransactionServiceTests
|
|||||||
|
|
||||||
var startDate = new DateTime(2025, 1, 1);
|
var startDate = new DateTime(2025, 1, 1);
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.CreateTransactionAsync(new Transaction
|
await service.CreateTransactionAsync(new Transaction
|
||||||
{
|
{
|
||||||
Description = "Recurring",
|
Description = "Recurring",
|
||||||
@@ -353,7 +354,7 @@ public class TransactionServiceTests
|
|||||||
|
|
||||||
var startDate = new DateTime(2025, 1, 1);
|
var startDate = new DateTime(2025, 1, 1);
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.CreateTransactionAsync(new Transaction
|
await service.CreateTransactionAsync(new Transaction
|
||||||
{
|
{
|
||||||
Description = "Recurring",
|
Description = "Recurring",
|
||||||
@@ -383,7 +384,7 @@ public class TransactionServiceTests
|
|||||||
|
|
||||||
var startDate = new DateTime(2025, 1, 1);
|
var startDate = new DateTime(2025, 1, 1);
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.CreateTransactionAsync(new Transaction
|
await service.CreateTransactionAsync(new Transaction
|
||||||
{
|
{
|
||||||
Description = "Weekly",
|
Description = "Weekly",
|
||||||
@@ -421,7 +422,7 @@ public class TransactionServiceTests
|
|||||||
|
|
||||||
var startDate = new DateTime(2025, 1, 15);
|
var startDate = new DateTime(2025, 1, 15);
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.CreateTransactionAsync(new Transaction
|
await service.CreateTransactionAsync(new Transaction
|
||||||
{
|
{
|
||||||
Description = "Monthly",
|
Description = "Monthly",
|
||||||
@@ -481,7 +482,7 @@ public class TransactionServiceTests
|
|||||||
|
|
||||||
var newDate = DateTime.Today.AddDays(5);
|
var newDate = DateTime.Today.AddDays(5);
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.UpdateTransactionAsync(
|
await service.UpdateTransactionAsync(
|
||||||
transactionId, TestDbContextFactory.TestUserId,
|
transactionId, TestDbContextFactory.TestUserId,
|
||||||
"Updated", 99m, newDate, TransactionType.Income,
|
"Updated", 99m, newDate, TransactionType.Income,
|
||||||
@@ -520,7 +521,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.UpdateTransactionAsync(
|
await service.UpdateTransactionAsync(
|
||||||
transactionId, TestDbContextFactory.OtherUserId,
|
transactionId, TestDbContextFactory.OtherUserId,
|
||||||
"Hacked", 999m, DateTime.Today, TransactionType.Income, 1, null);
|
"Hacked", 999m, DateTime.Today, TransactionType.Income, 1, null);
|
||||||
@@ -548,7 +549,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.DeleteTransactionAsync(transactionId, TestDbContextFactory.TestUserId);
|
await service.DeleteTransactionAsync(transactionId, TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -570,7 +571,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.DeleteTransactionAsync(transactionId, TestDbContextFactory.OtherUserId);
|
await service.DeleteTransactionAsync(transactionId, TestDbContextFactory.OtherUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -623,7 +624,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.DeleteTransactionSeriesAsync(childId, TestDbContextFactory.TestUserId);
|
await service.DeleteTransactionSeriesAsync(childId, TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -672,7 +673,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.DeleteTransactionSeriesAsync(childId, TestDbContextFactory.TestUserId);
|
await service.DeleteTransactionSeriesAsync(childId, TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -709,7 +710,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.DeleteTransactionSeriesAsync(parentId, TestDbContextFactory.TestUserId);
|
await service.DeleteTransactionSeriesAsync(parentId, TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -731,7 +732,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.DeleteTransactionSeriesAsync(transactionId, TestDbContextFactory.OtherUserId);
|
await service.DeleteTransactionSeriesAsync(transactionId, TestDbContextFactory.OtherUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -772,7 +773,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId, startDate.AddDays(100));
|
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId, startDate.AddDays(100));
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -808,7 +809,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId, startDate.AddDays(365));
|
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId, startDate.AddDays(365));
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -839,7 +840,7 @@ public class TransactionServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var context = factory.CreateContext();
|
using var context = factory.CreateContext();
|
||||||
var service = new TransactionService(context);
|
var service = new TransactionRepository(context);
|
||||||
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId);
|
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId);
|
||||||
|
|
||||||
using var verifyCtx = factory.CreateContext();
|
using var verifyCtx = factory.CreateContext();
|
||||||
@@ -860,7 +861,7 @@ public class TransactionServiceTests
|
|||||||
var current = new DateTime(2025, 6, 15);
|
var current = new DateTime(2025, 6, 15);
|
||||||
var expected = current.AddDays(expectedDays).AddMonths(expectedMonths).AddYears(expectedYears);
|
var expected = current.AddDays(expectedDays).AddMonths(expectedMonths).AddYears(expectedYears);
|
||||||
|
|
||||||
var result = TransactionService.GetNextOccurrence(current, type);
|
var result = TransactionRepository.GetNextOccurrence(current, type);
|
||||||
|
|
||||||
Assert.Equal(expected, result);
|
Assert.Equal(expected, result);
|
||||||
}
|
}
|
||||||
@@ -868,7 +869,7 @@ public class TransactionServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GetNextOccurrence_None_ReturnsMaxValue()
|
public void GetNextOccurrence_None_ReturnsMaxValue()
|
||||||
{
|
{
|
||||||
var result = TransactionService.GetNextOccurrence(DateTime.Today, RecurrenceType.None);
|
var result = TransactionRepository.GetNextOccurrence(DateTime.Today, RecurrenceType.None);
|
||||||
Assert.Equal(DateTime.MaxValue, result);
|
Assert.Equal(DateTime.MaxValue, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
|
<Project Path="BudgetApp.Data/BudgetApp.Data.csproj" />
|
||||||
<Project Path="BudgetApp.Tests/BudgetApp.Tests.csproj" />
|
<Project Path="BudgetApp.Tests/BudgetApp.Tests.csproj" />
|
||||||
<Project Path="BudgetApp/BudgetApp.csproj" />
|
<Project Path="BudgetApp/BudgetApp.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
@@ -12,15 +12,15 @@
|
|||||||
<InternalsVisibleTo Include="BudgetApp.Tests" />
|
<InternalsVisibleTo Include="BudgetApp.Tests" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\BudgetApp.Data\BudgetApp.Data.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Facebook" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@inject AccountService AccountService
|
@inject IAccountRepository AccountService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject AppState AppState
|
@inject AppState AppState
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
@@ -70,11 +70,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@if (accounts is not null && accounts.Count > 0)
|
@if (_accounts is not null && _accounts.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="nav-balances px-3">
|
<div class="nav-balances px-3">
|
||||||
<div class="balances-header">Balances</div>
|
<div class="balances-header">Balances</div>
|
||||||
@foreach (var account in accounts)
|
@foreach (var account in _accounts)
|
||||||
{
|
{
|
||||||
<div class="balance-item" style="cursor: pointer;" title="Click to manage accounts" @onclick="NavigateToAccounts">
|
<div class="balance-item" style="cursor: pointer;" title="Click to manage accounts" @onclick="NavigateToAccounts">
|
||||||
<div class="balance-color" style="background-color: @account.Color;"></div>
|
<div class="balance-color" style="background-color: @account.Color;"></div>
|
||||||
@@ -89,43 +89,3 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
|
||||||
private List<Account>? accounts;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
AppState.OnAccountsChanged += HandleAccountsChanged;
|
|
||||||
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
|
|
||||||
if (userId is not null)
|
|
||||||
{
|
|
||||||
accounts = await AccountService.GetAccountsAsync(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void HandleAccountsChanged()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
|
|
||||||
if (userId is not null)
|
|
||||||
{
|
|
||||||
accounts = await AccountService.GetAccountsAsync(userId);
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
AppState.OnAccountsChanged -= HandleAccountsChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void NavigateToAccounts()
|
|
||||||
{
|
|
||||||
Navigation.NavigateTo("accounts");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Layout;
|
||||||
|
|
||||||
|
public partial class NavMenu
|
||||||
|
{
|
||||||
|
private List<Account>? _accounts;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
AppState.OnAccountsChanged += HandleAccountsChanged;
|
||||||
|
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
|
if (userId is not null)
|
||||||
|
{
|
||||||
|
_accounts = await AccountService.GetAccountsAsync(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void HandleAccountsChanged()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
|
if (userId is not null)
|
||||||
|
{
|
||||||
|
_accounts = await AccountService.GetAccountsAsync(userId);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
AppState.OnAccountsChanged -= HandleAccountsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateToAccounts()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("accounts");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,10 @@
|
|||||||
A personal finance dashboard built with Blazor Server.
|
A personal finance dashboard built with Blazor Server.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@if (links.Count > 0)
|
@if (_links.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-center gap-3 mb-4">
|
<div class="d-flex justify-content-center gap-3 mb-4">
|
||||||
@foreach (var link in links)
|
@foreach (var link in _links)
|
||||||
{
|
{
|
||||||
<a href="@link.Url" target="_blank" rel="noopener noreferrer" class="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1" title="@link.Label">
|
<a href="@link.Url" target="_blank" rel="noopener noreferrer" class="btn btn-outline-secondary btn-sm d-flex align-items-center gap-1" title="@link.Label">
|
||||||
@((MarkupString)link.Icon) @link.Label
|
@((MarkupString)link.Icon) @link.Label
|
||||||
@@ -25,33 +25,3 @@
|
|||||||
<p class="text-center">
|
<p class="text-center">
|
||||||
<a href="/login">Sign in</a> or <a href="/register">Register</a>
|
<a href="/login">Sign in</a> or <a href="/register">Register</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@code {
|
|
||||||
private record ContactLink(string Label, string Url, string Icon);
|
|
||||||
private List<ContactLink> links = [];
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
var about = Configuration.GetSection("About");
|
|
||||||
|
|
||||||
var email = about["Email"];
|
|
||||||
if (!string.IsNullOrWhiteSpace(email))
|
|
||||||
links.Add(new("Email", $"mailto:{email}", "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558L0 4.697ZM6.761 8.83l-6.57 4.027A2 2 0 0 0 2 14h12a2 2 0 0 0 1.808-1.144l-6.57-4.027L8 9.586l-1.239-.757ZM16 11.801V4.697l-5.803 3.546L16 11.801Z'/></svg>"));
|
|
||||||
|
|
||||||
var discord = about["Discord"];
|
|
||||||
if (!string.IsNullOrWhiteSpace(discord))
|
|
||||||
links.Add(new("Discord", discord, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z'/></svg>"));
|
|
||||||
|
|
||||||
var github = about["GitHub"];
|
|
||||||
if (!string.IsNullOrWhiteSpace(github))
|
|
||||||
links.Add(new("GitHub", github, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z'/></svg>"));
|
|
||||||
|
|
||||||
var twitter = about["Twitter"];
|
|
||||||
if (!string.IsNullOrWhiteSpace(twitter))
|
|
||||||
links.Add(new("Twitter", twitter, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865l8.875 11.633Z'/></svg>"));
|
|
||||||
|
|
||||||
var mastodon = about["Mastodon"];
|
|
||||||
if (!string.IsNullOrWhiteSpace(mastodon))
|
|
||||||
links.Add(new("Mastodon", mastodon, "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765 2.79.765.504 1.783.504 5.253c-.005.76-.01 1.666.013 2.63.073 3.225.552 6.405 3.335 7.196 1.282.364 2.387.44 3.275.388 1.61-.094 2.515-.613 2.515-.613l-.053-1.154s-1.152.362-2.445.318c-1.28-.044-2.631-.139-2.838-1.723a3.2 3.2 0 0 1-.028-.462s1.255.309 2.846.382c.973.044 1.883-.058 2.808-.174Zm1.563-2.2v-4.14c0-.846-.215-1.518-.648-2.011-.447-.493-1.031-.745-1.758-.745-.842 0-1.48.324-1.9.974L8 4.66l-.447-.586c-.42-.65-1.058-.974-1.9-.974-.727 0-1.311.252-1.758.745-.434.493-.648 1.165-.648 2.012v4.139h1.686V5.12c0-.846.358-1.277 1.073-1.277.79 0 1.187.51 1.187 1.519V7.77h1.686V5.362c0-1.008.396-1.519 1.187-1.519.715 0 1.073.431 1.073 1.277v4.875h1.614Z'/></svg>"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class About
|
||||||
|
{
|
||||||
|
private record ContactLink(string Label, string Url, string Icon);
|
||||||
|
|
||||||
|
private readonly List<ContactLink> _links = [];
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var about = Configuration.GetSection("About");
|
||||||
|
|
||||||
|
var email = about["Email"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(email))
|
||||||
|
_links.Add(new("Email", $"mailto:{email}",
|
||||||
|
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414.05 3.555ZM0 4.697v7.104l5.803-3.558L0 4.697ZM6.761 8.83l-6.57 4.027A2 2 0 0 0 2 14h12a2 2 0 0 0 1.808-1.144l-6.57-4.027L8 9.586l-1.239-.757ZM16 11.801V4.697l-5.803 3.546L16 11.801Z'/></svg>"));
|
||||||
|
|
||||||
|
var discord = about["Discord"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(discord))
|
||||||
|
_links.Add(new("Discord", discord,
|
||||||
|
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z'/></svg>"));
|
||||||
|
|
||||||
|
var github = about["GitHub"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(github))
|
||||||
|
_links.Add(new("GitHub", github,
|
||||||
|
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z'/></svg>"));
|
||||||
|
|
||||||
|
var twitter = about["Twitter"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(twitter))
|
||||||
|
_links.Add(new("Twitter", twitter,
|
||||||
|
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865l8.875 11.633Z'/></svg>"));
|
||||||
|
|
||||||
|
var mastodon = about["Mastodon"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(mastodon))
|
||||||
|
_links.Add(new("Mastodon", mastodon,
|
||||||
|
"<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' viewBox='0 0 16 16'><path d='M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765 2.79.765.504 1.783.504 5.253c-.005.76-.01 1.666.013 2.63.073 3.225.552 6.405 3.335 7.196 1.282.364 2.387.44 3.275.388 1.61-.094 2.515-.613 2.515-.613l-.053-1.154s-1.152.362-2.445.318c-1.28-.044-2.631-.139-2.838-1.723a3.2 3.2 0 0 1-.028-.462s1.255.309 2.846.382c.973.044 1.883-.058 2.808-.174Zm1.563-2.2v-4.14c0-.846-.215-1.518-.648-2.011-.447-.493-1.031-.745-1.758-.745-.842 0-1.48.324-1.9.974L8 4.66l-.447-.586c-.42-.65-1.058-.974-1.9-.974-.727 0-1.311.252-1.758.745-.434.493-.648 1.165-.648 2.012v4.139h1.686V5.12c0-.846.358-1.277 1.073-1.277.79 0 1.187.51 1.187 1.519V7.77h1.686V5.362c0-1.008.396-1.519 1.187-1.519.715 0 1.073.431 1.073 1.277v4.875h1.614Z'/></svg>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@page "/accounts"
|
@page "/accounts"
|
||||||
|
@using BudgetApp.Data.Entities
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject AccountService AccountService
|
@inject IAccountRepository AccountService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject AppState AppState
|
@inject AppState AppState
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
@@ -15,20 +16,20 @@
|
|||||||
<h5 class="mb-0">Add Account</h5>
|
<h5 class="mb-0">Add Account</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<EditForm Model="formModel" OnValidSubmit="AddAccount" FormName="addAccount">
|
<EditForm Model="_formModel" OnValidSubmit="AddAccount" FormName="addAccount">
|
||||||
<ToastValidator />
|
<ToastValidator />
|
||||||
<div class="row g-3 align-items-end">
|
<div class="row g-3 align-items-end">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Account Name</label>
|
<label class="form-label">Account Name</label>
|
||||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Current Account" title="A friendly name for this account" />
|
<InputText @bind-Value="_formModel.Name" class="form-control" placeholder="e.g. Current Account" title="A friendly name for this account" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Current Balance</label>
|
<label class="form-label">Current Balance</label>
|
||||||
<InputNumber @bind-Value="formModel.CurrentBalance" class="form-control numeric-input" title="The opening balance for this account" />
|
<InputNumber @bind-Value="_formModel.CurrentBalance" class="form-control numeric-input" title="The opening balance for this account" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Currency</label>
|
<label class="form-label">Currency</label>
|
||||||
<InputSelect @bind-Value="formModel.CurrencyCode" class="form-select" title="Currency used for this account">
|
<InputSelect @bind-Value="_formModel.CurrencyCode" class="form-select" title="Currency used for this account">
|
||||||
@foreach (var kvp in Account.CurrencySymbols)
|
@foreach (var kvp in Account.CurrencySymbols)
|
||||||
{
|
{
|
||||||
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
|
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Colour</label>
|
<label class="form-label">Colour</label>
|
||||||
<InputText @bind-Value="formModel.Color" type="color" class="form-control form-control-color" title="Colour used to identify this account in charts and lists" />
|
<InputText @bind-Value="_formModel.Color" type="color" class="form-control form-control-color" title="Colour used to identify this account in charts and lists" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||||
@@ -47,31 +48,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (accounts is null)
|
@if (_accounts is null)
|
||||||
{
|
{
|
||||||
<p><em>Loading...</em></p>
|
<p><em>Loading...</em></p>
|
||||||
}
|
}
|
||||||
else if (accounts.Count == 0)
|
else if (_accounts.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">No accounts yet. Add one above to get started.</div>
|
<div class="alert alert-info">No accounts yet. Add one above to get started.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@foreach (var account in accounts)
|
@foreach (var account in _accounts)
|
||||||
{
|
{
|
||||||
<div class="col-md-4 col-sm-6 mb-3">
|
<div class="col-md-4 col-sm-6 mb-3">
|
||||||
<div class="card h-100" style="border-left: 4px solid @account.Color;">
|
<div class="card h-100" style="border-left: 4px solid @account.Color;">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (editingAccountId == account.Id)
|
@if (_editingAccountId == account.Id)
|
||||||
{
|
{
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label form-label-sm mb-1">Name</label>
|
<label class="form-label form-label-sm mb-1">Name</label>
|
||||||
<input type="text" class="form-control form-control-sm" @bind="editName" />
|
<input type="text" class="form-control form-control-sm" @bind="_editName" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label form-label-sm mb-1">Currency</label>
|
<label class="form-label form-label-sm mb-1">Currency</label>
|
||||||
<select class="form-select form-select-sm" @bind="editCurrencyCode">
|
<select class="form-select form-select-sm" @bind="_editCurrencyCode">
|
||||||
@foreach (var kvp in Account.CurrencySymbols)
|
@foreach (var kvp in Account.CurrencySymbols)
|
||||||
{
|
{
|
||||||
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
|
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
|
||||||
@@ -80,7 +81,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label form-label-sm mb-1">Colour</label>
|
<label class="form-label form-label-sm mb-1">Colour</label>
|
||||||
<input type="color" class="form-control form-control-color form-control-sm" @bind="editColor" />
|
<input type="color" class="form-control form-control-color form-control-sm" @bind="_editColor" />
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
<button class="btn btn-success btn-sm" @onclick="() => SaveAccountDetails(account.Id)">Save</button>
|
<button class="btn btn-success btn-sm" @onclick="() => SaveAccountDetails(account.Id)">Save</button>
|
||||||
@@ -100,13 +101,13 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (editingBalanceAccountId == account.Id)
|
@if (_editingBalanceAccountId == account.Id)
|
||||||
{
|
{
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<span class="input-group-text">@account.GetSymbol()</span>
|
<span class="input-group-text">@account.GetSymbol()</span>
|
||||||
<input type="number" class="form-control numeric-input" step="0.01"
|
<input type="number" class="form-control numeric-input" step="0.01"
|
||||||
@bind="editingBalanceValue" />
|
@bind="_editingBalanceValue" />
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex gap-1 mt-2">
|
<div class="d-flex gap-1 mt-2">
|
||||||
<button class="btn btn-success btn-sm flex-fill" @onclick="() => SaveBalance(account.Id)">Save</button>
|
<button class="btn btn-success btn-sm flex-fill" @onclick="() => SaveBalance(account.Id)">Save</button>
|
||||||
@@ -131,7 +132,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showDeleteModal && pendingDeleteAccount is not null)
|
@if (_showDeleteModal && _pendingDeleteAccount is not null)
|
||||||
{
|
{
|
||||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@@ -141,7 +142,7 @@ else
|
|||||||
<button type="button" class="btn-close" @onclick="CancelDeleteAccount"></button>
|
<button type="button" class="btn-close" @onclick="CancelDeleteAccount"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to delete <strong>@pendingDeleteAccount.Name</strong>?</p>
|
<p>Are you sure you want to delete <strong>@_pendingDeleteAccount.Name</strong>?</p>
|
||||||
<p class="text-danger mb-0">This will permanently remove the account and all its transactions.</p>
|
<p class="text-danger mb-0">This will permanently remove the account and all its transactions.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -154,138 +155,11 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Account>? accounts;
|
|
||||||
private AccountForm formModel = new();
|
|
||||||
private string? userId;
|
|
||||||
|
|
||||||
// Balance editing state
|
// Balance editing state
|
||||||
private int? editingBalanceAccountId;
|
|
||||||
private decimal editingBalanceValue;
|
|
||||||
|
|
||||||
// Account details editing state
|
// Account details editing state
|
||||||
private int? editingAccountId;
|
|
||||||
private string editName = string.Empty;
|
|
||||||
private string editColor = "#1b6ec2";
|
|
||||||
private string editCurrencyCode = "GBP";
|
|
||||||
|
|
||||||
// Delete confirmation state
|
// Delete confirmation state
|
||||||
private bool showDeleteModal;
|
|
||||||
private Account? pendingDeleteAccount;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
await LoadAccounts();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadAccounts()
|
|
||||||
{
|
|
||||||
if (userId is not null)
|
|
||||||
{
|
|
||||||
accounts = await AccountService.GetAccountsAsync(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddAccount()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
var account = new Account
|
|
||||||
{
|
|
||||||
Name = formModel.Name,
|
|
||||||
CurrentBalance = formModel.CurrentBalance,
|
|
||||||
Color = formModel.Color,
|
|
||||||
CurrencyCode = formModel.CurrencyCode,
|
|
||||||
UserId = userId
|
|
||||||
};
|
|
||||||
await AccountService.CreateAccountAsync(account);
|
|
||||||
formModel = new AccountForm();
|
|
||||||
await LoadAccounts();
|
|
||||||
AppState.NotifyAccountsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ConfirmDeleteAccount(Account account)
|
|
||||||
{
|
|
||||||
pendingDeleteAccount = account;
|
|
||||||
showDeleteModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAccountConfirmed()
|
|
||||||
{
|
|
||||||
if (userId is null || pendingDeleteAccount is null) return;
|
|
||||||
|
|
||||||
showDeleteModal = false;
|
|
||||||
await AccountService.DeleteAccountAsync(pendingDeleteAccount.Id, userId);
|
|
||||||
pendingDeleteAccount = null;
|
|
||||||
await LoadAccounts();
|
|
||||||
AppState.NotifyAccountsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelDeleteAccount()
|
|
||||||
{
|
|
||||||
showDeleteModal = false;
|
|
||||||
pendingDeleteAccount = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartBalanceEdit(Account account)
|
|
||||||
{
|
|
||||||
editingBalanceAccountId = account.Id;
|
|
||||||
editingBalanceValue = account.CurrentBalance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelBalanceEdit()
|
|
||||||
{
|
|
||||||
editingBalanceAccountId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveBalance(int accountId)
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
await AccountService.UpdateBalanceAsync(accountId, userId, editingBalanceValue);
|
|
||||||
editingBalanceAccountId = null;
|
|
||||||
await LoadAccounts();
|
|
||||||
AppState.NotifyAccountsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartAccountEdit(Account account)
|
|
||||||
{
|
|
||||||
editingAccountId = account.Id;
|
|
||||||
editName = account.Name;
|
|
||||||
editColor = account.Color;
|
|
||||||
editCurrencyCode = account.CurrencyCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelAccountEdit()
|
|
||||||
{
|
|
||||||
editingAccountId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveAccountDetails(int accountId)
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
await AccountService.UpdateAccountDetailsAsync(accountId, userId, editName, editColor, editCurrencyCode);
|
|
||||||
editingAccountId = null;
|
|
||||||
await LoadAccounts();
|
|
||||||
AppState.NotifyAccountsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class AccountForm
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Account name is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
public decimal CurrentBalance { get; set; }
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required]
|
|
||||||
[System.ComponentModel.DataAnnotations.MaxLength(7)]
|
|
||||||
public string Color { get; set; } = "#1b6ec2";
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required]
|
|
||||||
[System.ComponentModel.DataAnnotations.MaxLength(3)]
|
|
||||||
public string CurrencyCode { get; set; } = "GBP";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
|
||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Accounts
|
||||||
|
{
|
||||||
|
private List<Account>? _accounts;
|
||||||
|
private AccountForm _formModel = new();
|
||||||
|
private string? _userId;
|
||||||
|
private int? _editingBalanceAccountId;
|
||||||
|
private decimal _editingBalanceValue;
|
||||||
|
private int? _editingAccountId;
|
||||||
|
private string _editName = string.Empty;
|
||||||
|
private string _editColor = "#1b6ec2";
|
||||||
|
private string _editCurrencyCode = "GBP";
|
||||||
|
private bool _showDeleteModal;
|
||||||
|
private Account? _pendingDeleteAccount;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
await LoadAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadAccounts()
|
||||||
|
{
|
||||||
|
if (_userId is not null)
|
||||||
|
{
|
||||||
|
_accounts = await AccountService.GetAccountsAsync(_userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddAccount()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
var account = new Account
|
||||||
|
{
|
||||||
|
Name = _formModel.Name,
|
||||||
|
CurrentBalance = _formModel.CurrentBalance,
|
||||||
|
Color = _formModel.Color,
|
||||||
|
CurrencyCode = _formModel.CurrencyCode,
|
||||||
|
UserId = _userId
|
||||||
|
};
|
||||||
|
await AccountService.CreateAccountAsync(account);
|
||||||
|
_formModel = new AccountForm();
|
||||||
|
await LoadAccounts();
|
||||||
|
AppState.NotifyAccountsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfirmDeleteAccount(Account account)
|
||||||
|
{
|
||||||
|
_pendingDeleteAccount = account;
|
||||||
|
_showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAccountConfirmed()
|
||||||
|
{
|
||||||
|
if (_userId is null || _pendingDeleteAccount is null) return;
|
||||||
|
|
||||||
|
_showDeleteModal = false;
|
||||||
|
await AccountService.DeleteAccountAsync(_pendingDeleteAccount.Id, _userId);
|
||||||
|
_pendingDeleteAccount = null;
|
||||||
|
await LoadAccounts();
|
||||||
|
AppState.NotifyAccountsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelDeleteAccount()
|
||||||
|
{
|
||||||
|
_showDeleteModal = false;
|
||||||
|
_pendingDeleteAccount = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartBalanceEdit(Account account)
|
||||||
|
{
|
||||||
|
_editingBalanceAccountId = account.Id;
|
||||||
|
_editingBalanceValue = account.CurrentBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelBalanceEdit()
|
||||||
|
{
|
||||||
|
_editingBalanceAccountId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveBalance(int accountId)
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
await AccountService.UpdateBalanceAsync(accountId, _userId, _editingBalanceValue);
|
||||||
|
_editingBalanceAccountId = null;
|
||||||
|
await LoadAccounts();
|
||||||
|
AppState.NotifyAccountsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartAccountEdit(Account account)
|
||||||
|
{
|
||||||
|
_editingAccountId = account.Id;
|
||||||
|
_editName = account.Name;
|
||||||
|
_editColor = account.Color;
|
||||||
|
_editCurrencyCode = account.CurrencyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelAccountEdit()
|
||||||
|
{
|
||||||
|
_editingAccountId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveAccountDetails(int accountId)
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
await AccountService.UpdateAccountDetailsAsync(accountId, _userId, _editName, _editColor, _editCurrencyCode);
|
||||||
|
_editingAccountId = null;
|
||||||
|
await LoadAccounts();
|
||||||
|
AppState.NotifyAccountsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AccountForm
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Account name is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal CurrentBalance { get; set; }
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
|
[System.ComponentModel.DataAnnotations.MaxLength(7)]
|
||||||
|
public string Color { get; set; } = "#1b6ec2";
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required]
|
||||||
|
[System.ComponentModel.DataAnnotations.MaxLength(3)]
|
||||||
|
public string CurrencyCode { get; set; } = "GBP";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@page "/categories"
|
@page "/categories"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject CategoryService CategoryService
|
@inject ICategoryRepository CategoryService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@@ -14,17 +14,17 @@
|
|||||||
<h5 class="mb-0">Add Category</h5>
|
<h5 class="mb-0">Add Category</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<EditForm Model="formModel" OnValidSubmit="AddCategory" FormName="addCategory">
|
<EditForm Model="_formModel" OnValidSubmit="AddCategory" FormName="addCategory">
|
||||||
<ToastValidator />
|
<ToastValidator />
|
||||||
<div class="row g-3 align-items-end">
|
<div class="row g-3 align-items-end">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label">Category Name</label>
|
<label class="form-label">Category Name</label>
|
||||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Groceries, Rent, Salary" title="A name to group related transactions" />
|
<InputText @bind-Value="_formModel.Name" class="form-control" placeholder="e.g. Groceries, Rent, Salary" title="A name to group related transactions" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Icon</label>
|
<label class="form-label">Icon</label>
|
||||||
<button type="button" class="btn btn-outline-secondary w-100" style="font-size: 1.4rem; height: 38px; padding: 0;"
|
<button type="button" class="btn btn-outline-secondary w-100" style="font-size: 1.4rem; height: 38px; padding: 0;"
|
||||||
title="Click to choose an emoji icon" @onclick="OpenAddIconPicker">@(string.IsNullOrEmpty(formModel.Icon) ? "📁" : formModel.Icon)</button>
|
title="Click to choose an emoji icon" @onclick="OpenAddIconPicker">@(string.IsNullOrEmpty(_formModel.Icon) ? "📁" : _formModel.Icon)</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||||
@@ -34,29 +34,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (categories is null)
|
@if (_categories is null)
|
||||||
{
|
{
|
||||||
<p><em>Loading...</em></p>
|
<p><em>Loading...</em></p>
|
||||||
}
|
}
|
||||||
else if (categories.Count == 0)
|
else if (_categories.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">No categories yet. Add one above to organise your transactions.</div>
|
<div class="alert alert-info">No categories yet. Add one above to organise your transactions.</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 g-3">
|
<div class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 g-3">
|
||||||
@foreach (var category in categories)
|
@foreach (var category in _categories)
|
||||||
{
|
{
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card h-100 text-center">
|
<div class="card h-100 text-center">
|
||||||
<div class="card-body d-flex flex-column align-items-center justify-content-center">
|
<div class="card-body d-flex flex-column align-items-center justify-content-center">
|
||||||
@if (editingCategoryId == category.Id)
|
@if (_editingCategoryId == category.Id)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-outline-secondary" style="font-size: 2rem; width: 52px; height: 52px; padding: 0;"
|
<button type="button" class="btn btn-outline-secondary" style="font-size: 2rem; width: 52px; height: 52px; padding: 0;"
|
||||||
@onclick="OpenEditIconPicker">@(string.IsNullOrEmpty(editIcon) ? "📁" : editIcon)</button>
|
@onclick="OpenEditIconPicker">@(string.IsNullOrEmpty(_editIcon) ? "📁" : _editIcon)</button>
|
||||||
<div class="mt-2" style="height: 31px; width: 100%;">
|
<div class="mt-2" style="height: 31px; width: 100%;">
|
||||||
<input type="text" class="form-control form-control-sm text-center"
|
<input type="text" class="form-control form-control-sm text-center"
|
||||||
style="font-size: 1rem; font-weight: 500;" @bind="editCategoryName" />
|
style="font-size: 1rem; font-weight: 500;" @bind="_editCategoryName" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -73,7 +73,7 @@ else
|
|||||||
{
|
{
|
||||||
<span class="badge bg-secondary" title="Default categories cannot be edited or deleted">Default</span>
|
<span class="badge bg-secondary" title="Default categories cannot be edited or deleted">Default</span>
|
||||||
}
|
}
|
||||||
else if (editingCategoryId == category.Id)
|
else if (_editingCategoryId == category.Id)
|
||||||
{
|
{
|
||||||
<button class="btn btn-success btn-sm" @onclick="() => SaveCategoryEdit(category.Id)">Save</button>
|
<button class="btn btn-success btn-sm" @onclick="() => SaveCategoryEdit(category.Id)">Save</button>
|
||||||
<button class="btn btn-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
|
<button class="btn btn-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
|
||||||
@@ -91,7 +91,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showIconModal)
|
@if (_showIconModal)
|
||||||
{
|
{
|
||||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);" @onclick="CloseIconModal">
|
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);" @onclick="CloseIconModal">
|
||||||
<div class="modal-dialog modal-dialog-centered modal-sm" @onclick:stopPropagation="true">
|
<div class="modal-dialog modal-dialog-centered modal-sm" @onclick:stopPropagation="true">
|
||||||
@@ -115,118 +115,9 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Category>? categories;
|
|
||||||
private CategoryForm formModel = new();
|
|
||||||
private string? userId;
|
|
||||||
|
|
||||||
// Edit state
|
// Edit state
|
||||||
private int? editingCategoryId;
|
|
||||||
private string editCategoryName = string.Empty;
|
|
||||||
private string editIcon = string.Empty;
|
|
||||||
|
|
||||||
// Icon modal state
|
// Icon modal state
|
||||||
private bool showIconModal;
|
|
||||||
private string iconPickerTarget = "add"; // "add" or "edit"
|
|
||||||
|
|
||||||
private static readonly string[] AvailableIcons =
|
|
||||||
[
|
|
||||||
"🛒", "🏠", "💡", "🚗", "🍽️", "🎬", "🏥", "🛡️", "💰", "🏦",
|
|
||||||
"📱", "🛍️", "📚", "✈️", "🎁", "🏋️", "🎮", "🎵", "🐾", "☕",
|
|
||||||
"🍕", "👶", "💼", "🔧", "📦", "💊", "🎨", "🌿", "🏢", "📰",
|
|
||||||
"💳", "🧾", "📋", "🔌", "🚌", "🚀", "🎂", "🧹", "📸", "💎"
|
|
||||||
];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
await LoadCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadCategories()
|
|
||||||
{
|
|
||||||
if (userId is not null)
|
|
||||||
{
|
|
||||||
await CategoryService.EnsureDefaultCategoriesAsync(userId);
|
|
||||||
categories = await CategoryService.GetCategoriesAsync(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddCategory()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
var category = new Category
|
|
||||||
{
|
|
||||||
Name = formModel.Name,
|
|
||||||
Icon = formModel.Icon,
|
|
||||||
UserId = userId
|
|
||||||
};
|
|
||||||
await CategoryService.CreateCategoryAsync(category);
|
|
||||||
formModel = new CategoryForm();
|
|
||||||
await LoadCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartCategoryEdit(Category category)
|
|
||||||
{
|
|
||||||
editingCategoryId = category.Id;
|
|
||||||
editCategoryName = category.Name;
|
|
||||||
editIcon = category.Icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelCategoryEdit()
|
|
||||||
{
|
|
||||||
editingCategoryId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveCategoryEdit(int id)
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
await CategoryService.UpdateCategoryAsync(id, userId, editCategoryName, editIcon);
|
|
||||||
editingCategoryId = null;
|
|
||||||
await LoadCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteCategory(int id)
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
await CategoryService.DeleteCategoryAsync(id, userId);
|
|
||||||
await LoadCategories();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenAddIconPicker()
|
|
||||||
{
|
|
||||||
iconPickerTarget = "add";
|
|
||||||
showIconModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenEditIconPicker()
|
|
||||||
{
|
|
||||||
iconPickerTarget = "edit";
|
|
||||||
showIconModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseIconModal() => showIconModal = false;
|
|
||||||
|
|
||||||
private void SelectIcon(string emoji)
|
|
||||||
{
|
|
||||||
if (iconPickerTarget == "add")
|
|
||||||
formModel.Icon = emoji;
|
|
||||||
else
|
|
||||||
editIcon = emoji;
|
|
||||||
|
|
||||||
showIconModal = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class CategoryForm
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Category name is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.MaxLength(10)]
|
|
||||||
public string Icon { get; set; } = "📁";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
|
||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Categories
|
||||||
|
{
|
||||||
|
private List<Category>? _categories;
|
||||||
|
private CategoryForm _formModel = new();
|
||||||
|
private string? _userId;
|
||||||
|
private int? _editingCategoryId;
|
||||||
|
private string _editCategoryName = string.Empty;
|
||||||
|
private string _editIcon = string.Empty;
|
||||||
|
private bool _showIconModal;
|
||||||
|
private string _iconPickerTarget = "add"; // "add" or "edit"
|
||||||
|
|
||||||
|
private static readonly string[] AvailableIcons =
|
||||||
|
[
|
||||||
|
"??", "??", "??", "??", "???", "??", "??", "???", "??", "??",
|
||||||
|
"??", "???", "??", "??", "??", "???", "??", "??", "??", "?",
|
||||||
|
"??", "??", "??", "??", "??", "??", "??", "??", "??", "??",
|
||||||
|
"??", "??", "??", "??", "??", "??", "??", "??", "??", "??"
|
||||||
|
];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
await LoadCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCategories()
|
||||||
|
{
|
||||||
|
if (_userId is not null)
|
||||||
|
{
|
||||||
|
await CategoryService.EnsureDefaultCategoriesAsync(_userId);
|
||||||
|
_categories = await CategoryService.GetCategoriesAsync(_userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddCategory()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
var category = new Category
|
||||||
|
{
|
||||||
|
Name = _formModel.Name,
|
||||||
|
Icon = _formModel.Icon,
|
||||||
|
UserId = _userId
|
||||||
|
};
|
||||||
|
await CategoryService.CreateCategoryAsync(category);
|
||||||
|
_formModel = new CategoryForm();
|
||||||
|
await LoadCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartCategoryEdit(Category category)
|
||||||
|
{
|
||||||
|
_editingCategoryId = category.Id;
|
||||||
|
_editCategoryName = category.Name;
|
||||||
|
_editIcon = category.Icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelCategoryEdit()
|
||||||
|
{
|
||||||
|
_editingCategoryId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveCategoryEdit(int id)
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
await CategoryService.UpdateCategoryAsync(id, _userId, _editCategoryName, _editIcon);
|
||||||
|
_editingCategoryId = null;
|
||||||
|
await LoadCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteCategory(int id)
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
await CategoryService.DeleteCategoryAsync(id, _userId);
|
||||||
|
await LoadCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenAddIconPicker()
|
||||||
|
{
|
||||||
|
_iconPickerTarget = "add";
|
||||||
|
_showIconModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditIconPicker()
|
||||||
|
{
|
||||||
|
_iconPickerTarget = "edit";
|
||||||
|
_showIconModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseIconModal() => _showIconModal = false;
|
||||||
|
|
||||||
|
private void SelectIcon(string emoji)
|
||||||
|
{
|
||||||
|
if (_iconPickerTarget == "add")
|
||||||
|
_formModel.Icon = emoji;
|
||||||
|
else
|
||||||
|
_editIcon = emoji;
|
||||||
|
|
||||||
|
_showIconModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CategoryForm
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Category name is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.MaxLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.MaxLength(10)]
|
||||||
|
public string Icon { get; set; } = "??";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject DashboardService DashboardService
|
@inject DashboardService DashboardService
|
||||||
@inject TransactionService TransactionService
|
@inject ITransactionRepository TransactionService
|
||||||
@inject SalaryService SalaryService
|
@inject ISalaryRepository SalaryService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime Js
|
||||||
|
@using BudgetApp.Data.Entities
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@@ -13,11 +14,11 @@
|
|||||||
<h3 class="mb-2">Dashboard</h3>
|
<h3 class="mb-2">Dashboard</h3>
|
||||||
<p class="text-muted mb-4">An overview of your balances, recent and upcoming transactions.</p>
|
<p class="text-muted mb-4">An overview of your balances, recent and upcoming transactions.</p>
|
||||||
|
|
||||||
@if (data is null)
|
@if (_data is null)
|
||||||
{
|
{
|
||||||
<p><em>Loading...</em></p>
|
<p><em>Loading...</em></p>
|
||||||
}
|
}
|
||||||
else if (data.Accounts.Count == 0)
|
else if (_data.Accounts.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<h5>Welcome!</h5>
|
<h5>Welcome!</h5>
|
||||||
@@ -31,16 +32,16 @@ else
|
|||||||
<h5 class="mb-0">Balance Trend</h5>
|
<h5 class="mb-0">Balance Trend</h5>
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn @(windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
<button class="btn @(_windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
||||||
<button class="btn @(windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
<button class="btn @(_windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
||||||
<button class="btn @(windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
<button class="btn @(_windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
||||||
<button class="btn @(windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
<button class="btn @(_windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="date" class="form-control form-control-sm date-picker"
|
<input type="date" class="form-control form-control-sm date-picker"
|
||||||
title="Custom start date" @bind="windowStart" @bind:after="OnWindowDateChanged" />
|
title="Custom start date" @bind="_windowStart" @bind:after="OnWindowDateChanged" />
|
||||||
<span class="text-muted">to</span>
|
<span class="text-muted">to</span>
|
||||||
<input type="date" class="form-control form-control-sm date-picker"
|
<input type="date" class="form-control form-control-sm date-picker"
|
||||||
title="Custom end date" @bind="windowEnd" @bind:after="OnWindowDateChanged" />
|
title="Custom end date" @bind="_windowEnd" @bind:after="OnWindowDateChanged" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -51,41 +52,41 @@ else
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── Edit / Add Modal ── *@
|
@* ── Edit / Add Modal ── *@
|
||||||
@if (showModal)
|
@if (_showModal)
|
||||||
{
|
{
|
||||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||||
<div class="modal-dialog modal-dialog-scrollable">
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">@(editingTransactionId is null ? "Add Transaction" : "Edit Transaction")</h5>
|
<h5 class="modal-title">@(_editingTransactionId is null ? "Add Transaction" : "Edit Transaction")</h5>
|
||||||
<button type="button" class="btn-close" @onclick="CloseModal"></button>
|
<button type="button" class="btn-close" @onclick="CloseModal"></button>
|
||||||
</div>
|
</div>
|
||||||
<EditForm Model="modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
|
<EditForm Model="_modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
|
||||||
<ToastValidator />
|
<ToastValidator />
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Description</label>
|
<label class="form-label">Description</label>
|
||||||
<InputText @bind-Value="modalForm.Description" class="form-control" />
|
<InputText @bind-Value="_modalForm.Description" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Amount</label>
|
<label class="form-label">Amount</label>
|
||||||
<InputNumber @bind-Value="modalForm.Amount" class="form-control numeric-input" />
|
<InputNumber @bind-Value="_modalForm.Amount" class="form-control numeric-input" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Date</label>
|
<label class="form-label">Date</label>
|
||||||
<InputDate @bind-Value="modalForm.Date" class="form-control" />
|
<InputDate @bind-Value="_modalForm.Date" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Type</label>
|
<label class="form-label">Type</label>
|
||||||
<InputSelect @bind-Value="modalForm.Type" class="form-select">
|
<InputSelect @bind-Value="_modalForm.Type" class="form-select">
|
||||||
<option value="@TransactionType.Expense">Expense</option>
|
<option value="@TransactionType.Expense">Expense</option>
|
||||||
<option value="@TransactionType.Income">Income</option>
|
<option value="@TransactionType.Income">Income</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Account</label>
|
<label class="form-label">Account</label>
|
||||||
<InputSelect @bind-Value="modalForm.AccountId" class="form-select">
|
<InputSelect @bind-Value="_modalForm.AccountId" class="form-select">
|
||||||
@foreach (var a in data.Accounts)
|
@foreach (var a in _data.Accounts)
|
||||||
{
|
{
|
||||||
<option value="@a.Id">@a.Name</option>
|
<option value="@a.Id">@a.Name</option>
|
||||||
}
|
}
|
||||||
@@ -93,19 +94,19 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Category</label>
|
<label class="form-label">Category</label>
|
||||||
<InputSelect @bind-Value="modalForm.CategoryId" class="form-select">
|
<InputSelect @bind-Value="_modalForm.CategoryId" class="form-select">
|
||||||
<option value="">— None —</option>
|
<option value="">— None —</option>
|
||||||
@foreach (var c in data.Categories)
|
@foreach (var c in _data.Categories)
|
||||||
{
|
{
|
||||||
<option value="@c.Id">@(string.IsNullOrEmpty(c.Icon) ? "" : c.Icon + " ")@c.Name</option>
|
<option value="@c.Id">@(string.IsNullOrEmpty(c.Icon) ? "" : c.Icon + " ")@c.Name</option>
|
||||||
}
|
}
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
@if (editingTransactionId is null)
|
@if (_editingTransactionId is null)
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Recurrence</label>
|
<label class="form-label">Recurrence</label>
|
||||||
<InputSelect @bind-Value="modalForm.RecurrenceType" class="form-select">
|
<InputSelect @bind-Value="_modalForm.RecurrenceType" class="form-select">
|
||||||
<option value="@RecurrenceType.None">None</option>
|
<option value="@RecurrenceType.None">None</option>
|
||||||
<option value="@RecurrenceType.Daily">Daily</option>
|
<option value="@RecurrenceType.Daily">Daily</option>
|
||||||
<option value="@RecurrenceType.Weekly">Weekly</option>
|
<option value="@RecurrenceType.Weekly">Weekly</option>
|
||||||
@@ -113,15 +114,15 @@ else
|
|||||||
<option value="@RecurrenceType.Yearly">Yearly</option>
|
<option value="@RecurrenceType.Yearly">Yearly</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
@if (modalForm.RecurrenceType != RecurrenceType.None)
|
@if (_modalForm.RecurrenceType != RecurrenceType.None)
|
||||||
{
|
{
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Recurrence End Date <small class="text-muted">(max 1 year, defaults to 1 year if blank)</small></label>
|
<label class="form-label">Recurrence End Date <small class="text-muted">(max 1 year, defaults to 1 year if blank)</small></label>
|
||||||
<InputDate @bind-Value="modalForm.RecurrenceEndDate" class="form-control" max="@modalForm.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
<InputDate @bind-Value="_modalForm.RecurrenceEndDate" class="form-control" max="@_modalForm.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (editIsPartOfSeries)
|
else if (_editIsPartOfSeries)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info mb-0">
|
<div class="alert alert-info mb-0">
|
||||||
<i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
|
<i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
|
||||||
@@ -142,31 +143,31 @@ else
|
|||||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
<ul class="nav nav-tabs card-header-tabs" style="flex-grow: 0;">
|
<ul class="nav nav-tabs card-header-tabs" style="flex-grow: 0;">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "recent" ? "active" : "")" title="Transactions that have already occurred" @onclick='() => { activeTab = "recent"; recentPage = 1; }'>
|
<button class="nav-link @(_activeTab == "recent" ? "active" : "")" title="Transactions that have already occurred" @onclick='() => { _activeTab = "recent"; _recentPage = 1; }'>
|
||||||
Recent <span class="badge bg-secondary ms-1">@filteredRecent.Count</span>
|
Recent <span class="badge bg-secondary ms-1">@FilteredRecent.Count</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<button class="nav-link @(activeTab == "upcoming" ? "active" : "")" title="Scheduled future transactions" @onclick='() => { activeTab = "upcoming"; upcomingPage = 1; }'>
|
<button class="nav-link @(_activeTab == "upcoming" ? "active" : "")" title="Scheduled future transactions" @onclick='() => { _activeTab = "upcoming"; _upcomingPage = 1; }'>
|
||||||
Upcoming <span class="badge bg-secondary ms-1">@filteredUpcoming.Count</span>
|
Upcoming <span class="badge bg-secondary ms-1">@FilteredUpcoming.Count</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<input type="search" class="form-control form-control-sm" style="max-width: 180px;"
|
<input type="search" class="form-control form-control-sm" style="max-width: 180px;"
|
||||||
title="Filter by description, account, category or amount" placeholder="Search..." @bind="dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
|
title="Filter by description, account, category or amount" placeholder="Search..." @bind="_dashboardSearchQuery" @bind:event="oninput" @bind:after="OnDashboardSearchChanged" />
|
||||||
<button class="btn btn-sm btn-primary" title="Quickly add a new transaction" @onclick="OpenAddModal">+ Add</button>
|
<button class="btn btn-sm btn-primary" title="Quickly add a new transaction" @onclick="OpenAddModal">+ Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (data.Accounts.Count > 1)
|
@if (_data.Accounts.Count > 1)
|
||||||
{
|
{
|
||||||
<div class="px-3 py-2 border-bottom d-flex flex-wrap align-items-center gap-2">
|
<div class="px-3 py-2 border-bottom d-flex flex-wrap align-items-center gap-2">
|
||||||
<small class="text-muted me-1">Filter:</small>
|
<small class="text-muted me-1">Filter:</small>
|
||||||
@foreach (var a in data.Accounts)
|
@foreach (var a in _data.Accounts)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm px-2 py-0 @(hiddenAccountIds.Contains(a.Id) ? "btn-outline-secondary" : "")"
|
<button class="btn btn-sm px-2 py-0 @(_hiddenAccountIds.Contains(a.Id) ? "btn-outline-secondary" : "")"
|
||||||
style="font-size: 0.75rem; @(hiddenAccountIds.Contains(a.Id) ? "opacity: 0.5;" : $"background-color: {a.Color}; color: white;")"
|
style="font-size: 0.75rem; @(_hiddenAccountIds.Contains(a.Id) ? "opacity: 0.5;" : $"background-color: {a.Color}; color: white;")"
|
||||||
title="@(hiddenAccountIds.Contains(a.Id) ? $"Show {a.Name} transactions" : $"Hide {a.Name} transactions")"
|
title="@(_hiddenAccountIds.Contains(a.Id) ? $"Show {a.Name} transactions" : $"Hide {a.Name} transactions")"
|
||||||
@onclick="() => ToggleAccountFilter(a.Id)">
|
@onclick="() => ToggleAccountFilter(a.Id)">
|
||||||
@a.Name
|
@a.Name
|
||||||
</button>
|
</button>
|
||||||
@@ -174,18 +175,18 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
||||||
@if (activeTab == "recent")
|
@if (_activeTab == "recent")
|
||||||
{
|
{
|
||||||
@if (filteredRecent.Count == 0)
|
@if (FilteredRecent.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="p-3 text-muted mb-0">No recent transactions.</p>
|
<p class="p-3 text-muted mb-0">No recent transactions.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
@foreach (var t in filteredRecent.Skip((recentPage - 1) * PageSize).Take(PageSize))
|
@foreach (var t in FilteredRecent.Skip((_recentPage - 1) * PageSize).Take(PageSize))
|
||||||
{
|
{
|
||||||
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
<div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
||||||
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
|
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
|
||||||
@onclick="() => SelectTransaction(t.Id)">
|
@onclick="() => SelectTransaction(t.Id)">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
@@ -217,7 +218,7 @@ else
|
|||||||
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
|
<small class="text-muted" style="font-size: 0.7rem;">@(t.Type == TransactionType.Income ? "Income" : "Expense")</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (selectedTransactionId == t.Id)
|
@if (_selectedTransactionId == t.Id)
|
||||||
{
|
{
|
||||||
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
|
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
|
||||||
@if (t.IsPaid)
|
@if (t.IsPaid)
|
||||||
@@ -240,25 +241,25 @@ else
|
|||||||
@if (RecentTotalPages > 1)
|
@if (RecentTotalPages > 1)
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
||||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage <= 1 ? true : (bool?)null)" @onclick="() => recentPage--">← Prev</button>
|
<button class="btn btn-sm btn-outline-secondary" disabled="@(_recentPage <= 1 ? true : (bool?)null)" @onclick="() => _recentPage--">← Prev</button>
|
||||||
<small class="text-muted">Page @recentPage of @RecentTotalPages</small>
|
<small class="text-muted">Page @_recentPage of @RecentTotalPages</small>
|
||||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(recentPage >= RecentTotalPages ? true : (bool?)null)" @onclick="() => recentPage++">Next →</button>
|
<button class="btn btn-sm btn-outline-secondary" disabled="@(_recentPage >= RecentTotalPages ? true : (bool?)null)" @onclick="() => _recentPage++">Next →</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@if (filteredUpcoming.Count == 0)
|
@if (FilteredUpcoming.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="p-3 text-muted mb-0">No upcoming transactions.</p>
|
<p class="p-3 text-muted mb-0">No upcoming transactions.</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
@foreach (var t in filteredUpcoming.Skip((upcomingPage - 1) * PageSize).Take(PageSize))
|
@foreach (var t in FilteredUpcoming.Skip((_upcomingPage - 1) * PageSize).Take(PageSize))
|
||||||
{
|
{
|
||||||
<div class="list-group-item list-group-item-action txn-row @(selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
<div class="list-group-item list-group-item-action txn-row @(_selectedTransactionId == t.Id ? "txn-row-selected" : "")"
|
||||||
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
|
style="cursor: pointer; border-left: 3px solid @(t.Type == TransactionType.Income ? "#198754" : "#dc3545");"
|
||||||
@onclick="() => SelectTransaction(t.Id)">
|
@onclick="() => SelectTransaction(t.Id)">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
@@ -283,7 +284,7 @@ else
|
|||||||
<div class="@(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold" style="font-size: 1.05rem;">
|
<div class="@(t.Type == TransactionType.Income ? "text-success" : "text-danger") fw-semibold" style="font-size: 1.05rem;">
|
||||||
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
|
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
|
||||||
</div>
|
</div>
|
||||||
@if (projectedBalances.TryGetValue(t.Id, out var projected))
|
@if (_projectedBalances.TryGetValue(t.Id, out var projected))
|
||||||
{
|
{
|
||||||
<small class="@(projected >= 0 ? "text-success" : "text-danger")" style="font-size: 0.7rem;">
|
<small class="@(projected >= 0 ? "text-success" : "text-danger")" style="font-size: 0.7rem;">
|
||||||
Projected: @t.Account.FormatAmount(projected)
|
Projected: @t.Account.FormatAmount(projected)
|
||||||
@@ -295,7 +296,7 @@ else
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (selectedTransactionId == t.Id)
|
@if (_selectedTransactionId == t.Id)
|
||||||
{
|
{
|
||||||
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
|
<div class="mt-2 pt-2 border-top d-flex justify-content-between align-items-center gap-2">
|
||||||
<button class="btn btn-outline-success btn-sm" title="Mark as paid and move to Recent" @onclick="() => MarkAsPaid(t.Id, true)" @onclick:stopPropagation="true">✅ Mark as Paid</button>
|
<button class="btn btn-outline-success btn-sm" title="Mark as paid and move to Recent" @onclick="() => MarkAsPaid(t.Id, true)" @onclick:stopPropagation="true">✅ Mark as Paid</button>
|
||||||
@@ -311,9 +312,9 @@ else
|
|||||||
@if (UpcomingTotalPages > 1)
|
@if (UpcomingTotalPages > 1)
|
||||||
{
|
{
|
||||||
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
|
||||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage <= 1 ? true : (bool?)null)" @onclick="() => upcomingPage--">← Prev</button>
|
<button class="btn btn-sm btn-outline-secondary" disabled="@(_upcomingPage <= 1 ? true : (bool?)null)" @onclick="() => _upcomingPage--">← Prev</button>
|
||||||
<small class="text-muted">Page @upcomingPage of @UpcomingTotalPages</small>
|
<small class="text-muted">Page @_upcomingPage of @UpcomingTotalPages</small>
|
||||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(upcomingPage >= UpcomingTotalPages ? true : (bool?)null)" @onclick="() => upcomingPage++">Next →</button>
|
<button class="btn btn-sm btn-outline-secondary" disabled="@(_upcomingPage >= UpcomingTotalPages ? true : (bool?)null)" @onclick="() => _upcomingPage++">Next →</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,18 +322,18 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showDeleteModal && pendingDeleteTransaction is not null)
|
@if (_showDeleteModal && _pendingDeleteTransaction is not null)
|
||||||
{
|
{
|
||||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">@(pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction")</h5>
|
<h5 class="modal-title">@(_pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction")</h5>
|
||||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
|
<p>Are you sure you want to delete <strong>@_pendingDeleteTransaction.Description</strong>?</p>
|
||||||
@if (pendingDeleteIsRecurring)
|
@if (_pendingDeleteIsRecurring)
|
||||||
{
|
{
|
||||||
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
|
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
|
||||||
}
|
}
|
||||||
@@ -343,7 +344,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||||
@if (pendingDeleteIsRecurring)
|
@if (_pendingDeleteIsRecurring)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
||||||
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
||||||
@@ -360,371 +361,17 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private DashboardData? data;
|
|
||||||
private string? userId;
|
|
||||||
private int? selectedTransactionId;
|
|
||||||
private string activeTab = "recent";
|
|
||||||
private Dictionary<int, decimal> projectedBalances = new();
|
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
private bool showModal;
|
|
||||||
private int? editingTransactionId;
|
|
||||||
private bool editIsPartOfSeries;
|
|
||||||
private ModalTransactionForm modalForm = new();
|
|
||||||
|
|
||||||
// Chart redraw tracking
|
// Chart redraw tracking
|
||||||
private int chartDataVersion;
|
|
||||||
private int lastRenderedChartVersion;
|
|
||||||
|
|
||||||
// Time window
|
// Time window
|
||||||
private DateTime windowStart = DateTime.Today;
|
|
||||||
private DateTime windowEnd = DateTime.Today.AddDays(30);
|
|
||||||
private string windowPreset = "30d";
|
|
||||||
|
|
||||||
// Account filter
|
// Account filter
|
||||||
private HashSet<int> hiddenAccountIds = [];
|
|
||||||
private string dashboardSearchQuery = string.Empty;
|
|
||||||
|
|
||||||
private List<Transaction> filteredRecent => data is null ? []
|
|
||||||
: data.RecentTransactions
|
|
||||||
.Where(t => !hiddenAccountIds.Contains(t.AccountId))
|
|
||||||
.Where(t => MatchesSearch(t))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
private List<Transaction> filteredUpcoming => data is null ? []
|
|
||||||
: data.UpcomingTransactions
|
|
||||||
.Where(t => !hiddenAccountIds.Contains(t.AccountId))
|
|
||||||
.Where(t => MatchesSearch(t))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
private bool MatchesSearch(Transaction t)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(dashboardSearchQuery)) return true;
|
|
||||||
var q = dashboardSearchQuery.Trim();
|
|
||||||
return t.Description.Contains(q, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| t.Account.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| (t.Category?.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|
|
||||||
|| t.Amount.ToString("F2").Contains(q, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleAccountFilter(int accountId)
|
|
||||||
{
|
|
||||||
if (!hiddenAccountIds.Remove(accountId))
|
|
||||||
hiddenAccountIds.Add(accountId);
|
|
||||||
recentPage = 1;
|
|
||||||
upcomingPage = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDashboardSearchChanged()
|
|
||||||
{
|
|
||||||
recentPage = 1;
|
|
||||||
upcomingPage = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
private int recentPage = 1;
|
|
||||||
private int upcomingPage = 1;
|
|
||||||
private const int PageSize = 10;
|
|
||||||
private int RecentTotalPages => (int)Math.Ceiling(filteredRecent.Count / (double)PageSize);
|
|
||||||
private int UpcomingTotalPages => (int)Math.Ceiling(filteredUpcoming.Count / (double)PageSize);
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
|
|
||||||
if (userId is not null)
|
|
||||||
{
|
|
||||||
await RefreshData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (data?.ChartData.Datasets.Count > 0 && chartDataVersion != lastRenderedChartVersion)
|
|
||||||
{
|
|
||||||
lastRenderedChartVersion = chartDataVersion;
|
|
||||||
|
|
||||||
var datasets = data.ChartData.Datasets.Select(ds => new
|
|
||||||
{
|
|
||||||
label = ds.Label,
|
|
||||||
color = ds.Color,
|
|
||||||
data = ds.Data.Select(d => (double)d).ToArray(),
|
|
||||||
currencySymbol = ds.CurrencySymbol
|
|
||||||
}).ToArray();
|
|
||||||
|
|
||||||
var dayLabels = data.ChartData.DayTransactionLabels
|
|
||||||
.Select(d => d.ToArray()).ToArray();
|
|
||||||
|
|
||||||
int highlightIndex = GetHighlightIndex();
|
|
||||||
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdateChart",
|
|
||||||
"balanceChart",
|
|
||||||
data.ChartData.Labels.ToArray(),
|
|
||||||
datasets,
|
|
||||||
dayLabels,
|
|
||||||
highlightIndex,
|
|
||||||
GetSalaryDayIndices(),
|
|
||||||
data.CurrencySymbol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int GetHighlightIndex()
|
|
||||||
{
|
|
||||||
if (selectedTransactionId is null || data is null) return -1;
|
|
||||||
|
|
||||||
var txn = data.RecentTransactions.FirstOrDefault(t => t.Id == selectedTransactionId)
|
|
||||||
?? data.UpcomingTransactions.FirstOrDefault(t => t.Id == selectedTransactionId);
|
|
||||||
if (txn is null) return -1;
|
|
||||||
|
|
||||||
var label = txn.Date.ToString("MMM dd");
|
|
||||||
return data.ChartData.Labels.IndexOf(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int[] GetSalaryDayIndices()
|
|
||||||
{
|
|
||||||
if (data is null) return [];
|
|
||||||
|
|
||||||
var salaryDates = data.RecentTransactions
|
|
||||||
.Concat(data.UpcomingTransactions)
|
|
||||||
.Where(t => t.SalaryId is not null)
|
|
||||||
.Select(t => t.Date.ToString("MMM dd"))
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
return data.ChartData.Labels
|
|
||||||
.Select((label, index) => (label, index))
|
|
||||||
.Where(x => salaryDates.Contains(x.label))
|
|
||||||
.Select(x => x.index)
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SelectTransaction(int id)
|
|
||||||
{
|
|
||||||
selectedTransactionId = selectedTransactionId == id ? null : id;
|
|
||||||
|
|
||||||
if (data?.ChartData.Datasets.Count > 0)
|
|
||||||
{
|
|
||||||
int highlightIndex = GetHighlightIndex();
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.highlightDate", "balanceChart", highlightIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenAddModal()
|
|
||||||
{
|
|
||||||
editingTransactionId = null;
|
|
||||||
modalForm = new ModalTransactionForm
|
|
||||||
{
|
|
||||||
AccountId = data?.Accounts.FirstOrDefault()?.Id ?? 0
|
|
||||||
};
|
|
||||||
showModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OpenEditModal(Transaction t)
|
|
||||||
{
|
|
||||||
editingTransactionId = t.Id;
|
|
||||||
editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
|
||||||
modalForm = new ModalTransactionForm
|
|
||||||
{
|
|
||||||
Description = t.Description,
|
|
||||||
Amount = t.Amount,
|
|
||||||
Date = t.Date,
|
|
||||||
Type = t.Type,
|
|
||||||
AccountId = t.AccountId,
|
|
||||||
CategoryId = t.CategoryId,
|
|
||||||
RecurrenceType = t.RecurrenceType,
|
|
||||||
RecurrenceEndDate = t.RecurrenceEndDate
|
|
||||||
};
|
|
||||||
showModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseModal() => showModal = false;
|
|
||||||
|
|
||||||
private async Task SaveModal()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
if (editingTransactionId is null)
|
|
||||||
{
|
|
||||||
var transaction = new Transaction
|
|
||||||
{
|
|
||||||
Description = modalForm.Description,
|
|
||||||
Amount = modalForm.Amount,
|
|
||||||
Date = modalForm.Date,
|
|
||||||
Type = modalForm.Type,
|
|
||||||
AccountId = modalForm.AccountId,
|
|
||||||
CategoryId = modalForm.CategoryId,
|
|
||||||
RecurrenceType = modalForm.RecurrenceType,
|
|
||||||
RecurrenceEndDate = modalForm.RecurrenceEndDate
|
|
||||||
};
|
|
||||||
await TransactionService.CreateTransactionAsync(transaction);
|
|
||||||
await RefreshData();
|
|
||||||
|
|
||||||
// Keep modal open and reset form for the next entry
|
|
||||||
var previousAccountId = modalForm.AccountId;
|
|
||||||
var previousCategoryId = modalForm.CategoryId;
|
|
||||||
var previousType = modalForm.Type;
|
|
||||||
modalForm = new ModalTransactionForm
|
|
||||||
{
|
|
||||||
AccountId = previousAccountId,
|
|
||||||
CategoryId = previousCategoryId,
|
|
||||||
Type = previousType
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await TransactionService.UpdateTransactionAsync(
|
|
||||||
editingTransactionId.Value, userId,
|
|
||||||
modalForm.Description, modalForm.Amount, modalForm.Date,
|
|
||||||
modalForm.Type, modalForm.AccountId, modalForm.CategoryId,
|
|
||||||
modalForm.RecurrenceType, modalForm.RecurrenceEndDate);
|
|
||||||
|
|
||||||
showModal = false;
|
|
||||||
await RefreshData();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete confirmation state
|
// Delete confirmation state
|
||||||
private bool showDeleteModal;
|
|
||||||
private Transaction? pendingDeleteTransaction;
|
|
||||||
private bool pendingDeleteIsRecurring;
|
|
||||||
|
|
||||||
private void ConfirmDelete(Transaction t)
|
|
||||||
{
|
|
||||||
pendingDeleteTransaction = t;
|
|
||||||
pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
|
||||||
showDeleteModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteSingle()
|
|
||||||
{
|
|
||||||
if (userId is null || pendingDeleteTransaction is null) return;
|
|
||||||
showDeleteModal = false;
|
|
||||||
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
|
|
||||||
pendingDeleteTransaction = null;
|
|
||||||
await RefreshData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteSeries()
|
|
||||||
{
|
|
||||||
if (userId is null || pendingDeleteTransaction is null) return;
|
|
||||||
showDeleteModal = false;
|
|
||||||
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
|
|
||||||
pendingDeleteTransaction = null;
|
|
||||||
await RefreshData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelDelete()
|
|
||||||
{
|
|
||||||
showDeleteModal = false;
|
|
||||||
pendingDeleteTransaction = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task MarkAsPaid(int id, bool isPaid)
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
await TransactionService.MarkAsPaidAsync(id, userId, isPaid);
|
|
||||||
selectedTransactionId = null;
|
|
||||||
await RefreshData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetRecurrenceLabel(Transaction t)
|
|
||||||
{
|
|
||||||
if (t.RecurrenceType != RecurrenceType.None)
|
|
||||||
return t.RecurrenceType.ToString();
|
|
||||||
return t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ComputeProjectedBalances()
|
|
||||||
{
|
|
||||||
projectedBalances.Clear();
|
|
||||||
if (data is null) return;
|
|
||||||
|
|
||||||
var accountBalances = data.Accounts.ToDictionary(a => a.Id, a => a.CurrentBalance);
|
|
||||||
|
|
||||||
foreach (var t in data.UpcomingTransactions)
|
|
||||||
{
|
|
||||||
if (!accountBalances.ContainsKey(t.AccountId)) continue;
|
|
||||||
|
|
||||||
accountBalances[t.AccountId] += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
|
||||||
projectedBalances[t.Id] = accountBalances[t.AccountId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RefreshData()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
|
||||||
data = await DashboardService.GetDashboardDataAsync(userId, windowStart, windowEnd);
|
|
||||||
chartDataVersion++;
|
|
||||||
ComputeProjectedBalances();
|
|
||||||
recentPage = 1;
|
|
||||||
upcomingPage = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SetWindow(int days)
|
|
||||||
{
|
|
||||||
windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" };
|
|
||||||
windowStart = DateTime.Today;
|
|
||||||
windowEnd = DateTime.Today.AddDays(days);
|
|
||||||
await RefreshData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnWindowDateChanged()
|
|
||||||
{
|
|
||||||
windowPreset = "custom";
|
|
||||||
await RefreshData();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "balanceChart");
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ModalTransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Range(0.01, (double)decimal.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
|
||||||
public decimal Amount { get; set; }
|
|
||||||
|
|
||||||
public DateTime Date { get; set; } = DateTime.Today;
|
|
||||||
|
|
||||||
public TransactionType Type { get; set; } = TransactionType.Expense;
|
|
||||||
|
|
||||||
public int AccountId { get; set; }
|
|
||||||
|
|
||||||
public int? CategoryId { get; set; }
|
|
||||||
|
|
||||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
|
||||||
|
|
||||||
public DateTime? RecurrenceEndDate { get; set; }
|
|
||||||
|
|
||||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
|
||||||
{
|
|
||||||
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
|
||||||
{
|
|
||||||
var maxEnd = Date.AddYears(1);
|
|
||||||
if (RecurrenceEndDate.Value > maxEnd)
|
|
||||||
{
|
|
||||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
|
||||||
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
|
||||||
[nameof(RecurrenceEndDate)]);
|
|
||||||
}
|
|
||||||
if (RecurrenceEndDate.Value <= Date)
|
|
||||||
{
|
|
||||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
|
||||||
"Recurrence end date must be after the transaction date.",
|
|
||||||
[nameof(RecurrenceEndDate)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Services.Models;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Home
|
||||||
|
{
|
||||||
|
private DashboardData? _data;
|
||||||
|
private string? _userId;
|
||||||
|
private int? _selectedTransactionId;
|
||||||
|
private string _activeTab = "recent";
|
||||||
|
private readonly Dictionary<int, decimal> _projectedBalances = new();
|
||||||
|
private bool _showModal;
|
||||||
|
private int? _editingTransactionId;
|
||||||
|
private bool _editIsPartOfSeries;
|
||||||
|
private ModalTransactionForm _modalForm = new();
|
||||||
|
private int _chartDataVersion;
|
||||||
|
private int _lastRenderedChartVersion;
|
||||||
|
private DateTime _windowStart = DateTime.Today;
|
||||||
|
private DateTime _windowEnd = DateTime.Today.AddDays(30);
|
||||||
|
private string _windowPreset = "30d";
|
||||||
|
private readonly HashSet<int> _hiddenAccountIds = [];
|
||||||
|
private string _dashboardSearchQuery = string.Empty;
|
||||||
|
|
||||||
|
private List<Transaction> FilteredRecent => _data is null
|
||||||
|
? []
|
||||||
|
: _data.RecentTransactions
|
||||||
|
.Where(t => !_hiddenAccountIds.Contains(t.AccountId))
|
||||||
|
.Where(MatchesSearch)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private List<Transaction> FilteredUpcoming => _data is null
|
||||||
|
? []
|
||||||
|
: _data.UpcomingTransactions
|
||||||
|
.Where(t => !_hiddenAccountIds.Contains(t.AccountId))
|
||||||
|
.Where(MatchesSearch)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private bool MatchesSearch(Transaction t)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_dashboardSearchQuery)) return true;
|
||||||
|
var q = _dashboardSearchQuery.Trim();
|
||||||
|
return t.Description.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| t.Account.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (t.Category?.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||||
|
|| t.Amount.ToString("F2").Contains(q, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleAccountFilter(int accountId)
|
||||||
|
{
|
||||||
|
if (!_hiddenAccountIds.Remove(accountId))
|
||||||
|
_hiddenAccountIds.Add(accountId);
|
||||||
|
_recentPage = 1;
|
||||||
|
_upcomingPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDashboardSearchChanged()
|
||||||
|
{
|
||||||
|
_recentPage = 1;
|
||||||
|
_upcomingPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _recentPage = 1;
|
||||||
|
private int _upcomingPage = 1;
|
||||||
|
private const int PageSize = 10;
|
||||||
|
private int RecentTotalPages => (int)Math.Ceiling(FilteredRecent.Count / (double)PageSize);
|
||||||
|
private int UpcomingTotalPages => (int)Math.Ceiling(FilteredUpcoming.Count / (double)PageSize);
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
|
if (_userId is not null)
|
||||||
|
{
|
||||||
|
await RefreshData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (_data?.ChartData.Datasets.Count > 0 && _chartDataVersion != _lastRenderedChartVersion)
|
||||||
|
{
|
||||||
|
_lastRenderedChartVersion = _chartDataVersion;
|
||||||
|
|
||||||
|
var datasets = _data.ChartData.Datasets.Select(ds => new
|
||||||
|
{
|
||||||
|
label = ds.Label,
|
||||||
|
color = ds.Color,
|
||||||
|
data = ds.Data.Select(d => (double)d).ToArray(),
|
||||||
|
currencySymbol = ds.CurrencySymbol
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
var dayLabels = _data.ChartData.DayTransactionLabels
|
||||||
|
.Select(d => d.ToArray()).ToArray();
|
||||||
|
|
||||||
|
int highlightIndex = GetHighlightIndex();
|
||||||
|
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.createOrUpdateChart",
|
||||||
|
"balanceChart",
|
||||||
|
_data.ChartData.Labels.ToArray(),
|
||||||
|
datasets,
|
||||||
|
dayLabels,
|
||||||
|
highlightIndex,
|
||||||
|
GetSalaryDayIndices(),
|
||||||
|
_data.CurrencySymbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetHighlightIndex()
|
||||||
|
{
|
||||||
|
if (_selectedTransactionId is null || _data is null) return -1;
|
||||||
|
|
||||||
|
var txn = _data.RecentTransactions.FirstOrDefault(t => t.Id == _selectedTransactionId)
|
||||||
|
?? _data.UpcomingTransactions.FirstOrDefault(t => t.Id == _selectedTransactionId);
|
||||||
|
if (txn is null) return -1;
|
||||||
|
|
||||||
|
var label = txn.Date.ToString("MMM dd");
|
||||||
|
return _data.ChartData.Labels.IndexOf(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[] GetSalaryDayIndices()
|
||||||
|
{
|
||||||
|
if (_data is null) return [];
|
||||||
|
|
||||||
|
var salaryDates = _data.RecentTransactions
|
||||||
|
.Concat(_data.UpcomingTransactions)
|
||||||
|
.Where(t => t.SalaryId is not null)
|
||||||
|
.Select(t => t.Date.ToString("MMM dd"))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
return _data.ChartData.Labels
|
||||||
|
.Select((label, index) => (label, index))
|
||||||
|
.Where(x => salaryDates.Contains(x.label))
|
||||||
|
.Select(x => x.index)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SelectTransaction(int id)
|
||||||
|
{
|
||||||
|
_selectedTransactionId = _selectedTransactionId == id ? null : id;
|
||||||
|
|
||||||
|
if (_data?.ChartData.Datasets.Count > 0)
|
||||||
|
{
|
||||||
|
int highlightIndex = GetHighlightIndex();
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.highlightDate", "balanceChart", highlightIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenAddModal()
|
||||||
|
{
|
||||||
|
_editingTransactionId = null;
|
||||||
|
_modalForm = new ModalTransactionForm
|
||||||
|
{
|
||||||
|
AccountId = _data?.Accounts.FirstOrDefault()?.Id ?? 0
|
||||||
|
};
|
||||||
|
_showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenEditModal(Transaction t)
|
||||||
|
{
|
||||||
|
_editingTransactionId = t.Id;
|
||||||
|
_editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||||
|
_modalForm = new ModalTransactionForm
|
||||||
|
{
|
||||||
|
Description = t.Description,
|
||||||
|
Amount = t.Amount,
|
||||||
|
Date = t.Date,
|
||||||
|
Type = t.Type,
|
||||||
|
AccountId = t.AccountId,
|
||||||
|
CategoryId = t.CategoryId,
|
||||||
|
RecurrenceType = t.RecurrenceType,
|
||||||
|
RecurrenceEndDate = t.RecurrenceEndDate
|
||||||
|
};
|
||||||
|
_showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseModal() => _showModal = false;
|
||||||
|
|
||||||
|
private async Task SaveModal()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
if (_editingTransactionId is null)
|
||||||
|
{
|
||||||
|
var transaction = new Transaction
|
||||||
|
{
|
||||||
|
Description = _modalForm.Description,
|
||||||
|
Amount = _modalForm.Amount,
|
||||||
|
Date = _modalForm.Date,
|
||||||
|
Type = _modalForm.Type,
|
||||||
|
AccountId = _modalForm.AccountId,
|
||||||
|
CategoryId = _modalForm.CategoryId,
|
||||||
|
RecurrenceType = _modalForm.RecurrenceType,
|
||||||
|
RecurrenceEndDate = _modalForm.RecurrenceEndDate
|
||||||
|
};
|
||||||
|
await TransactionService.CreateTransactionAsync(transaction);
|
||||||
|
await RefreshData();
|
||||||
|
|
||||||
|
// Keep modal open and reset form for the next entry
|
||||||
|
var previousAccountId = _modalForm.AccountId;
|
||||||
|
var previousCategoryId = _modalForm.CategoryId;
|
||||||
|
var previousType = _modalForm.Type;
|
||||||
|
_modalForm = new ModalTransactionForm
|
||||||
|
{
|
||||||
|
AccountId = previousAccountId,
|
||||||
|
CategoryId = previousCategoryId,
|
||||||
|
Type = previousType
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await TransactionService.UpdateTransactionAsync(
|
||||||
|
_editingTransactionId.Value, _userId,
|
||||||
|
_modalForm.Description, _modalForm.Amount, _modalForm.Date,
|
||||||
|
_modalForm.Type, _modalForm.AccountId, _modalForm.CategoryId,
|
||||||
|
_modalForm.RecurrenceType, _modalForm.RecurrenceEndDate);
|
||||||
|
|
||||||
|
_showModal = false;
|
||||||
|
await RefreshData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _showDeleteModal;
|
||||||
|
private Transaction? _pendingDeleteTransaction;
|
||||||
|
private bool _pendingDeleteIsRecurring;
|
||||||
|
|
||||||
|
private void ConfirmDelete(Transaction t)
|
||||||
|
{
|
||||||
|
_pendingDeleteTransaction = t;
|
||||||
|
_pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||||
|
_showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSingle()
|
||||||
|
{
|
||||||
|
if (_userId is null || _pendingDeleteTransaction is null) return;
|
||||||
|
_showDeleteModal = false;
|
||||||
|
await TransactionService.DeleteTransactionAsync(_pendingDeleteTransaction.Id, _userId);
|
||||||
|
_pendingDeleteTransaction = null;
|
||||||
|
await RefreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSeries()
|
||||||
|
{
|
||||||
|
if (_userId is null || _pendingDeleteTransaction is null) return;
|
||||||
|
_showDeleteModal = false;
|
||||||
|
await TransactionService.DeleteTransactionSeriesAsync(_pendingDeleteTransaction.Id, _userId);
|
||||||
|
_pendingDeleteTransaction = null;
|
||||||
|
await RefreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelDelete()
|
||||||
|
{
|
||||||
|
_showDeleteModal = false;
|
||||||
|
_pendingDeleteTransaction = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkAsPaid(int id, bool isPaid)
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
await TransactionService.MarkAsPaidAsync(id, _userId, isPaid);
|
||||||
|
_selectedTransactionId = null;
|
||||||
|
await RefreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRecurrenceLabel(Transaction t)
|
||||||
|
{
|
||||||
|
if (t.RecurrenceType != RecurrenceType.None)
|
||||||
|
return t.RecurrenceType.ToString();
|
||||||
|
return t.ParentTransaction?.RecurrenceType.ToString() ?? "Recurring";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ComputeProjectedBalances()
|
||||||
|
{
|
||||||
|
_projectedBalances.Clear();
|
||||||
|
if (_data is null) return;
|
||||||
|
|
||||||
|
var accountBalances = _data.Accounts.ToDictionary(a => a.Id, a => a.CurrentBalance);
|
||||||
|
|
||||||
|
foreach (var t in _data.UpcomingTransactions)
|
||||||
|
{
|
||||||
|
if (!accountBalances.ContainsKey(t.AccountId)) continue;
|
||||||
|
|
||||||
|
accountBalances[t.AccountId] += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
||||||
|
_projectedBalances[t.Id] = accountBalances[t.AccountId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshData()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, _windowEnd);
|
||||||
|
_data = await DashboardService.GetDashboardDataAsync(_userId, _windowStart, _windowEnd);
|
||||||
|
_chartDataVersion++;
|
||||||
|
ComputeProjectedBalances();
|
||||||
|
_recentPage = 1;
|
||||||
|
_upcomingPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetWindow(int days)
|
||||||
|
{
|
||||||
|
_windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => "custom" };
|
||||||
|
_windowStart = DateTime.Today;
|
||||||
|
_windowEnd = DateTime.Today.AddDays(days);
|
||||||
|
await RefreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnWindowDateChanged()
|
||||||
|
{
|
||||||
|
_windowPreset = "custom";
|
||||||
|
await RefreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.destroyChart", "balanceChart");
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ModalTransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Range(0.01, (double)decimal.MaxValue,
|
||||||
|
ErrorMessage = "Amount must be greater than zero")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
public DateTime Date { get; set; } = DateTime.Today;
|
||||||
|
|
||||||
|
public TransactionType Type { get; set; } = TransactionType.Expense;
|
||||||
|
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
|
||||||
|
public int? CategoryId { get; set; }
|
||||||
|
|
||||||
|
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
||||||
|
|
||||||
|
public DateTime? RecurrenceEndDate { get; set; }
|
||||||
|
|
||||||
|
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||||
|
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
||||||
|
{
|
||||||
|
var maxEnd = Date.AddYears(1);
|
||||||
|
if (RecurrenceEndDate.Value > maxEnd)
|
||||||
|
{
|
||||||
|
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||||
|
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
||||||
|
[nameof(RecurrenceEndDate)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RecurrenceEndDate.Value <= Date)
|
||||||
|
{
|
||||||
|
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||||
|
"Recurrence end date must be after the transaction date.",
|
||||||
|
[nameof(RecurrenceEndDate)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/login"
|
@page "/login"
|
||||||
@layout AuthLayout
|
@layout AuthLayout
|
||||||
|
@using BudgetApp.Data.Entities
|
||||||
@using Microsoft.AspNetCore.Authentication
|
@using Microsoft.AspNetCore.Authentication
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
@inject SignInManager<ApplicationUser> SignInManager
|
||||||
@@ -17,9 +18,9 @@
|
|||||||
<EditForm Model="Model" OnValidSubmit="HandleLogin" FormName="login">
|
<EditForm Model="Model" OnValidSubmit="HandleLogin" FormName="login">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger">@errorMessage</div>
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -37,17 +38,17 @@
|
|||||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
@if (externalProviders.Count > 0)
|
@if (_externalProviders.Count > 0)
|
||||||
{
|
{
|
||||||
<hr class="my-4" />
|
<hr class="my-4" />
|
||||||
<p class="text-center text-muted mb-3">Or sign in with</p>
|
<p class="text-center text-muted mb-3">Or sign in with</p>
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="d-flex flex-column gap-2">
|
||||||
@foreach (var provider in externalProviders)
|
@foreach (var provider in _externalProviders)
|
||||||
{
|
{
|
||||||
<form method="post" action="/api/auth/external-login">
|
<form method="post" action="/api/auth/external-login">
|
||||||
<input type="hidden" name="provider" value="@provider.Name" />
|
<input type="hidden" name="provider" value="@provider.Name" />
|
||||||
<input type="hidden" name="returnUrl" value="/" />
|
<input type="hidden" name="returnUrl" value="/" />
|
||||||
<input type="hidden" name="__RequestVerificationToken" value="@antiforgeryToken" />
|
<input type="hidden" name="__RequestVerificationToken" value="@_antiforgeryToken" />
|
||||||
<button type="submit" class="btn btn-outline-secondary w-100 d-flex align-items-center justify-content-center gap-2">
|
<button type="submit" class="btn btn-outline-secondary w-100 d-flex align-items-center justify-content-center gap-2">
|
||||||
@GetProviderIcon(provider.DisplayName ?? provider.Name)
|
@GetProviderIcon(provider.DisplayName ?? provider.Name)
|
||||||
<span>@(provider.DisplayName ?? provider.Name)</span>
|
<span>@(provider.DisplayName ?? provider.Name)</span>
|
||||||
@@ -60,70 +61,3 @@
|
|||||||
<p class="text-center mt-3">
|
<p class="text-center mt-3">
|
||||||
Don't have an account? <a href="/register">Register</a>
|
Don't have an account? <a href="/register">Register</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@code {
|
|
||||||
[SupplyParameterFromForm]
|
|
||||||
private LoginModel Model { get; set; } = new();
|
|
||||||
|
|
||||||
private string? errorMessage;
|
|
||||||
private List<AuthenticationScheme> externalProviders = [];
|
|
||||||
private string antiforgeryToken = string.Empty;
|
|
||||||
|
|
||||||
[CascadingParameter]
|
|
||||||
private HttpContext? HttpContext { get; set; }
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var schemes = await SchemeProvider.GetAllSchemesAsync();
|
|
||||||
externalProviders = schemes
|
|
||||||
.Where(s => !string.IsNullOrEmpty(s.DisplayName)
|
|
||||||
&& s.Name != IdentityConstants.ApplicationScheme
|
|
||||||
&& s.Name != IdentityConstants.ExternalScheme
|
|
||||||
&& s.Name != IdentityConstants.TwoFactorUserIdScheme
|
|
||||||
&& s.Name != IdentityConstants.TwoFactorRememberMeScheme)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (HttpContext is not null)
|
|
||||||
{
|
|
||||||
var tokens = HttpContext.RequestServices
|
|
||||||
.GetRequiredService<Microsoft.AspNetCore.Antiforgery.IAntiforgery>()
|
|
||||||
.GetAndStoreTokens(HttpContext);
|
|
||||||
antiforgeryToken = tokens.RequestToken ?? string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleLogin()
|
|
||||||
{
|
|
||||||
errorMessage = null;
|
|
||||||
|
|
||||||
var result = await SignInManager.PasswordSignInAsync(
|
|
||||||
Model.Email, Model.Password, isPersistent: false, lockoutOnFailure: false);
|
|
||||||
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
Navigation.NavigateTo("/", forceLoad: true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errorMessage = "Invalid email or password.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MarkupString GetProviderIcon(string provider) => provider switch
|
|
||||||
{
|
|
||||||
"Google" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path fill=\"#4285F4\" d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z\"/><path fill=\"#34A853\" d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"/><path fill=\"#FBBC05\" d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"/><path fill=\"#EA4335\" d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"/></svg>"),
|
|
||||||
"Microsoft" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 23 23\"><rect fill=\"#f25022\" x=\"1\" y=\"1\" width=\"10\" height=\"10\"/><rect fill=\"#00a4ef\" x=\"1\" y=\"12\" width=\"10\" height=\"10\"/><rect fill=\"#7fba00\" x=\"12\" y=\"1\" width=\"10\" height=\"10\"/><rect fill=\"#ffb900\" x=\"12\" y=\"12\" width=\"10\" height=\"10\"/></svg>"),
|
|
||||||
"Facebook" => new MarkupString("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path fill=\"#1877F2\" d=\"M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.413c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z\"/></svg>"),
|
|
||||||
_ => new MarkupString("")
|
|
||||||
};
|
|
||||||
|
|
||||||
private sealed class LoginModel
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.EmailAddress]
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
|
||||||
public string Password { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Login
|
||||||
|
{
|
||||||
|
[SupplyParameterFromForm] private LoginModel Model { get; set; } = new();
|
||||||
|
|
||||||
|
private string? _errorMessage;
|
||||||
|
private List<AuthenticationScheme> _externalProviders = [];
|
||||||
|
private string _antiforgeryToken = string.Empty;
|
||||||
|
|
||||||
|
[CascadingParameter] private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var schemes = await SchemeProvider.GetAllSchemesAsync();
|
||||||
|
_externalProviders = schemes
|
||||||
|
.Where(s => !string.IsNullOrEmpty(s.DisplayName)
|
||||||
|
&& s.Name != IdentityConstants.ApplicationScheme
|
||||||
|
&& s.Name != IdentityConstants.ExternalScheme
|
||||||
|
&& s.Name != IdentityConstants.TwoFactorUserIdScheme
|
||||||
|
&& s.Name != IdentityConstants.TwoFactorRememberMeScheme)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (HttpContext is not null)
|
||||||
|
{
|
||||||
|
var tokens = HttpContext.RequestServices
|
||||||
|
.GetRequiredService<Microsoft.AspNetCore.Antiforgery.IAntiforgery>()
|
||||||
|
.GetAndStoreTokens(HttpContext);
|
||||||
|
_antiforgeryToken = tokens.RequestToken ?? string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleLogin()
|
||||||
|
{
|
||||||
|
_errorMessage = null;
|
||||||
|
|
||||||
|
var result = await SignInManager.PasswordSignInAsync(
|
||||||
|
Model.Email, Model.Password, isPersistent: false, lockoutOnFailure: false);
|
||||||
|
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/", forceLoad: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_errorMessage = "Invalid email or password.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MarkupString GetProviderIcon(string provider) => provider switch
|
||||||
|
{
|
||||||
|
"Google" => new MarkupString(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path fill=\"#4285F4\" d=\"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z\"/><path fill=\"#34A853\" d=\"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z\"/><path fill=\"#FBBC05\" d=\"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z\"/><path fill=\"#EA4335\" d=\"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z\"/></svg>"),
|
||||||
|
"Microsoft" => new MarkupString(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 23 23\"><rect fill=\"#f25022\" x=\"1\" y=\"1\" width=\"10\" height=\"10\"/><rect fill=\"#00a4ef\" x=\"1\" y=\"12\" width=\"10\" height=\"10\"/><rect fill=\"#7fba00\" x=\"12\" y=\"1\" width=\"10\" height=\"10\"/><rect fill=\"#ffb900\" x=\"12\" y=\"12\" width=\"10\" height=\"10\"/></svg>"),
|
||||||
|
"Facebook" => new MarkupString(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 24 24\"><path fill=\"#1877F2\" d=\"M24 12.073C24 5.405 18.627 0 12 0S0 5.405 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.413c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z\"/></svg>"),
|
||||||
|
_ => new MarkupString("")
|
||||||
|
};
|
||||||
|
|
||||||
|
private sealed class LoginModel
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/logout"
|
@page "/logout"
|
||||||
|
@using BudgetApp.Data.Entities
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
@inject SignInManager<ApplicationUser> SignInManager
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@page "/register"
|
@page "/register"
|
||||||
@layout AuthLayout
|
@layout AuthLayout
|
||||||
|
@using BudgetApp.Data.Entities
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@inject UserManager<ApplicationUser> UserManager
|
@inject UserManager<ApplicationUser> UserManager
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
@inject SignInManager<ApplicationUser> SignInManager
|
||||||
@@ -12,9 +13,9 @@
|
|||||||
<EditForm Model="Model" OnValidSubmit="HandleRegister" FormName="register">
|
<EditForm Model="Model" OnValidSubmit="HandleRegister" FormName="register">
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(errorMessage))
|
@if (!string.IsNullOrEmpty(_errorMessage))
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger">@errorMessage</div>
|
<div class="alert alert-danger">@_errorMessage</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -41,43 +42,3 @@
|
|||||||
<p class="text-center mt-3">
|
<p class="text-center mt-3">
|
||||||
Already have an account? <a href="/login">Sign in</a>
|
Already have an account? <a href="/login">Sign in</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@code {
|
|
||||||
[SupplyParameterFromForm]
|
|
||||||
private RegisterModel Model { get; set; } = new();
|
|
||||||
|
|
||||||
private string? errorMessage;
|
|
||||||
|
|
||||||
private async Task HandleRegister()
|
|
||||||
{
|
|
||||||
errorMessage = null;
|
|
||||||
|
|
||||||
var user = new ApplicationUser { UserName = Model.Email, Email = Model.Email };
|
|
||||||
var result = await UserManager.CreateAsync(user, Model.Password);
|
|
||||||
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
await SignInManager.SignInAsync(user, isPersistent: false);
|
|
||||||
Navigation.NavigateTo("/", forceLoad: true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class RegisterModel
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email address")]
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
|
||||||
public string Password { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
|
||||||
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
|
|
||||||
public string ConfirmPassword { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Register
|
||||||
|
{
|
||||||
|
[SupplyParameterFromForm] private RegisterModel Model { get; set; } = new();
|
||||||
|
|
||||||
|
private string? _errorMessage;
|
||||||
|
|
||||||
|
private async Task HandleRegister()
|
||||||
|
{
|
||||||
|
_errorMessage = null;
|
||||||
|
|
||||||
|
var user = new ApplicationUser { UserName = Model.Email, Email = Model.Email };
|
||||||
|
var result = await UserManager.CreateAsync(user, Model.Password);
|
||||||
|
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
await SignInManager.SignInAsync(user, isPersistent: false);
|
||||||
|
Navigation.NavigateTo("/", forceLoad: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RegisterModel
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Email is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.EmailAddress(ErrorMessage = "Invalid email address")]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Password is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||||
|
public string Password { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your password")]
|
||||||
|
[System.ComponentModel.DataAnnotations.Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
|
||||||
|
public string ConfirmPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
@page "/reports"
|
@page "/reports"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject TransactionService TransactionService
|
@inject ITransactionRepository TransactionService
|
||||||
@inject AccountService AccountService
|
@inject IAccountRepository AccountService
|
||||||
@inject CategoryService CategoryService
|
@inject ICategoryRepository CategoryService
|
||||||
@inject SalaryService SalaryService
|
@inject ISalaryRepository SalaryService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime Js
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
<h3 class="mb-2">Reports</h3>
|
<h3 class="mb-2">Reports</h3>
|
||||||
<p class="text-muted mb-4">Visual breakdown of your spending and income by category for a specified time period.</p>
|
<p class="text-muted mb-4">Visual breakdown of your spending and income by category for a specified time period.</p>
|
||||||
|
|
||||||
@if (loading)
|
@if (_loading)
|
||||||
{
|
{
|
||||||
<p><em>Loading...</em></p>
|
<p><em>Loading...</em></p>
|
||||||
}
|
}
|
||||||
else if (accounts.Count == 0)
|
else if (_accounts.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<h5>No data yet</h5>
|
<h5>No data yet</h5>
|
||||||
@@ -33,23 +33,23 @@ else
|
|||||||
<h5 class="mb-0">Filters</h5>
|
<h5 class="mb-0">Filters</h5>
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn @(preset == "7d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(7)">7d</button>
|
<button class="btn @(_preset == "7d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(7)">7d</button>
|
||||||
<button class="btn @(preset == "30d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(30)">30d</button>
|
<button class="btn @(_preset == "30d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(30)">30d</button>
|
||||||
<button class="btn @(preset == "90d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(90)">90d</button>
|
<button class="btn @(_preset == "90d" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(90)">90d</button>
|
||||||
<button class="btn @(preset == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(365)">1y</button>
|
<button class="btn @(_preset == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(365)">1y</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
||||||
title="Start date" @bind="dateFrom" @bind:after="OnDateChanged" />
|
title="Start date" @bind="_dateFrom" @bind:after="OnDateChanged" />
|
||||||
<span class="text-muted">to</span>
|
<span class="text-muted">to</span>
|
||||||
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
||||||
title="End date" @bind="dateTo" @bind:after="OnDateChanged" />
|
title="End date" @bind="_dateTo" @bind:after="OnDateChanged" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
@foreach (var account in accounts)
|
@foreach (var account in _accounts)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm @(selectedAccountIds.Contains(account.Id) ? "btn-primary" : "btn-outline-secondary")"
|
<button class="btn btn-sm @(_selectedAccountIds.Contains(account.Id) ? "btn-primary" : "btn-outline-secondary")"
|
||||||
title="Toggle @account.Name" @onclick="() => ToggleAccount(account.Id)">
|
title="Toggle @account.Name" @onclick="() => ToggleAccount(account.Id)">
|
||||||
<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background-color:@account.Color;"></span>
|
<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background-color:@account.Color;"></span>
|
||||||
@account.Name
|
@account.Name
|
||||||
@@ -65,7 +65,7 @@ else
|
|||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header"><h5 class="mb-0">Expenses by Category</h5></div>
|
<div class="card-header"><h5 class="mb-0">Expenses by Category</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (categoryBreakdown.Count == 0)
|
@if (_categoryBreakdown.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="text-muted">No expenses in this period.</p>
|
<p class="text-muted">No expenses in this period.</p>
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ else
|
|||||||
<div class="card h-100">
|
<div class="card h-100">
|
||||||
<div class="card-header"><h5 class="mb-0">Income by Category</h5></div>
|
<div class="card-header"><h5 class="mb-0">Income by Category</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (incomeBreakdown.Count == 0)
|
@if (_incomeBreakdown.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="text-muted">No income in this period.</p>
|
<p class="text-muted">No income in this period.</p>
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ else
|
|||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h5 class="mb-0">Monthly Income vs Expenses</h5></div>
|
<div class="card-header"><h5 class="mb-0">Monthly Income vs Expenses</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (monthlyLabels.Count == 0)
|
@if (_monthlyLabels.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="text-muted">No transactions in this period.</p>
|
<p class="text-muted">No transactions in this period.</p>
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ else
|
|||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h5 class="mb-0">Category Summary</h5></div>
|
<div class="card-header"><h5 class="mb-0">Category Summary</h5></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (categoryBreakdown.Count == 0 && incomeBreakdown.Count == 0)
|
@if (_categoryBreakdown.Count == 0 && _incomeBreakdown.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="text-muted">No transactions in this period.</p>
|
<p class="text-muted">No transactions in this period.</p>
|
||||||
}
|
}
|
||||||
@@ -137,14 +137,14 @@ else
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var row in categorySummary)
|
@foreach (var row in _categorySummary)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@row.Icon @row.Name</td>
|
<td>@row.Icon @row.Name</td>
|
||||||
<td class="text-end text-success">@currencySymbol@row.Income.ToString("N2")</td>
|
<td class="text-end text-success">@_currencySymbol@row.Income.ToString("N2")</td>
|
||||||
<td class="text-end text-danger">@currencySymbol@row.Expense.ToString("N2")</td>
|
<td class="text-end text-danger">@_currencySymbol@row.Expense.ToString("N2")</td>
|
||||||
<td class="text-end @(row.Net >= 0 ? "text-success" : "text-danger")">
|
<td class="text-end @(row.Net >= 0 ? "text-success" : "text-danger")">
|
||||||
@currencySymbol@row.Net.ToString("N2")
|
@_currencySymbol@row.Net.ToString("N2")
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -152,11 +152,11 @@ else
|
|||||||
<tfoot>
|
<tfoot>
|
||||||
<tr class="fw-bold">
|
<tr class="fw-bold">
|
||||||
<td>Total</td>
|
<td>Total</td>
|
||||||
<td class="text-end text-success">@currencySymbol@categorySummary.Sum(r => r.Income).ToString("N2")</td>
|
<td class="text-end text-success">@_currencySymbol@_categorySummary.Sum(r => r.Income).ToString("N2")</td>
|
||||||
<td class="text-end text-danger">@currencySymbol@categorySummary.Sum(r => r.Expense).ToString("N2")</td>
|
<td class="text-end text-danger">@_currencySymbol@_categorySummary.Sum(r => r.Expense).ToString("N2")</td>
|
||||||
@{ var totalNet = categorySummary.Sum(r => r.Net); }
|
@{ var totalNet = _categorySummary.Sum(r => r.Net); }
|
||||||
<td class="text-end @(totalNet >= 0 ? "text-success" : "text-danger")">
|
<td class="text-end @(totalNet >= 0 ? "text-success" : "text-danger")">
|
||||||
@currencySymbol@totalNet.ToString("N2")
|
@_currencySymbol@totalNet.ToString("N2")
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@@ -166,209 +166,3 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
|
||||||
private bool loading = true;
|
|
||||||
private string? userId;
|
|
||||||
|
|
||||||
private DateTime dateFrom = DateTime.Today;
|
|
||||||
private DateTime dateTo = DateTime.Today.AddDays(30);
|
|
||||||
private string? preset = "30d";
|
|
||||||
|
|
||||||
private List<Account> accounts = [];
|
|
||||||
private HashSet<int> selectedAccountIds = [];
|
|
||||||
private List<Category> categories = [];
|
|
||||||
private string currencySymbol = "£";
|
|
||||||
|
|
||||||
private List<CategoryAmount> categoryBreakdown = [];
|
|
||||||
private List<CategoryAmount> incomeBreakdown = [];
|
|
||||||
private List<CategorySummaryRow> categorySummary = [];
|
|
||||||
|
|
||||||
private List<string> monthlyLabels = [];
|
|
||||||
private List<decimal> monthlyIncome = [];
|
|
||||||
private List<decimal> monthlyExpenses = [];
|
|
||||||
|
|
||||||
private static readonly string[] chartColors =
|
|
||||||
[
|
|
||||||
"#4dc9f6", "#f67019", "#f53794", "#537bc4", "#acc236",
|
|
||||||
"#166a8f", "#00a950", "#58595b", "#8549ba", "#e6194b",
|
|
||||||
"#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
|
|
||||||
"#42d4f4", "#f032e6", "#bfef45", "#fabed4", "#469990"
|
|
||||||
];
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
accounts = await AccountService.GetAccountsAsync(userId);
|
|
||||||
categories = await CategoryService.GetCategoriesAsync(userId);
|
|
||||||
selectedAccountIds = accounts.Select(a => a.Id).ToHashSet();
|
|
||||||
currencySymbol = accounts.FirstOrDefault()?.GetSymbol() ?? "£";
|
|
||||||
|
|
||||||
await LoadReport();
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (!firstRender || loading) return;
|
|
||||||
await RenderCharts();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadReport()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, dateTo);
|
|
||||||
var transactions = await TransactionService.GetTransactionsInRangeAsync(userId, dateFrom, dateTo);
|
|
||||||
|
|
||||||
var filtered = transactions
|
|
||||||
.Where(t => selectedAccountIds.Contains(t.AccountId))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
categoryBreakdown = filtered
|
|
||||||
.Where(t => t.Type == TransactionType.Expense)
|
|
||||||
.GroupBy(t => t.CategoryId)
|
|
||||||
.Select(g =>
|
|
||||||
{
|
|
||||||
var cat = categories.FirstOrDefault(c => c.Id == g.Key);
|
|
||||||
return new CategoryAmount
|
|
||||||
{
|
|
||||||
Name = cat?.Name ?? "Uncategorised",
|
|
||||||
Icon = cat?.Icon ?? "",
|
|
||||||
Total = g.Sum(t => t.Amount)
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.OrderByDescending(c => c.Total)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
incomeBreakdown = filtered
|
|
||||||
.Where(t => t.Type == TransactionType.Income)
|
|
||||||
.GroupBy(t => t.CategoryId)
|
|
||||||
.Select(g =>
|
|
||||||
{
|
|
||||||
var cat = categories.FirstOrDefault(c => c.Id == g.Key);
|
|
||||||
return new CategoryAmount
|
|
||||||
{
|
|
||||||
Name = cat?.Name ?? "Uncategorised",
|
|
||||||
Icon = cat?.Icon ?? "",
|
|
||||||
Total = g.Sum(t => t.Amount)
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.OrderByDescending(c => c.Total)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var allCategoryKeys = filtered
|
|
||||||
.Select(t => t.CategoryId)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
categorySummary = allCategoryKeys.Select(key =>
|
|
||||||
{
|
|
||||||
var cat = categories.FirstOrDefault(c => c.Id == key);
|
|
||||||
var income = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Income).Sum(t => t.Amount);
|
|
||||||
var expense = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Expense).Sum(t => t.Amount);
|
|
||||||
return new CategorySummaryRow
|
|
||||||
{
|
|
||||||
Name = cat?.Name ?? "Uncategorised",
|
|
||||||
Icon = cat?.Icon ?? "",
|
|
||||||
Income = income,
|
|
||||||
Expense = expense
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.OrderByDescending(r => r.Expense)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Monthly data
|
|
||||||
var monthly = filtered
|
|
||||||
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
|
||||||
.OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
monthlyLabels = monthly.Select(g => $"{g.Key.Year}-{g.Key.Month:D2}").ToList();
|
|
||||||
monthlyIncome = monthly.Select(g => g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount)).ToList();
|
|
||||||
monthlyExpenses = monthly.Select(g => g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount)).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task RenderCharts()
|
|
||||||
{
|
|
||||||
if (categoryBreakdown.Count > 0)
|
|
||||||
{
|
|
||||||
var labels = categoryBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray();
|
|
||||||
var data = categoryBreakdown.Select(c => c.Total).ToArray();
|
|
||||||
var colors = Enumerable.Range(0, categoryBreakdown.Count).Select(i => chartColors[i % chartColors.Length]).ToArray();
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "expensePieChart", labels, data, colors, currencySymbol, "doughnut");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incomeBreakdown.Count > 0)
|
|
||||||
{
|
|
||||||
var labels = incomeBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray();
|
|
||||||
var data = incomeBreakdown.Select(c => c.Total).ToArray();
|
|
||||||
var colors = Enumerable.Range(0, incomeBreakdown.Count).Select(i => chartColors[i % chartColors.Length]).ToArray();
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "incomePieChart", labels, data, colors, currencySymbol, "doughnut");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (monthlyLabels.Count > 0)
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.createOrUpdateBarChart", "monthlyBarChart", monthlyLabels.ToArray(), monthlyIncome.ToArray(), monthlyExpenses.ToArray(), currencySymbol);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SetPreset(int days)
|
|
||||||
{
|
|
||||||
preset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
|
||||||
dateFrom = DateTime.Today;
|
|
||||||
dateTo = DateTime.Today.AddDays(days);
|
|
||||||
await Refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnDateChanged()
|
|
||||||
{
|
|
||||||
preset = null;
|
|
||||||
await Refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ToggleAccount(int accountId)
|
|
||||||
{
|
|
||||||
if (!selectedAccountIds.Remove(accountId))
|
|
||||||
selectedAccountIds.Add(accountId);
|
|
||||||
await Refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task Refresh()
|
|
||||||
{
|
|
||||||
await LoadReport();
|
|
||||||
StateHasChanged();
|
|
||||||
await Task.Yield();
|
|
||||||
await RenderCharts();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "expensePieChart");
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "incomePieChart");
|
|
||||||
await JS.InvokeVoidAsync("chartInterop.destroyChart", "monthlyBarChart");
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class CategoryAmount
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Icon { get; set; } = string.Empty;
|
|
||||||
public decimal Total { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class CategorySummaryRow
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string Icon { get; set; } = string.Empty;
|
|
||||||
public decimal Income { get; set; }
|
|
||||||
public decimal Expense { get; set; }
|
|
||||||
public decimal Net => Income - Expense;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Reports
|
||||||
|
{
|
||||||
|
private bool _loading = true;
|
||||||
|
private string? _userId;
|
||||||
|
private DateTime _dateFrom = DateTime.Today;
|
||||||
|
private DateTime _dateTo = DateTime.Today.AddDays(30);
|
||||||
|
private string? _preset = "30d";
|
||||||
|
private List<Account> _accounts = [];
|
||||||
|
private HashSet<int> _selectedAccountIds = [];
|
||||||
|
private List<Category> _categories = [];
|
||||||
|
private string _currencySymbol = "£";
|
||||||
|
private List<CategoryAmount> _categoryBreakdown = [];
|
||||||
|
private List<CategoryAmount> _incomeBreakdown = [];
|
||||||
|
private List<CategorySummaryRow> _categorySummary = [];
|
||||||
|
private List<string> _monthlyLabels = [];
|
||||||
|
private List<decimal> _monthlyIncome = [];
|
||||||
|
private List<decimal> _monthlyExpenses = [];
|
||||||
|
|
||||||
|
private static readonly string[] ChartColors =
|
||||||
|
[
|
||||||
|
"#4dc9f6", "#f67019", "#f53794", "#537bc4", "#acc236",
|
||||||
|
"#166a8f", "#00a950", "#58595b", "#8549ba", "#e6194b",
|
||||||
|
"#3cb44b", "#ffe119", "#4363d8", "#f58231", "#911eb4",
|
||||||
|
"#42d4f4", "#f032e6", "#bfef45", "#fabed4", "#469990"
|
||||||
|
];
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
_accounts = await AccountService.GetAccountsAsync(_userId);
|
||||||
|
_categories = await CategoryService.GetCategoriesAsync(_userId);
|
||||||
|
_selectedAccountIds = _accounts.Select(a => a.Id).ToHashSet();
|
||||||
|
_currencySymbol = _accounts.FirstOrDefault()?.GetSymbol() ?? "£";
|
||||||
|
|
||||||
|
await LoadReport();
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender || _loading) return;
|
||||||
|
await RenderCharts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadReport()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, _dateTo);
|
||||||
|
var transactions = await TransactionService.GetTransactionsInRangeAsync(_userId, _dateFrom, _dateTo);
|
||||||
|
|
||||||
|
var filtered = transactions
|
||||||
|
.Where(t => _selectedAccountIds.Contains(t.AccountId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_categoryBreakdown = filtered
|
||||||
|
.Where(t => t.Type == TransactionType.Expense)
|
||||||
|
.GroupBy(t => t.CategoryId)
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var cat = _categories.FirstOrDefault(c => c.Id == g.Key);
|
||||||
|
return new CategoryAmount
|
||||||
|
{
|
||||||
|
Name = cat?.Name ?? "Uncategorised",
|
||||||
|
Icon = cat?.Icon ?? "",
|
||||||
|
Total = g.Sum(t => t.Amount)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderByDescending(c => c.Total)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_incomeBreakdown = filtered
|
||||||
|
.Where(t => t.Type == TransactionType.Income)
|
||||||
|
.GroupBy(t => t.CategoryId)
|
||||||
|
.Select(g =>
|
||||||
|
{
|
||||||
|
var cat = _categories.FirstOrDefault(c => c.Id == g.Key);
|
||||||
|
return new CategoryAmount
|
||||||
|
{
|
||||||
|
Name = cat?.Name ?? "Uncategorised",
|
||||||
|
Icon = cat?.Icon ?? "",
|
||||||
|
Total = g.Sum(t => t.Amount)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderByDescending(c => c.Total)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var allCategoryKeys = filtered
|
||||||
|
.Select(t => t.CategoryId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_categorySummary = allCategoryKeys.Select(key =>
|
||||||
|
{
|
||||||
|
var cat = _categories.FirstOrDefault(c => c.Id == key);
|
||||||
|
var income = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Income)
|
||||||
|
.Sum(t => t.Amount);
|
||||||
|
var expense = filtered.Where(t => t.CategoryId == key && t.Type == TransactionType.Expense)
|
||||||
|
.Sum(t => t.Amount);
|
||||||
|
return new CategorySummaryRow
|
||||||
|
{
|
||||||
|
Name = cat?.Name ?? "Uncategorised",
|
||||||
|
Icon = cat?.Icon ?? "",
|
||||||
|
Income = income,
|
||||||
|
Expense = expense
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.OrderByDescending(r => r.Expense)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Monthly data
|
||||||
|
var monthly = filtered
|
||||||
|
.GroupBy(t => new { t.Date.Year, t.Date.Month })
|
||||||
|
.OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_monthlyLabels = monthly.Select(g => $"{g.Key.Year}-{g.Key.Month:D2}").ToList();
|
||||||
|
_monthlyIncome = monthly.Select(g => g.Where(t => t.Type == TransactionType.Income).Sum(t => t.Amount))
|
||||||
|
.ToList();
|
||||||
|
_monthlyExpenses = monthly.Select(g => g.Where(t => t.Type == TransactionType.Expense).Sum(t => t.Amount))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RenderCharts()
|
||||||
|
{
|
||||||
|
if (_categoryBreakdown.Count > 0)
|
||||||
|
{
|
||||||
|
var labels = _categoryBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray();
|
||||||
|
var data = _categoryBreakdown.Select(c => c.Total).ToArray();
|
||||||
|
var colors = Enumerable.Range(0, _categoryBreakdown.Count).Select(i => ChartColors[i % ChartColors.Length])
|
||||||
|
.ToArray();
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "expensePieChart", labels, data, colors,
|
||||||
|
_currencySymbol, "doughnut");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_incomeBreakdown.Count > 0)
|
||||||
|
{
|
||||||
|
var labels = _incomeBreakdown.Select(c => $"{c.Icon} {c.Name}".Trim()).ToArray();
|
||||||
|
var data = _incomeBreakdown.Select(c => c.Total).ToArray();
|
||||||
|
var colors = Enumerable.Range(0, _incomeBreakdown.Count).Select(i => ChartColors[i % ChartColors.Length])
|
||||||
|
.ToArray();
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.createOrUpdatePieChart", "incomePieChart", labels, data, colors,
|
||||||
|
_currencySymbol, "doughnut");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_monthlyLabels.Count > 0)
|
||||||
|
{
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.createOrUpdateBarChart", "monthlyBarChart", _monthlyLabels.ToArray(),
|
||||||
|
_monthlyIncome.ToArray(), _monthlyExpenses.ToArray(), _currencySymbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetPreset(int days)
|
||||||
|
{
|
||||||
|
_preset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
||||||
|
_dateFrom = DateTime.Today;
|
||||||
|
_dateTo = DateTime.Today.AddDays(days);
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnDateChanged()
|
||||||
|
{
|
||||||
|
_preset = null;
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleAccount(int accountId)
|
||||||
|
{
|
||||||
|
if (!_selectedAccountIds.Remove(accountId))
|
||||||
|
_selectedAccountIds.Add(accountId);
|
||||||
|
await Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Refresh()
|
||||||
|
{
|
||||||
|
await LoadReport();
|
||||||
|
StateHasChanged();
|
||||||
|
await Task.Yield();
|
||||||
|
await RenderCharts();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.destroyChart", "expensePieChart");
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.destroyChart", "incomePieChart");
|
||||||
|
await Js.InvokeVoidAsync("chartInterop.destroyChart", "monthlyBarChart");
|
||||||
|
}
|
||||||
|
catch (JSDisconnectedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CategoryAmount
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Icon { get; set; } = string.Empty;
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CategorySummaryRow
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Icon { get; set; } = string.Empty;
|
||||||
|
public decimal Income { get; set; }
|
||||||
|
public decimal Expense { get; set; }
|
||||||
|
public decimal Net => Income - Expense;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
@page "/salary"
|
@page "/salary"
|
||||||
|
@using BudgetApp.Data.Entities
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject SalaryService SalaryService
|
@inject ISalaryRepository SalaryService
|
||||||
@inject AccountService AccountService
|
@inject IAccountRepository AccountService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
<h5 class="mb-0">Add Income Source</h5>
|
<h5 class="mb-0">Add Income Source</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (accountsList is not null && accountsList.Count == 0)
|
@if (_accountsList is not null && _accountsList.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning mb-0">
|
<div class="alert alert-warning mb-0">
|
||||||
You need to <a href="/accounts">create an account</a> before adding a salary.
|
You need to <a href="/accounts">create an account</a> before adding a salary.
|
||||||
@@ -23,23 +24,23 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<EditForm Model="formModel" OnValidSubmit="AddSalary" FormName="addSalary">
|
<EditForm Model="_formModel" OnValidSubmit="AddSalary" FormName="addSalary">
|
||||||
<ToastValidator />
|
<ToastValidator />
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">Name</label>
|
<label class="form-label">Name</label>
|
||||||
<InputText @bind-Value="formModel.Name" class="form-control" placeholder="e.g. Main Job" />
|
<InputText @bind-Value="_formModel.Name" class="form-control" placeholder="e.g. Main Job" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Amount</label>
|
<label class="form-label">Amount</label>
|
||||||
<InputNumber @bind-Value="formModel.Amount" class="form-control numeric-input" />
|
<InputNumber @bind-Value="_formModel.Amount" class="form-control numeric-input" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Account</label>
|
<label class="form-label">Account</label>
|
||||||
<InputSelect @bind-Value="formModel.AccountId" class="form-select">
|
<InputSelect @bind-Value="_formModel.AccountId" class="form-select">
|
||||||
@if (accountsList is not null)
|
@if (_accountsList is not null)
|
||||||
{
|
{
|
||||||
@foreach (var account in accountsList)
|
@foreach (var account in _accountsList)
|
||||||
{
|
{
|
||||||
<option value="@account.Id">@account.Name</option>
|
<option value="@account.Id">@account.Name</option>
|
||||||
}
|
}
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Recurrence</label>
|
<label class="form-label">Recurrence</label>
|
||||||
<InputSelect @bind-Value="formModel.RecurrenceType" class="form-select">
|
<InputSelect @bind-Value="_formModel.RecurrenceType" class="form-select">
|
||||||
<option value="@RecurrenceType.Weekly">Weekly</option>
|
<option value="@RecurrenceType.Weekly">Weekly</option>
|
||||||
<option value="@RecurrenceType.Monthly">Monthly</option>
|
<option value="@RecurrenceType.Monthly">Monthly</option>
|
||||||
<option value="@RecurrenceType.Yearly">Yearly</option>
|
<option value="@RecurrenceType.Yearly">Yearly</option>
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label">First Payday</label>
|
<label class="form-label">First Payday</label>
|
||||||
<InputDate @bind-Value="formModel.StartDate" class="form-control" title="The date of the first payment — the interval repeats from here indefinitely" />
|
<InputDate @bind-Value="_formModel.StartDate" class="form-control" title="The date of the first payment — the interval repeats from here indefinitely" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12 col-lg-auto d-flex align-items-end">
|
<div class="col-md-12 col-lg-auto d-flex align-items-end">
|
||||||
<button type="submit" class="btn btn-primary w-100">Add</button>
|
<button type="submit" class="btn btn-primary w-100">Add</button>
|
||||||
@@ -67,11 +68,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (salaries is null)
|
@if (_salaries is null)
|
||||||
{
|
{
|
||||||
<p><em>Loading...</em></p>
|
<p><em>Loading...</em></p>
|
||||||
}
|
}
|
||||||
else if (salaries.Count == 0)
|
else if (_salaries.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">No income sources yet. Add one above to start tracking your salary.</div>
|
<div class="alert alert-info">No income sources yet. Add one above to start tracking your salary.</div>
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ else
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var s in salaries)
|
@foreach (var s in _salaries)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fw-semibold">💰 @s.Name</td>
|
<td class="fw-semibold">💰 @s.Name</td>
|
||||||
@@ -108,7 +109,7 @@ else
|
|||||||
<span class="badge bg-info text-nowrap">🔁 @s.GetPaydaySummary()</span>
|
<span class="badge bg-info text-nowrap">🔁 @s.GetPaydaySummary()</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-nowrap">
|
<td class="text-nowrap">
|
||||||
@if (nextPaydates.TryGetValue(s.Id, out var next) && next.HasValue)
|
@if (_nextPaydates.TryGetValue(s.Id, out var next) && next.HasValue)
|
||||||
{
|
{
|
||||||
<span>@next.Value.ToString("MMM dd, yyyy")</span>
|
<span>@next.Value.ToString("MMM dd, yyyy")</span>
|
||||||
}
|
}
|
||||||
@@ -126,12 +127,12 @@ else
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<small class="text-muted">@salaries.Count income source@(salaries.Count == 1 ? "" : "s") · Transactions are generated automatically and appear on the <a href="/transactions">Transactions</a> page.</small>
|
<small class="text-muted">@_salaries.Count income source@(_salaries.Count == 1 ? "" : "s") · Transactions are generated automatically and appear on the <a href="/transactions">Transactions</a> page.</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showDeleteModal && pendingDeleteSalary is not null)
|
@if (_showDeleteModal && _pendingDeleteSalary is not null)
|
||||||
{
|
{
|
||||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@@ -141,7 +142,7 @@ else
|
|||||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to delete <strong>@pendingDeleteSalary.Name</strong>?</p>
|
<p>Are you sure you want to delete <strong>@_pendingDeleteSalary.Name</strong>?</p>
|
||||||
<p>All future unpaid salary transactions will be removed. Past transactions remain in your history.</p>
|
<p>All future unpaid salary transactions will be removed. Past transactions remain in your history.</p>
|
||||||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,110 +156,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<BudgetApp.Data.Salary>? salaries;
|
|
||||||
private List<Account>? accountsList;
|
|
||||||
private Dictionary<int, DateTime?> nextPaydates = [];
|
|
||||||
private string? userId;
|
|
||||||
|
|
||||||
private SalaryForm formModel = new();
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadData()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
var windowEnd = DateTime.Today.AddDays(90);
|
|
||||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
|
||||||
|
|
||||||
salaries = await SalaryService.GetSalariesAsync(userId);
|
|
||||||
accountsList = await AccountService.GetAccountsAsync(userId);
|
|
||||||
|
|
||||||
nextPaydates.Clear();
|
|
||||||
foreach (var sal in salaries)
|
|
||||||
{
|
|
||||||
nextPaydates[sal.Id] = await SalaryService.GetNextPaydateAsync(sal.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accountsList.Count > 0 && formModel.AccountId == 0)
|
|
||||||
{
|
|
||||||
formModel.AccountId = accountsList[0].Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddSalary()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
var entry = new BudgetApp.Data.Salary
|
|
||||||
{
|
|
||||||
Name = formModel.Name,
|
|
||||||
Amount = formModel.Amount,
|
|
||||||
AccountId = formModel.AccountId,
|
|
||||||
RecurrenceType = formModel.RecurrenceType,
|
|
||||||
StartDate = formModel.StartDate,
|
|
||||||
UserId = userId
|
|
||||||
};
|
|
||||||
|
|
||||||
await SalaryService.CreateSalaryAsync(entry, DateTime.Today.AddDays(90));
|
|
||||||
|
|
||||||
formModel = new SalaryForm
|
|
||||||
{
|
|
||||||
AccountId = accountsList?.FirstOrDefault()?.Id ?? 0
|
|
||||||
};
|
|
||||||
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete state
|
// Delete state
|
||||||
private bool showDeleteModal;
|
|
||||||
private BudgetApp.Data.Salary? pendingDeleteSalary;
|
|
||||||
|
|
||||||
private void ConfirmDelete(BudgetApp.Data.Salary s)
|
|
||||||
{
|
|
||||||
pendingDeleteSalary = s;
|
|
||||||
showDeleteModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelDelete()
|
|
||||||
{
|
|
||||||
showDeleteModal = false;
|
|
||||||
pendingDeleteSalary = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExecuteDelete()
|
|
||||||
{
|
|
||||||
if (userId is null || pendingDeleteSalary is null) return;
|
|
||||||
showDeleteModal = false;
|
|
||||||
await SalaryService.DeleteSalaryAsync(pendingDeleteSalary.Id, userId);
|
|
||||||
pendingDeleteSalary = null;
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class SalaryForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Name is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
|
||||||
public decimal Amount { get; set; }
|
|
||||||
|
|
||||||
public int AccountId { get; set; }
|
|
||||||
|
|
||||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.Monthly;
|
|
||||||
|
|
||||||
public DateTime StartDate { get; set; } = DateTime.Today;
|
|
||||||
|
|
||||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
|
||||||
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
|
||||||
{
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
|
||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Salary
|
||||||
|
{
|
||||||
|
private List<Data.Entities.Salary>? _salaries;
|
||||||
|
private List<Account>? _accountsList;
|
||||||
|
private readonly Dictionary<int, DateTime?> _nextPaydates = [];
|
||||||
|
private string? _userId;
|
||||||
|
private SalaryForm _formModel = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
var windowEnd = DateTime.Today.AddDays(90);
|
||||||
|
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, windowEnd);
|
||||||
|
|
||||||
|
_salaries = await SalaryService.GetSalariesAsync(_userId);
|
||||||
|
_accountsList = await AccountService.GetAccountsAsync(_userId);
|
||||||
|
|
||||||
|
_nextPaydates.Clear();
|
||||||
|
foreach (var sal in _salaries)
|
||||||
|
{
|
||||||
|
_nextPaydates[sal.Id] = await SalaryService.GetNextPaydateAsync(sal.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_accountsList.Count > 0 && _formModel.AccountId == 0)
|
||||||
|
{
|
||||||
|
_formModel.AccountId = _accountsList[0].Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddSalary()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
var entry = new Data.Entities.Salary
|
||||||
|
{
|
||||||
|
Name = _formModel.Name,
|
||||||
|
Amount = _formModel.Amount,
|
||||||
|
AccountId = _formModel.AccountId,
|
||||||
|
RecurrenceType = _formModel.RecurrenceType,
|
||||||
|
StartDate = _formModel.StartDate,
|
||||||
|
UserId = _userId
|
||||||
|
};
|
||||||
|
|
||||||
|
await SalaryService.CreateSalaryAsync(entry, DateTime.Today.AddDays(90));
|
||||||
|
|
||||||
|
_formModel = new SalaryForm
|
||||||
|
{
|
||||||
|
AccountId = _accountsList?.FirstOrDefault()?.Id ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _showDeleteModal;
|
||||||
|
private Data.Entities.Salary? _pendingDeleteSalary;
|
||||||
|
|
||||||
|
private void ConfirmDelete(Data.Entities.Salary s)
|
||||||
|
{
|
||||||
|
_pendingDeleteSalary = s;
|
||||||
|
_showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelDelete()
|
||||||
|
{
|
||||||
|
_showDeleteModal = false;
|
||||||
|
_pendingDeleteSalary = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteDelete()
|
||||||
|
{
|
||||||
|
if (_userId is null || _pendingDeleteSalary is null) return;
|
||||||
|
_showDeleteModal = false;
|
||||||
|
await SalaryService.DeleteSalaryAsync(_pendingDeleteSalary.Id, _userId);
|
||||||
|
_pendingDeleteSalary = null;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SalaryForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Name is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue,
|
||||||
|
ErrorMessage = "Amount must be greater than zero")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
|
||||||
|
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.Monthly;
|
||||||
|
|
||||||
|
public DateTime StartDate { get; set; } = DateTime.Today;
|
||||||
|
|
||||||
|
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||||
|
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
@page "/settings"
|
@page "/settings"
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@using BudgetApp.Data.Entities
|
||||||
@using Microsoft.AspNetCore.Identity
|
@using Microsoft.AspNetCore.Identity
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@inject UserManager<ApplicationUser> UserManager
|
@inject UserManager<ApplicationUser> UserManager
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
@inject SignInManager<ApplicationUser> SignInManager
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@@ -24,30 +24,30 @@
|
|||||||
<h5 class="mb-0">Change Password</h5>
|
<h5 class="mb-0">Change Password</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (passwordSuccess)
|
@if (_passwordSuccess)
|
||||||
{
|
{
|
||||||
<div class="alert alert-success">Password changed successfully.</div>
|
<div class="alert alert-success">Password changed successfully.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(passwordError))
|
@if (!string.IsNullOrEmpty(_passwordError))
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger">@passwordError</div>
|
<div class="alert alert-danger">@_passwordError</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<EditForm Model="passwordForm" OnValidSubmit="ChangePassword" FormName="changePassword">
|
<EditForm Model="_passwordForm" OnValidSubmit="ChangePassword" FormName="changePassword">
|
||||||
<ToastValidator />
|
<ToastValidator />
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Current Password</label>
|
<label class="form-label">Current Password</label>
|
||||||
<InputText @bind-Value="passwordForm.CurrentPassword" type="password" class="form-control" />
|
<InputText @bind-Value="_passwordForm.CurrentPassword" type="password" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">New Password</label>
|
<label class="form-label">New Password</label>
|
||||||
<InputText @bind-Value="passwordForm.NewPassword" type="password" class="form-control" />
|
<InputText @bind-Value="_passwordForm.NewPassword" type="password" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Confirm New Password</label>
|
<label class="form-label">Confirm New Password</label>
|
||||||
<InputText @bind-Value="passwordForm.ConfirmNewPassword" type="password" class="form-control" />
|
<InputText @bind-Value="_passwordForm.ConfirmNewPassword" type="password" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<button type="submit" class="btn btn-primary">Update Password</button>
|
<button type="submit" class="btn btn-primary">Update Password</button>
|
||||||
@@ -67,19 +67,19 @@
|
|||||||
This will permanently delete all your accounts, transactions and categories, giving you a clean slate. Your user account and password will be kept.
|
This will permanently delete all your accounts, transactions and categories, giving you a clean slate. Your user account and password will be kept.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@if (resetSuccess)
|
@if (_resetSuccess)
|
||||||
{
|
{
|
||||||
<div class="alert alert-success">All data has been reset successfully.</div>
|
<div class="alert alert-success">All data has been reset successfully.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(resetError))
|
@if (!string.IsNullOrEmpty(_resetError))
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger">@resetError</div>
|
<div class="alert alert-danger">@_resetError</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!confirmingReset)
|
@if (!_confirmingReset)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-warning" @onclick="() => confirmingReset = true">
|
<button class="btn btn-outline-warning" @onclick="() => _confirmingReset = true">
|
||||||
Reset All Data
|
Reset All Data
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -89,8 +89,8 @@
|
|||||||
<strong>Are you sure?</strong> This cannot be undone. Type <code>I UNDERSTAND</code> and enter your password to confirm.
|
<strong>Are you sure?</strong> This cannot be undone. Type <code>I UNDERSTAND</code> and enter your password to confirm.
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" style="max-width: 400px;">
|
<div class="mb-3" style="max-width: 400px;">
|
||||||
<input type="text" class="form-control mb-2" placeholder="Type I UNDERSTAND to confirm" @bind="resetConfirmation" />
|
<input type="text" class="form-control mb-2" placeholder="Type I UNDERSTAND to confirm" @bind="_resetConfirmation" />
|
||||||
<input type="password" class="form-control mb-2" placeholder="Enter your password" @bind="resetPassword" />
|
<input type="password" class="form-control mb-2" placeholder="Enter your password" @bind="_resetPassword" />
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-warning" @onclick="ResetData">Confirm Reset</button>
|
<button class="btn btn-warning" @onclick="ResetData">Confirm Reset</button>
|
||||||
<button class="btn btn-secondary" @onclick="CancelReset">Cancel</button>
|
<button class="btn btn-secondary" @onclick="CancelReset">Cancel</button>
|
||||||
@@ -110,14 +110,14 @@
|
|||||||
This will permanently delete your account and all associated data (accounts, transactions, and categories). This action cannot be undone.
|
This will permanently delete your account and all associated data (accounts, transactions, and categories). This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@if (!string.IsNullOrEmpty(deleteError))
|
@if (!string.IsNullOrEmpty(_deleteError))
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger">@deleteError</div>
|
<div class="alert alert-danger">@_deleteError</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!confirmingDelete)
|
@if (!_confirmingDelete)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-danger" @onclick="() => confirmingDelete = true">
|
<button class="btn btn-outline-danger" @onclick="() => _confirmingDelete = true">
|
||||||
Delete My Account
|
Delete My Account
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -127,166 +127,10 @@
|
|||||||
<strong>Are you sure?</strong> Type your password to confirm.
|
<strong>Are you sure?</strong> Type your password to confirm.
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group mb-3" style="max-width: 400px;">
|
<div class="input-group mb-3" style="max-width: 400px;">
|
||||||
<input type="password" class="form-control" placeholder="Enter your password" @bind="deletePassword" />
|
<input type="password" class="form-control" placeholder="Enter your password" @bind="_deletePassword" />
|
||||||
<button class="btn btn-danger" @onclick="DeleteAccount">Confirm Delete</button>
|
<button class="btn btn-danger" @onclick="DeleteAccount">Confirm Delete</button>
|
||||||
<button class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
<button class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
|
||||||
private static readonly string appVersion =
|
|
||||||
typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
|
||||||
|
|
||||||
private ChangePasswordForm passwordForm = new();
|
|
||||||
private bool passwordSuccess;
|
|
||||||
private string? passwordError;
|
|
||||||
|
|
||||||
private bool confirmingDelete;
|
|
||||||
private string deletePassword = string.Empty;
|
|
||||||
private string? deleteError;
|
|
||||||
|
|
||||||
private bool confirmingReset;
|
|
||||||
private string resetConfirmation = string.Empty;
|
|
||||||
private string resetPassword = string.Empty;
|
|
||||||
private string? resetError;
|
|
||||||
private bool resetSuccess;
|
|
||||||
|
|
||||||
private async Task ChangePassword()
|
|
||||||
{
|
|
||||||
passwordSuccess = false;
|
|
||||||
passwordError = null;
|
|
||||||
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
if (userIdClaim is null) return;
|
|
||||||
|
|
||||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
|
||||||
if (user is null) return;
|
|
||||||
|
|
||||||
var result = await UserManager.ChangePasswordAsync(user, passwordForm.CurrentPassword, passwordForm.NewPassword);
|
|
||||||
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
passwordSuccess = true;
|
|
||||||
passwordForm = new ChangePasswordForm();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
passwordError = string.Join(" ", result.Errors.Select(e => e.Description));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteAccount()
|
|
||||||
{
|
|
||||||
deleteError = null;
|
|
||||||
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
if (userIdClaim is null) return;
|
|
||||||
|
|
||||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
|
||||||
if (user is null) return;
|
|
||||||
|
|
||||||
var passwordValid = await UserManager.CheckPasswordAsync(user, deletePassword);
|
|
||||||
if (!passwordValid)
|
|
||||||
{
|
|
||||||
deleteError = "Incorrect password.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete all user data explicitly in correct dependency order
|
|
||||||
await DbContext.Transactions
|
|
||||||
.Where(t => t.Account.UserId == userIdClaim)
|
|
||||||
.ExecuteDeleteAsync();
|
|
||||||
|
|
||||||
await DbContext.Accounts
|
|
||||||
.Where(a => a.UserId == userIdClaim)
|
|
||||||
.ExecuteDeleteAsync();
|
|
||||||
|
|
||||||
await DbContext.Categories
|
|
||||||
.Where(c => c.UserId == userIdClaim)
|
|
||||||
.ExecuteDeleteAsync();
|
|
||||||
|
|
||||||
// Delete identity user
|
|
||||||
await UserManager.DeleteAsync(user);
|
|
||||||
await SignInManager.SignOutAsync();
|
|
||||||
|
|
||||||
Navigation.NavigateTo("/login", forceLoad: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelDelete()
|
|
||||||
{
|
|
||||||
confirmingDelete = false;
|
|
||||||
deletePassword = string.Empty;
|
|
||||||
deleteError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ResetData()
|
|
||||||
{
|
|
||||||
resetError = null;
|
|
||||||
resetSuccess = false;
|
|
||||||
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
if (userIdClaim is null) return;
|
|
||||||
|
|
||||||
var user = await UserManager.FindByIdAsync(userIdClaim);
|
|
||||||
if (user is null) return;
|
|
||||||
|
|
||||||
if (!string.Equals(resetConfirmation?.Trim(), "I UNDERSTAND", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
resetError = "Please type 'I UNDERSTAND' exactly to confirm.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var passwordValid = await UserManager.CheckPasswordAsync(user, resetPassword);
|
|
||||||
if (!passwordValid)
|
|
||||||
{
|
|
||||||
resetError = "Incorrect password.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await DbContext.Transactions
|
|
||||||
.Where(t => t.Account.UserId == userIdClaim)
|
|
||||||
.ExecuteDeleteAsync();
|
|
||||||
|
|
||||||
await DbContext.Accounts
|
|
||||||
.Where(a => a.UserId == userIdClaim)
|
|
||||||
.ExecuteDeleteAsync();
|
|
||||||
|
|
||||||
await DbContext.Categories
|
|
||||||
.Where(c => c.UserId == userIdClaim)
|
|
||||||
.ExecuteDeleteAsync();
|
|
||||||
|
|
||||||
confirmingReset = false;
|
|
||||||
resetConfirmation = string.Empty;
|
|
||||||
resetPassword = string.Empty;
|
|
||||||
resetSuccess = true;
|
|
||||||
|
|
||||||
AppState.NotifyAccountsChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelReset()
|
|
||||||
{
|
|
||||||
confirmingReset = false;
|
|
||||||
resetConfirmation = string.Empty;
|
|
||||||
resetPassword = string.Empty;
|
|
||||||
resetError = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ChangePasswordForm
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
|
|
||||||
public string CurrentPassword { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
|
||||||
public string NewPassword { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
|
|
||||||
[System.ComponentModel.DataAnnotations.Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
|
|
||||||
public string ConfirmNewPassword { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Settings
|
||||||
|
{
|
||||||
|
private static readonly string AppVersion =
|
||||||
|
typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||||
|
|
||||||
|
private ChangePasswordForm _passwordForm = new();
|
||||||
|
private bool _passwordSuccess;
|
||||||
|
private string? _passwordError;
|
||||||
|
private bool _confirmingDelete;
|
||||||
|
private string _deletePassword = string.Empty;
|
||||||
|
private string? _deleteError;
|
||||||
|
private bool _confirmingReset;
|
||||||
|
private string _resetConfirmation = string.Empty;
|
||||||
|
private string _resetPassword = string.Empty;
|
||||||
|
private string? _resetError;
|
||||||
|
private bool _resetSuccess;
|
||||||
|
|
||||||
|
private async Task ChangePassword()
|
||||||
|
{
|
||||||
|
_passwordSuccess = false;
|
||||||
|
_passwordError = null;
|
||||||
|
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (userIdClaim is null) return;
|
||||||
|
|
||||||
|
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||||
|
if (user is null) return;
|
||||||
|
|
||||||
|
var result =
|
||||||
|
await UserManager.ChangePasswordAsync(user, _passwordForm.CurrentPassword, _passwordForm.NewPassword);
|
||||||
|
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
_passwordSuccess = true;
|
||||||
|
_passwordForm = new ChangePasswordForm();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_passwordError = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteAccount()
|
||||||
|
{
|
||||||
|
_deleteError = null;
|
||||||
|
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (userIdClaim is null) return;
|
||||||
|
|
||||||
|
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||||
|
if (user is null) return;
|
||||||
|
|
||||||
|
var passwordValid = await UserManager.CheckPasswordAsync(user, _deletePassword);
|
||||||
|
if (!passwordValid)
|
||||||
|
{
|
||||||
|
_deleteError = "Incorrect password.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all user data explicitly in correct dependency order
|
||||||
|
await DbContext.Transactions
|
||||||
|
.Where(t => t.Account.UserId == userIdClaim)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
await DbContext.Accounts
|
||||||
|
.Where(a => a.UserId == userIdClaim)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
await DbContext.Categories
|
||||||
|
.Where(c => c.UserId == userIdClaim)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
// Delete identity user
|
||||||
|
await UserManager.DeleteAsync(user);
|
||||||
|
await SignInManager.SignOutAsync();
|
||||||
|
|
||||||
|
Navigation.NavigateTo("/login", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelDelete()
|
||||||
|
{
|
||||||
|
_confirmingDelete = false;
|
||||||
|
_deletePassword = string.Empty;
|
||||||
|
_deleteError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ResetData()
|
||||||
|
{
|
||||||
|
_resetError = null;
|
||||||
|
_resetSuccess = false;
|
||||||
|
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
var userIdClaim = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (userIdClaim is null) return;
|
||||||
|
|
||||||
|
var user = await UserManager.FindByIdAsync(userIdClaim);
|
||||||
|
if (user is null) return;
|
||||||
|
|
||||||
|
if (!string.Equals(_resetConfirmation?.Trim(), "I UNDERSTAND", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_resetError = "Please type 'I UNDERSTAND' exactly to confirm.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordValid = await UserManager.CheckPasswordAsync(user, _resetPassword);
|
||||||
|
if (!passwordValid)
|
||||||
|
{
|
||||||
|
_resetError = "Incorrect password.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await DbContext.Transactions
|
||||||
|
.Where(t => t.Account.UserId == userIdClaim)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
await DbContext.Accounts
|
||||||
|
.Where(a => a.UserId == userIdClaim)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
await DbContext.Categories
|
||||||
|
.Where(c => c.UserId == userIdClaim)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
_confirmingReset = false;
|
||||||
|
_resetConfirmation = string.Empty;
|
||||||
|
_resetPassword = string.Empty;
|
||||||
|
_resetSuccess = true;
|
||||||
|
|
||||||
|
AppState.NotifyAccountsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelReset()
|
||||||
|
{
|
||||||
|
_confirmingReset = false;
|
||||||
|
_resetConfirmation = string.Empty;
|
||||||
|
_resetPassword = string.Empty;
|
||||||
|
_resetError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ChangePasswordForm
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Current password is required")]
|
||||||
|
public string CurrentPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "New password is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.MinLength(8, ErrorMessage = "Password must be at least 8 characters")]
|
||||||
|
public string NewPassword { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Please confirm your new password")]
|
||||||
|
[System.ComponentModel.DataAnnotations.Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match")]
|
||||||
|
public string ConfirmNewPassword { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
@page "/transactions"
|
@page "/transactions"
|
||||||
|
@using BudgetApp.Data.Entities
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject TransactionService TransactionService
|
@inject ITransactionRepository TransactionService
|
||||||
@inject AccountService AccountService
|
@inject IAccountRepository AccountService
|
||||||
@inject CategoryService CategoryService
|
@inject ICategoryRepository CategoryService
|
||||||
@inject SalaryService SalaryService
|
@inject ISalaryRepository SalaryService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
<h5 class="mb-0">Add Transaction</h5>
|
<h5 class="mb-0">Add Transaction</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@if (accountsList is not null && accountsList.Count == 0)
|
@if (_accountsList is not null && _accountsList.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-warning mb-0">
|
<div class="alert alert-warning mb-0">
|
||||||
You need to <a href="/accounts">create an account</a> before adding transactions.
|
You need to <a href="/accounts">create an account</a> before adding transactions.
|
||||||
@@ -25,34 +26,34 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<EditForm Model="formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
|
<EditForm Model="_formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
|
||||||
<ToastValidator />
|
<ToastValidator />
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Description</label>
|
<label class="form-label">Description</label>
|
||||||
<InputText @bind-Value="formModel.Description" class="form-control" placeholder="e.g. Food shopping" title="A short description of the transaction" />
|
<InputText @bind-Value="_formModel.Description" class="form-control" placeholder="e.g. Food shopping" title="A short description of the transaction" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Amount</label>
|
<label class="form-label">Amount</label>
|
||||||
<InputNumber @bind-Value="formModel.Amount" class="form-control numeric-input" title="Enter a positive amount — the type determines income or expense" />
|
<InputNumber @bind-Value="_formModel.Amount" class="form-control numeric-input" title="Enter a positive amount — the type determines income or expense" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Date</label>
|
<label class="form-label">Date</label>
|
||||||
<InputDate @bind-Value="formModel.Date" class="form-control" />
|
<InputDate @bind-Value="_formModel.Date" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Type</label>
|
<label class="form-label">Type</label>
|
||||||
<InputSelect @bind-Value="formModel.Type" class="form-select">
|
<InputSelect @bind-Value="_formModel.Type" class="form-select">
|
||||||
<option value="@TransactionType.Expense">Expense</option>
|
<option value="@TransactionType.Expense">Expense</option>
|
||||||
<option value="@TransactionType.Income">Income</option>
|
<option value="@TransactionType.Income">Income</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Account</label>
|
<label class="form-label">Account</label>
|
||||||
<InputSelect @bind-Value="formModel.AccountId" class="form-select">
|
<InputSelect @bind-Value="_formModel.AccountId" class="form-select">
|
||||||
@if (accountsList is not null)
|
@if (_accountsList is not null)
|
||||||
{
|
{
|
||||||
@foreach (var account in accountsList)
|
@foreach (var account in _accountsList)
|
||||||
{
|
{
|
||||||
<option value="@account.Id">@account.Name</option>
|
<option value="@account.Id">@account.Name</option>
|
||||||
}
|
}
|
||||||
@@ -61,11 +62,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label">Category</label>
|
<label class="form-label">Category</label>
|
||||||
<InputSelect @bind-Value="formModel.CategoryId" class="form-select">
|
<InputSelect @bind-Value="_formModel.CategoryId" class="form-select">
|
||||||
<option value="">— None —</option>
|
<option value="">— None —</option>
|
||||||
@if (categoriesList is not null)
|
@if (_categoriesList is not null)
|
||||||
{
|
{
|
||||||
@foreach (var category in categoriesList)
|
@foreach (var category in _categoriesList)
|
||||||
{
|
{
|
||||||
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
|
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
|
||||||
}
|
}
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">Recurrence</label>
|
<label class="form-label">Recurrence</label>
|
||||||
<InputSelect @bind-Value="formModel.RecurrenceType" class="form-select" title="Set a recurring schedule — future occurrences are generated automatically">
|
<InputSelect @bind-Value="_formModel.RecurrenceType" class="form-select" title="Set a recurring schedule — future occurrences are generated automatically">
|
||||||
<option value="@RecurrenceType.None">None</option>
|
<option value="@RecurrenceType.None">None</option>
|
||||||
<option value="@RecurrenceType.Daily">Daily</option>
|
<option value="@RecurrenceType.Daily">Daily</option>
|
||||||
<option value="@RecurrenceType.Weekly">Weekly</option>
|
<option value="@RecurrenceType.Weekly">Weekly</option>
|
||||||
@@ -82,11 +83,11 @@
|
|||||||
<option value="@RecurrenceType.Yearly">Yearly</option>
|
<option value="@RecurrenceType.Yearly">Yearly</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
@if (formModel.RecurrenceType != RecurrenceType.None)
|
@if (_formModel.RecurrenceType != RecurrenceType.None)
|
||||||
{
|
{
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label">End Date <small class="text-muted">(max 1yr)</small></label>
|
<label class="form-label">End Date <small class="text-muted">(max 1yr)</small></label>
|
||||||
<InputDate @bind-Value="formModel.RecurrenceEndDate" class="form-control" max="@formModel.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
<InputDate @bind-Value="_formModel.RecurrenceEndDate" class="form-control" max="@_formModel.Date.AddYears(1).ToString("yyyy-MM-dd")" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="col-md-2 d-flex align-items-end">
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
@@ -98,11 +99,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (transactions is null)
|
@if (_transactions is null)
|
||||||
{
|
{
|
||||||
<p><em>Loading...</em></p>
|
<p><em>Loading...</em></p>
|
||||||
}
|
}
|
||||||
else if (transactions.Count == 0)
|
else if (_transactions.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info">No transactions yet.</div>
|
<div class="alert alert-info">No transactions yet.</div>
|
||||||
}
|
}
|
||||||
@@ -113,16 +114,16 @@ else
|
|||||||
<h5 class="mb-0">Filters</h5>
|
<h5 class="mb-0">Filters</h5>
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn @(windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
<button class="btn @(_windowPreset == "7d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 7 days" @onclick="() => SetWindow(7)">7d</button>
|
||||||
<button class="btn @(windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
<button class="btn @(_windowPreset == "30d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 30 days" @onclick="() => SetWindow(30)">30d</button>
|
||||||
<button class="btn @(windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
<button class="btn @(_windowPreset == "90d" ? "btn-primary" : "btn-outline-secondary")" title="Show next 90 days" @onclick="() => SetWindow(90)">90d</button>
|
||||||
<button class="btn @(windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
<button class="btn @(_windowPreset == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
||||||
title="Custom start date" @bind="windowStart" @bind:after="OnWindowDateChanged" />
|
title="Custom start date" @bind="_windowStart" @bind:after="OnWindowDateChanged" />
|
||||||
<span class="text-muted">to</span>
|
<span class="text-muted">to</span>
|
||||||
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
<input type="date" class="form-control form-control-sm" style="width:auto;"
|
||||||
title="Custom end date" @bind="windowEnd" @bind:after="OnWindowDateChanged" />
|
title="Custom end date" @bind="_windowEnd" @bind:after="OnWindowDateChanged" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body py-2">
|
<div class="card-body py-2">
|
||||||
@@ -130,25 +131,25 @@ else
|
|||||||
<div class="col-12 col-sm-auto">
|
<div class="col-12 col-sm-auto">
|
||||||
<select class="form-select form-select-sm" title="Filter transactions by account" @onchange="OnAccountFilterChanged">
|
<select class="form-select form-select-sm" title="Filter transactions by account" @onchange="OnAccountFilterChanged">
|
||||||
<option value="">All Accounts</option>
|
<option value="">All Accounts</option>
|
||||||
@if (accountsList is not null)
|
@if (_accountsList is not null)
|
||||||
{
|
{
|
||||||
@foreach (var a in accountsList)
|
@foreach (var a in _accountsList)
|
||||||
{
|
{
|
||||||
<option value="@a.Id" selected="@(filterAccountId == a.Id)">@a.Name</option>
|
<option value="@a.Id" selected="@(_filterAccountId == a.Id)">@a.Name</option>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm">
|
<div class="col-12 col-sm">
|
||||||
<input type="search" class="form-control form-control-sm"
|
<input type="search" class="form-control form-control-sm"
|
||||||
title="Search by description, account, category or amount" placeholder="Search..." @bind="searchQuery" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
title="Search by description, account, category or amount" placeholder="Search..." @bind="_searchQuery" @bind:event="oninput" @bind:after="OnSearchChanged" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-flex align-items-center gap-2">
|
<div class="col-auto d-flex align-items-center gap-2">
|
||||||
<label class="form-label mb-0 text-nowrap">Show:</label>
|
<label class="form-label mb-0 text-nowrap">Show:</label>
|
||||||
<select class="form-select form-select-sm" style="width:auto;" @onchange="OnPageSizeChanged">
|
<select class="form-select form-select-sm" style="width:auto;" @onchange="OnPageSizeChanged">
|
||||||
@foreach (var size in pageSizeOptions)
|
@foreach (var size in PageSizeOptions)
|
||||||
{
|
{
|
||||||
<option value="@size" selected="@(pageSize == size)">@size</option>
|
<option value="@size" selected="@(_pageSize == size)">@size</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,13 +175,13 @@ else
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var t in pagedTransactions)
|
@foreach (var t in PagedTransactions)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@if (t.SalaryId is null)
|
@if (t.SalaryId is null)
|
||||||
{
|
{
|
||||||
<input type="checkbox" class="form-check-input" checked="@selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
|
<input type="checkbox" class="form-check-input" checked="@_selectedIds.Contains(t.Id)" @onchange="() => ToggleSelect(t.Id)" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -255,55 +256,55 @@ else
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@if (totalPages > 1)
|
@if (TotalPages > 1)
|
||||||
{
|
{
|
||||||
<div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage <= 1 ? true : (bool?)null)" @onclick="() => currentPage--">← Prev</button>
|
<button class="btn btn-sm btn-outline-secondary" disabled="@(_currentPage <= 1 ? true : (bool?)null)" @onclick="() => _currentPage--">← Prev</button>
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
@if (selectedIds.Count > 0)
|
@if (_selectedIds.Count > 0)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
|
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
|
||||||
Delete @selectedIds.Count selected
|
Delete @_selectedIds.Count selected
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<small class="text-muted">Page @currentPage of @totalPages (@filteredTransactions.Count transactions)</small>
|
<small class="text-muted">Page @_currentPage of @TotalPages (@FilteredTransactions.Count transactions)</small>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-secondary" disabled="@(currentPage >= totalPages ? true : (bool?)null)" @onclick="() => currentPage++">Next →</button>
|
<button class="btn btn-sm btn-outline-secondary" disabled="@(_currentPage >= TotalPages ? true : (bool?)null)" @onclick="() => _currentPage++">Next →</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<small class="text-muted">@filteredTransactions.Count transactions</small>
|
<small class="text-muted">@FilteredTransactions.Count transactions</small>
|
||||||
@if (selectedIds.Count > 0)
|
@if (_selectedIds.Count > 0)
|
||||||
{
|
{
|
||||||
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
|
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
|
||||||
Delete @selectedIds.Count selected
|
Delete @_selectedIds.Count selected
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showDeleteModal)
|
@if (_showDeleteModal)
|
||||||
{
|
{
|
||||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h5 class="modal-title">@deleteModalTitle</h5>
|
<h5 class="modal-title">@_deleteModalTitle</h5>
|
||||||
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@if (bulkDeleteCount > 0)
|
@if (_bulkDeleteCount > 0)
|
||||||
{
|
{
|
||||||
<p>Are you sure you want to delete <strong>@bulkDeleteCount</strong> selected transactions?</p>
|
<p>Are you sure you want to delete <strong>@_bulkDeleteCount</strong> selected transactions?</p>
|
||||||
<p class="text-danger mb-0">This action cannot be undone.</p>
|
<p class="text-danger mb-0">This action cannot be undone.</p>
|
||||||
}
|
}
|
||||||
else if (pendingDeleteTransaction is not null)
|
else if (_pendingDeleteTransaction is not null)
|
||||||
{
|
{
|
||||||
<p>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
|
<p>Are you sure you want to delete <strong>@_pendingDeleteTransaction.Description</strong>?</p>
|
||||||
@if (pendingDeleteIsRecurring)
|
@if (_pendingDeleteIsRecurring)
|
||||||
{
|
{
|
||||||
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
|
<p>This transaction is part of a recurring series. Would you like to delete just this one, or all future transactions in the series?</p>
|
||||||
}
|
}
|
||||||
@@ -315,11 +316,11 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
<button type="button" class="btn btn-secondary" @onclick="CancelDelete">Cancel</button>
|
||||||
@if (bulkDeleteCount > 0)
|
@if (_bulkDeleteCount > 0)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-danger" @onclick="ExecuteBulkDelete">Delete All Selected</button>
|
<button type="button" class="btn btn-danger" @onclick="ExecuteBulkDelete">Delete All Selected</button>
|
||||||
}
|
}
|
||||||
else if (pendingDeleteIsRecurring)
|
else if (_pendingDeleteIsRecurring)
|
||||||
{
|
{
|
||||||
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
<button type="button" class="btn btn-outline-danger" @onclick="DeleteSingle">This One Only</button>
|
||||||
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
<button type="button" class="btn btn-danger" @onclick="DeleteSeries">All Future in Series</button>
|
||||||
@@ -334,7 +335,7 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (showEditModal)
|
@if (_showEditModal)
|
||||||
{
|
{
|
||||||
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
|
||||||
<div class="modal-dialog modal-dialog-scrollable">
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
@@ -343,34 +344,34 @@ else
|
|||||||
<h5 class="modal-title">Edit Transaction</h5>
|
<h5 class="modal-title">Edit Transaction</h5>
|
||||||
<button type="button" class="btn-close" @onclick="CloseEditModal"></button>
|
<button type="button" class="btn-close" @onclick="CloseEditModal"></button>
|
||||||
</div>
|
</div>
|
||||||
<EditForm Model="editModel" OnValidSubmit="SaveEdit" FormName="editTransaction">
|
<EditForm Model="_editModel" OnValidSubmit="SaveEdit" FormName="editTransaction">
|
||||||
<ToastValidator />
|
<ToastValidator />
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Description</label>
|
<label class="form-label">Description</label>
|
||||||
<InputText @bind-Value="editModel.Description" class="form-control" />
|
<InputText @bind-Value="_editModel.Description" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Amount</label>
|
<label class="form-label">Amount</label>
|
||||||
<InputNumber @bind-Value="editModel.Amount" class="form-control numeric-input" />
|
<InputNumber @bind-Value="_editModel.Amount" class="form-control numeric-input" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Date</label>
|
<label class="form-label">Date</label>
|
||||||
<InputDate @bind-Value="editModel.Date" class="form-control" />
|
<InputDate @bind-Value="_editModel.Date" class="form-control" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Type</label>
|
<label class="form-label">Type</label>
|
||||||
<InputSelect @bind-Value="editModel.Type" class="form-select">
|
<InputSelect @bind-Value="_editModel.Type" class="form-select">
|
||||||
<option value="@TransactionType.Expense">Expense</option>
|
<option value="@TransactionType.Expense">Expense</option>
|
||||||
<option value="@TransactionType.Income">Income</option>
|
<option value="@TransactionType.Income">Income</option>
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Account</label>
|
<label class="form-label">Account</label>
|
||||||
<InputSelect @bind-Value="editModel.AccountId" class="form-select">
|
<InputSelect @bind-Value="_editModel.AccountId" class="form-select">
|
||||||
@if (accountsList is not null)
|
@if (_accountsList is not null)
|
||||||
{
|
{
|
||||||
@foreach (var account in accountsList)
|
@foreach (var account in _accountsList)
|
||||||
{
|
{
|
||||||
<option value="@account.Id">@account.Name</option>
|
<option value="@account.Id">@account.Name</option>
|
||||||
}
|
}
|
||||||
@@ -379,18 +380,18 @@ else
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Category</label>
|
<label class="form-label">Category</label>
|
||||||
<InputSelect @bind-Value="editModel.CategoryId" class="form-select">
|
<InputSelect @bind-Value="_editModel.CategoryId" class="form-select">
|
||||||
<option value="">— None —</option>
|
<option value="">— None —</option>
|
||||||
@if (categoriesList is not null)
|
@if (_categoriesList is not null)
|
||||||
{
|
{
|
||||||
@foreach (var category in categoriesList)
|
@foreach (var category in _categoriesList)
|
||||||
{
|
{
|
||||||
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
|
<option value="@category.Id">@(string.IsNullOrEmpty(category.Icon) ? "" : category.Icon + " ")@category.Name</option>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</InputSelect>
|
</InputSelect>
|
||||||
</div>
|
</div>
|
||||||
@if (editIsPartOfSeries)
|
@if (_editIsPartOfSeries)
|
||||||
{
|
{
|
||||||
<div class="alert alert-info mb-0">
|
<div class="alert alert-info mb-0">
|
||||||
<i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
|
<i class="bi bi-info-circle"></i> Changes will apply to this and all future occurrences in the series.
|
||||||
@@ -409,350 +410,17 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<Transaction>? transactions;
|
|
||||||
private List<Account>? accountsList;
|
|
||||||
private List<Category>? categoriesList;
|
|
||||||
private TransactionForm formModel = new();
|
|
||||||
private string? userId;
|
|
||||||
private int? filterAccountId;
|
|
||||||
private string searchQuery = string.Empty;
|
|
||||||
|
|
||||||
// Time window state
|
// Time window state
|
||||||
private DateTime windowStart = DateTime.Today;
|
|
||||||
private DateTime windowEnd = DateTime.Today.AddDays(30);
|
|
||||||
private string? windowPreset = "30d";
|
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
private int currentPage = 1;
|
|
||||||
private int pageSize = 25;
|
|
||||||
private static readonly int[] pageSizeOptions = [10, 25, 50, 100];
|
|
||||||
private int totalPages => (int)Math.Ceiling(filteredTransactions.Count / (double)pageSize);
|
|
||||||
private List<Transaction> pagedTransactions => filteredTransactions
|
|
||||||
.Skip((currentPage - 1) * pageSize)
|
|
||||||
.Take(pageSize)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Sort state
|
// Sort state
|
||||||
private string sortColumn = "date";
|
|
||||||
private bool sortAscending = true;
|
|
||||||
|
|
||||||
private List<Transaction> filteredTransactions
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var list = (transactions ?? []).Where(t => t.Date.Date >= windowStart.Date && t.Date.Date <= windowEnd.Date);
|
|
||||||
if (filterAccountId is not null)
|
|
||||||
list = list.Where(t => t.AccountId == filterAccountId);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(searchQuery))
|
|
||||||
{
|
|
||||||
var q = searchQuery.Trim();
|
|
||||||
list = list.Where(t =>
|
|
||||||
t.Description.Contains(q, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| t.Account.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| (t.Category?.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|
|
||||||
|| t.Amount.ToString("F2").Contains(q, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
list = sortColumn switch
|
|
||||||
{
|
|
||||||
"description" => sortAscending
|
|
||||||
? list.OrderBy(t => t.Description, StringComparer.OrdinalIgnoreCase)
|
|
||||||
: list.OrderByDescending(t => t.Description, StringComparer.OrdinalIgnoreCase),
|
|
||||||
"amount" => sortAscending
|
|
||||||
? list.OrderBy(t => t.Amount)
|
|
||||||
: list.OrderByDescending(t => t.Amount),
|
|
||||||
_ => sortAscending
|
|
||||||
? list.OrderBy(t => t.Date)
|
|
||||||
: list.OrderByDescending(t => t.Date),
|
|
||||||
};
|
|
||||||
|
|
||||||
return list.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnAccountFilterChanged(ChangeEventArgs e)
|
|
||||||
{
|
|
||||||
filterAccountId = int.TryParse(e.Value?.ToString(), out var id) ? id : null;
|
|
||||||
currentPage = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSearchChanged() => currentPage = 1;
|
|
||||||
|
|
||||||
private async Task SetWindow(int days)
|
|
||||||
{
|
|
||||||
windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
|
||||||
windowStart = DateTime.Today;
|
|
||||||
windowEnd = DateTime.Today.AddDays(days);
|
|
||||||
currentPage = 1;
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnWindowDateChanged()
|
|
||||||
{
|
|
||||||
windowPreset = null;
|
|
||||||
currentPage = 1;
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnPageSizeChanged(ChangeEventArgs e)
|
|
||||||
{
|
|
||||||
if (int.TryParse(e.Value?.ToString(), out var size))
|
|
||||||
{
|
|
||||||
pageSize = size;
|
|
||||||
currentPage = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetSort(string column)
|
|
||||||
{
|
|
||||||
if (sortColumn == column)
|
|
||||||
sortAscending = !sortAscending;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sortColumn = column;
|
|
||||||
sortAscending = true;
|
|
||||||
}
|
|
||||||
currentPage = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MarkupString SortIndicator(string column)
|
|
||||||
{
|
|
||||||
if (sortColumn != column)
|
|
||||||
return new MarkupString("<span class=\"text-muted opacity-50\">⇅</span>");
|
|
||||||
return new MarkupString(sortAscending ? "▲" : "▼");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
||||||
userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadData()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
await SalaryService.MaterializeSalaryTransactionsAsync(userId, windowEnd);
|
|
||||||
transactions = await TransactionService.GetTransactionsAsync(userId);
|
|
||||||
accountsList = await AccountService.GetAccountsAsync(userId);
|
|
||||||
categoriesList = await CategoryService.GetCategoriesAsync(userId);
|
|
||||||
|
|
||||||
if (accountsList.Count > 0 && formModel.AccountId == 0)
|
|
||||||
{
|
|
||||||
formModel.AccountId = accountsList[0].Id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddTransaction()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
|
|
||||||
var transaction = new Transaction
|
|
||||||
{
|
|
||||||
Description = formModel.Description,
|
|
||||||
Amount = formModel.Amount,
|
|
||||||
Date = formModel.Date,
|
|
||||||
Type = formModel.Type,
|
|
||||||
AccountId = formModel.AccountId,
|
|
||||||
CategoryId = formModel.CategoryId,
|
|
||||||
RecurrenceType = formModel.RecurrenceType,
|
|
||||||
RecurrenceEndDate = formModel.RecurrenceEndDate
|
|
||||||
};
|
|
||||||
await TransactionService.CreateTransactionAsync(transaction);
|
|
||||||
formModel = new TransactionForm
|
|
||||||
{
|
|
||||||
AccountId = accountsList?.FirstOrDefault()?.Id ?? 0
|
|
||||||
};
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection state
|
// Selection state
|
||||||
private HashSet<int> selectedIds = [];
|
|
||||||
|
|
||||||
private void ToggleSelect(int id)
|
|
||||||
{
|
|
||||||
var t = transactions?.FirstOrDefault(x => x.Id == id);
|
|
||||||
if (t?.SalaryId is not null) return;
|
|
||||||
if (!selectedIds.Remove(id))
|
|
||||||
selectedIds.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool IsAllPageSelected()
|
|
||||||
{
|
|
||||||
var selectable = pagedTransactions.Where(t => t.SalaryId is null).ToList();
|
|
||||||
return selectable.Count > 0 && selectable.All(t => selectedIds.Contains(t.Id));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ToggleSelectAll(ChangeEventArgs e)
|
|
||||||
{
|
|
||||||
var selectable = pagedTransactions.Where(t => t.SalaryId is null).ToList();
|
|
||||||
if (e.Value is true)
|
|
||||||
{
|
|
||||||
foreach (var t in selectable)
|
|
||||||
selectedIds.Add(t.Id);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
foreach (var t in selectable)
|
|
||||||
selectedIds.Remove(t.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete confirmation state
|
// Delete confirmation state
|
||||||
private bool showDeleteModal;
|
|
||||||
private Transaction? pendingDeleteTransaction;
|
|
||||||
private bool pendingDeleteIsRecurring;
|
|
||||||
private int bulkDeleteCount;
|
|
||||||
private string deleteModalTitle = "Delete Transaction";
|
|
||||||
|
|
||||||
// Edit modal state
|
// Edit modal state
|
||||||
private bool showEditModal;
|
|
||||||
private int? editingTransactionId;
|
|
||||||
private bool editIsPartOfSeries;
|
|
||||||
private TransactionForm editModel = new();
|
|
||||||
|
|
||||||
private void OpenEditModal(Transaction t)
|
|
||||||
{
|
|
||||||
editingTransactionId = t.Id;
|
|
||||||
editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
|
||||||
editModel = new TransactionForm
|
|
||||||
{
|
|
||||||
Description = t.Description,
|
|
||||||
Amount = t.Amount,
|
|
||||||
Date = t.Date,
|
|
||||||
Type = t.Type,
|
|
||||||
AccountId = t.AccountId,
|
|
||||||
CategoryId = t.CategoryId,
|
|
||||||
RecurrenceType = t.RecurrenceType,
|
|
||||||
RecurrenceEndDate = t.RecurrenceEndDate
|
|
||||||
};
|
|
||||||
showEditModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CloseEditModal() => showEditModal = false;
|
|
||||||
|
|
||||||
private async Task SaveEdit()
|
|
||||||
{
|
|
||||||
if (userId is null || editingTransactionId is null) return;
|
|
||||||
|
|
||||||
await TransactionService.UpdateTransactionAsync(
|
|
||||||
editingTransactionId.Value, userId,
|
|
||||||
editModel.Description, editModel.Amount, editModel.Date,
|
|
||||||
editModel.Type, editModel.AccountId, editModel.CategoryId,
|
|
||||||
editModel.RecurrenceType, editModel.RecurrenceEndDate);
|
|
||||||
|
|
||||||
showEditModal = false;
|
|
||||||
editingTransactionId = null;
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ConfirmDelete(Transaction t)
|
|
||||||
{
|
|
||||||
pendingDeleteTransaction = t;
|
|
||||||
pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
|
||||||
bulkDeleteCount = 0;
|
|
||||||
deleteModalTitle = pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction";
|
|
||||||
showDeleteModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ConfirmBulkDelete()
|
|
||||||
{
|
|
||||||
pendingDeleteTransaction = null;
|
|
||||||
pendingDeleteIsRecurring = false;
|
|
||||||
bulkDeleteCount = selectedIds.Count;
|
|
||||||
deleteModalTitle = $"Delete {bulkDeleteCount} Transactions";
|
|
||||||
showDeleteModal = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteSingle()
|
|
||||||
{
|
|
||||||
if (userId is null || pendingDeleteTransaction is null) return;
|
|
||||||
showDeleteModal = false;
|
|
||||||
await TransactionService.DeleteTransactionAsync(pendingDeleteTransaction.Id, userId);
|
|
||||||
selectedIds.Remove(pendingDeleteTransaction.Id);
|
|
||||||
pendingDeleteTransaction = null;
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DeleteSeries()
|
|
||||||
{
|
|
||||||
if (userId is null || pendingDeleteTransaction is null) return;
|
|
||||||
showDeleteModal = false;
|
|
||||||
await TransactionService.DeleteTransactionSeriesAsync(pendingDeleteTransaction.Id, userId);
|
|
||||||
selectedIds.Remove(pendingDeleteTransaction.Id);
|
|
||||||
pendingDeleteTransaction = null;
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExecuteBulkDelete()
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
showDeleteModal = false;
|
|
||||||
foreach (var id in selectedIds.ToList())
|
|
||||||
{
|
|
||||||
var t = transactions?.FirstOrDefault(x => x.Id == id);
|
|
||||||
if (t?.SalaryId is not null) continue;
|
|
||||||
await TransactionService.DeleteTransactionAsync(id, userId);
|
|
||||||
}
|
|
||||||
selectedIds.Clear();
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CancelDelete()
|
|
||||||
{
|
|
||||||
showDeleteModal = false;
|
|
||||||
pendingDeleteTransaction = null;
|
|
||||||
bulkDeleteCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task TogglePaid(Transaction t)
|
|
||||||
{
|
|
||||||
if (userId is null) return;
|
|
||||||
await TransactionService.MarkAsPaidAsync(t.Id, userId, !t.IsPaid);
|
|
||||||
await LoadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
|
||||||
{
|
|
||||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
|
||||||
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
|
||||||
public string Description { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue, ErrorMessage = "Amount must be greater than zero")]
|
|
||||||
public decimal Amount { get; set; }
|
|
||||||
|
|
||||||
public DateTime Date { get; set; } = DateTime.Today;
|
|
||||||
|
|
||||||
public TransactionType Type { get; set; } = TransactionType.Expense;
|
|
||||||
|
|
||||||
public int AccountId { get; set; }
|
|
||||||
|
|
||||||
public int? CategoryId { get; set; }
|
|
||||||
|
|
||||||
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
|
||||||
|
|
||||||
public DateTime? RecurrenceEndDate { get; set; }
|
|
||||||
|
|
||||||
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
|
||||||
{
|
|
||||||
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
|
||||||
{
|
|
||||||
var maxEnd = Date.AddYears(1);
|
|
||||||
if (RecurrenceEndDate.Value > maxEnd)
|
|
||||||
{
|
|
||||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
|
||||||
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
|
||||||
[nameof(RecurrenceEndDate)]);
|
|
||||||
}
|
|
||||||
if (RecurrenceEndDate.Value <= Date)
|
|
||||||
{
|
|
||||||
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
|
||||||
"Recurrence end date must be after the transaction date.",
|
|
||||||
[nameof(RecurrenceEndDate)]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components.Pages;
|
||||||
|
|
||||||
|
public partial class Transactions
|
||||||
|
{
|
||||||
|
private List<Transaction>? _transactions;
|
||||||
|
private List<Account>? _accountsList;
|
||||||
|
private List<Category>? _categoriesList;
|
||||||
|
private TransactionForm _formModel = new();
|
||||||
|
private string? _userId;
|
||||||
|
private int? _filterAccountId;
|
||||||
|
private string _searchQuery = string.Empty;
|
||||||
|
private DateTime _windowStart = DateTime.Today;
|
||||||
|
private DateTime _windowEnd = DateTime.Today.AddDays(30);
|
||||||
|
private string? _windowPreset = "30d";
|
||||||
|
private int _currentPage = 1;
|
||||||
|
private int _pageSize = 25;
|
||||||
|
private static readonly int[] PageSizeOptions = [10, 25, 50, 100];
|
||||||
|
private int TotalPages => (int)Math.Ceiling(FilteredTransactions.Count / (double)_pageSize);
|
||||||
|
|
||||||
|
private List<Transaction> PagedTransactions => FilteredTransactions
|
||||||
|
.Skip((_currentPage - 1) * _pageSize)
|
||||||
|
.Take(_pageSize)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private string _sortColumn = "date";
|
||||||
|
private bool _sortAscending = true;
|
||||||
|
|
||||||
|
private List<Transaction> FilteredTransactions
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var list = (_transactions ?? []).Where(t =>
|
||||||
|
t.Date.Date >= _windowStart.Date && t.Date.Date <= _windowEnd.Date);
|
||||||
|
if (_filterAccountId is not null)
|
||||||
|
list = list.Where(t => t.AccountId == _filterAccountId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(_searchQuery))
|
||||||
|
{
|
||||||
|
var q = _searchQuery.Trim();
|
||||||
|
list = list.Where(t =>
|
||||||
|
t.Description.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| t.Account.Name.Contains(q, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| (t.Category?.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false)
|
||||||
|
|| t.Amount.ToString("F2").Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
list = _sortColumn switch
|
||||||
|
{
|
||||||
|
"description" => _sortAscending
|
||||||
|
? list.OrderBy(t => t.Description, StringComparer.OrdinalIgnoreCase)
|
||||||
|
: list.OrderByDescending(t => t.Description, StringComparer.OrdinalIgnoreCase),
|
||||||
|
"amount" => _sortAscending
|
||||||
|
? list.OrderBy(t => t.Amount)
|
||||||
|
: list.OrderByDescending(t => t.Amount),
|
||||||
|
_ => _sortAscending
|
||||||
|
? list.OrderBy(t => t.Date)
|
||||||
|
: list.OrderByDescending(t => t.Date),
|
||||||
|
};
|
||||||
|
|
||||||
|
return list.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAccountFilterChanged(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_filterAccountId = int.TryParse(e.Value?.ToString(), out var id) ? id : null;
|
||||||
|
_currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSearchChanged() => _currentPage = 1;
|
||||||
|
|
||||||
|
private async Task SetWindow(int days)
|
||||||
|
{
|
||||||
|
_windowPreset = days switch { 7 => "7d", 30 => "30d", 90 => "90d", 365 => "1y", _ => null };
|
||||||
|
_windowStart = DateTime.Today;
|
||||||
|
_windowEnd = DateTime.Today.AddDays(days);
|
||||||
|
_currentPage = 1;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnWindowDateChanged()
|
||||||
|
{
|
||||||
|
_windowPreset = null;
|
||||||
|
_currentPage = 1;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPageSizeChanged(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
if (int.TryParse(e.Value?.ToString(), out var size))
|
||||||
|
{
|
||||||
|
_pageSize = size;
|
||||||
|
_currentPage = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetSort(string column)
|
||||||
|
{
|
||||||
|
if (_sortColumn == column)
|
||||||
|
_sortAscending = !_sortAscending;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_sortColumn = column;
|
||||||
|
_sortAscending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MarkupString SortIndicator(string column)
|
||||||
|
{
|
||||||
|
if (_sortColumn != column)
|
||||||
|
return new MarkupString("<span class=\"text-muted opacity-50\">⇅</span>");
|
||||||
|
return new MarkupString(_sortAscending ? "▲" : "▼");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
_userId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
await SalaryService.MaterializeSalaryTransactionsAsync(_userId, _windowEnd);
|
||||||
|
_transactions = await TransactionService.GetTransactionsAsync(_userId);
|
||||||
|
_accountsList = await AccountService.GetAccountsAsync(_userId);
|
||||||
|
_categoriesList = await CategoryService.GetCategoriesAsync(_userId);
|
||||||
|
|
||||||
|
if (_accountsList.Count > 0 && _formModel.AccountId == 0)
|
||||||
|
{
|
||||||
|
_formModel.AccountId = _accountsList[0].Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddTransaction()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
|
||||||
|
var transaction = new Transaction
|
||||||
|
{
|
||||||
|
Description = _formModel.Description,
|
||||||
|
Amount = _formModel.Amount,
|
||||||
|
Date = _formModel.Date,
|
||||||
|
Type = _formModel.Type,
|
||||||
|
AccountId = _formModel.AccountId,
|
||||||
|
CategoryId = _formModel.CategoryId,
|
||||||
|
RecurrenceType = _formModel.RecurrenceType,
|
||||||
|
RecurrenceEndDate = _formModel.RecurrenceEndDate
|
||||||
|
};
|
||||||
|
await TransactionService.CreateTransactionAsync(transaction);
|
||||||
|
_formModel = new TransactionForm
|
||||||
|
{
|
||||||
|
AccountId = _accountsList?.FirstOrDefault()?.Id ?? 0
|
||||||
|
};
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly HashSet<int> _selectedIds = [];
|
||||||
|
|
||||||
|
private void ToggleSelect(int id)
|
||||||
|
{
|
||||||
|
var t = _transactions?.FirstOrDefault(x => x.Id == id);
|
||||||
|
if (t?.SalaryId is not null) return;
|
||||||
|
if (!_selectedIds.Remove(id))
|
||||||
|
_selectedIds.Add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsAllPageSelected()
|
||||||
|
{
|
||||||
|
var selectable = PagedTransactions.Where(t => t.SalaryId is null).ToList();
|
||||||
|
return selectable.Count > 0 && selectable.All(t => _selectedIds.Contains(t.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleSelectAll(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
var selectable = PagedTransactions.Where(t => t.SalaryId is null).ToList();
|
||||||
|
if (e.Value is true)
|
||||||
|
{
|
||||||
|
foreach (var t in selectable)
|
||||||
|
_selectedIds.Add(t.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var t in selectable)
|
||||||
|
_selectedIds.Remove(t.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool _showDeleteModal;
|
||||||
|
private Transaction? _pendingDeleteTransaction;
|
||||||
|
private bool _pendingDeleteIsRecurring;
|
||||||
|
private int _bulkDeleteCount;
|
||||||
|
private string _deleteModalTitle = "Delete Transaction";
|
||||||
|
private bool _showEditModal;
|
||||||
|
private int? _editingTransactionId;
|
||||||
|
private bool _editIsPartOfSeries;
|
||||||
|
private TransactionForm _editModel = new();
|
||||||
|
|
||||||
|
private void OpenEditModal(Transaction t)
|
||||||
|
{
|
||||||
|
_editingTransactionId = t.Id;
|
||||||
|
_editIsPartOfSeries = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||||
|
_editModel = new TransactionForm
|
||||||
|
{
|
||||||
|
Description = t.Description,
|
||||||
|
Amount = t.Amount,
|
||||||
|
Date = t.Date,
|
||||||
|
Type = t.Type,
|
||||||
|
AccountId = t.AccountId,
|
||||||
|
CategoryId = t.CategoryId,
|
||||||
|
RecurrenceType = t.RecurrenceType,
|
||||||
|
RecurrenceEndDate = t.RecurrenceEndDate
|
||||||
|
};
|
||||||
|
_showEditModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseEditModal() => _showEditModal = false;
|
||||||
|
|
||||||
|
private async Task SaveEdit()
|
||||||
|
{
|
||||||
|
if (_userId is null || _editingTransactionId is null) return;
|
||||||
|
|
||||||
|
await TransactionService.UpdateTransactionAsync(
|
||||||
|
_editingTransactionId.Value, _userId,
|
||||||
|
_editModel.Description, _editModel.Amount, _editModel.Date,
|
||||||
|
_editModel.Type, _editModel.AccountId, _editModel.CategoryId,
|
||||||
|
_editModel.RecurrenceType, _editModel.RecurrenceEndDate);
|
||||||
|
|
||||||
|
_showEditModal = false;
|
||||||
|
_editingTransactionId = null;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfirmDelete(Transaction t)
|
||||||
|
{
|
||||||
|
_pendingDeleteTransaction = t;
|
||||||
|
_pendingDeleteIsRecurring = t.RecurrenceType != RecurrenceType.None || t.ParentTransactionId is not null;
|
||||||
|
_bulkDeleteCount = 0;
|
||||||
|
_deleteModalTitle = _pendingDeleteIsRecurring ? "Delete Recurring Transaction" : "Delete Transaction";
|
||||||
|
_showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ConfirmBulkDelete()
|
||||||
|
{
|
||||||
|
_pendingDeleteTransaction = null;
|
||||||
|
_pendingDeleteIsRecurring = false;
|
||||||
|
_bulkDeleteCount = _selectedIds.Count;
|
||||||
|
_deleteModalTitle = $"Delete {_bulkDeleteCount} Transactions";
|
||||||
|
_showDeleteModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSingle()
|
||||||
|
{
|
||||||
|
if (_userId is null || _pendingDeleteTransaction is null) return;
|
||||||
|
_showDeleteModal = false;
|
||||||
|
await TransactionService.DeleteTransactionAsync(_pendingDeleteTransaction.Id, _userId);
|
||||||
|
_selectedIds.Remove(_pendingDeleteTransaction.Id);
|
||||||
|
_pendingDeleteTransaction = null;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteSeries()
|
||||||
|
{
|
||||||
|
if (_userId is null || _pendingDeleteTransaction is null) return;
|
||||||
|
_showDeleteModal = false;
|
||||||
|
await TransactionService.DeleteTransactionSeriesAsync(_pendingDeleteTransaction.Id, _userId);
|
||||||
|
_selectedIds.Remove(_pendingDeleteTransaction.Id);
|
||||||
|
_pendingDeleteTransaction = null;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteBulkDelete()
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
_showDeleteModal = false;
|
||||||
|
foreach (var id in _selectedIds.ToList())
|
||||||
|
{
|
||||||
|
var t = _transactions?.FirstOrDefault(x => x.Id == id);
|
||||||
|
if (t?.SalaryId is not null) continue;
|
||||||
|
await TransactionService.DeleteTransactionAsync(id, _userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedIds.Clear();
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelDelete()
|
||||||
|
{
|
||||||
|
_showDeleteModal = false;
|
||||||
|
_pendingDeleteTransaction = null;
|
||||||
|
_bulkDeleteCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TogglePaid(Transaction t)
|
||||||
|
{
|
||||||
|
if (_userId is null) return;
|
||||||
|
await TransactionService.MarkAsPaidAsync(t.Id, _userId, !t.IsPaid);
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TransactionForm : System.ComponentModel.DataAnnotations.IValidatableObject
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Description is required")]
|
||||||
|
[System.ComponentModel.DataAnnotations.MaxLength(200)]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[System.ComponentModel.DataAnnotations.Range(0.01, double.MaxValue,
|
||||||
|
ErrorMessage = "Amount must be greater than zero")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
public DateTime Date { get; set; } = DateTime.Today;
|
||||||
|
|
||||||
|
public TransactionType Type { get; set; } = TransactionType.Expense;
|
||||||
|
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
|
||||||
|
public int? CategoryId { get; set; }
|
||||||
|
|
||||||
|
public RecurrenceType RecurrenceType { get; set; } = RecurrenceType.None;
|
||||||
|
|
||||||
|
public DateTime? RecurrenceEndDate { get; set; }
|
||||||
|
|
||||||
|
public IEnumerable<System.ComponentModel.DataAnnotations.ValidationResult> Validate(
|
||||||
|
System.ComponentModel.DataAnnotations.ValidationContext validationContext)
|
||||||
|
{
|
||||||
|
if (RecurrenceType != RecurrenceType.None && RecurrenceEndDate.HasValue)
|
||||||
|
{
|
||||||
|
var maxEnd = Date.AddYears(1);
|
||||||
|
if (RecurrenceEndDate.Value > maxEnd)
|
||||||
|
{
|
||||||
|
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||||
|
"Recurrence end date cannot be more than 1 year from the transaction date.",
|
||||||
|
[nameof(RecurrenceEndDate)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RecurrenceEndDate.Value <= Date)
|
||||||
|
{
|
||||||
|
yield return new System.ComponentModel.DataAnnotations.ValidationResult(
|
||||||
|
"Recurrence end date must be after the transaction date.",
|
||||||
|
[nameof(RecurrenceEndDate)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
@page "/weather"
|
|
||||||
@attribute [StreamRendering]
|
|
||||||
|
|
||||||
<PageTitle>Weather</PageTitle>
|
|
||||||
|
|
||||||
<h1>Weather</h1>
|
|
||||||
|
|
||||||
<p>This component demonstrates showing data.</p>
|
|
||||||
|
|
||||||
@if (forecasts == null)
|
|
||||||
{
|
|
||||||
<p><em>Loading...</em></p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th aria-label="Temperature in Celsius">Temp. (C)</th>
|
|
||||||
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
|
|
||||||
<th>Summary</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var forecast in forecasts)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@forecast.Date.ToShortDateString()</td>
|
|
||||||
<td>@forecast.TemperatureC</td>
|
|
||||||
<td>@forecast.TemperatureF</td>
|
|
||||||
<td>@forecast.Summary</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private WeatherForecast[]? forecasts;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
// Simulate asynchronous loading to demonstrate streaming rendering
|
|
||||||
await Task.Delay(500);
|
|
||||||
|
|
||||||
var startDate = DateOnly.FromDateTime(DateTime.Now);
|
|
||||||
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
|
|
||||||
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
|
|
||||||
{
|
|
||||||
Date = startDate.AddDays(index),
|
|
||||||
TemperatureC = Random.Shared.Next(-20, 55),
|
|
||||||
Summary = summaries[Random.Shared.Next(summaries.Length)]
|
|
||||||
}).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
private class WeatherForecast
|
|
||||||
{
|
|
||||||
public DateOnly Date { get; set; }
|
|
||||||
public int TemperatureC { get; set; }
|
|
||||||
public string? Summary { get; set; }
|
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
@rendermode InteractiveServer
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<div class="toast-stack">
|
<div class="toast-stack">
|
||||||
@foreach (var toast in toasts)
|
@foreach (var toast in _toasts)
|
||||||
{
|
{
|
||||||
<div class="toast-item @(toast.Dismissing ? "toast-dismiss" : "")">
|
<div class="toast-item @(toast.Dismissing ? "toast-dismiss" : "")">
|
||||||
<span class="toast-icon">⚠</span>
|
<span class="toast-icon">⚠</span>
|
||||||
@@ -11,41 +11,3 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
|
||||||
private readonly List<ToastItem> toasts = [];
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
ToastService.OnShow += ShowToast;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void ShowToast(string message)
|
|
||||||
{
|
|
||||||
var toast = new ToastItem { Message = message };
|
|
||||||
toasts.Add(toast);
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
_ = DismissAfterDelay(toast);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task DismissAfterDelay(ToastItem toast)
|
|
||||||
{
|
|
||||||
await Task.Delay(3500);
|
|
||||||
toast.Dismissing = true;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
await Task.Delay(300);
|
|
||||||
toasts.Remove(toast);
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
ToastService.OnShow -= ShowToast;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class ToastItem
|
|
||||||
{
|
|
||||||
public string Message { get; set; } = "";
|
|
||||||
public bool Dismissing { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace BudgetApp.Components;
|
||||||
|
|
||||||
|
public partial class ToastContainer
|
||||||
|
{
|
||||||
|
private readonly List<ToastItem> _toasts = [];
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
ToastService.OnShow += ShowToast;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ShowToast(string message)
|
||||||
|
{
|
||||||
|
var toast = new ToastItem { Message = message };
|
||||||
|
_toasts.Add(toast);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
_ = DismissAfterDelay(toast);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DismissAfterDelay(ToastItem toast)
|
||||||
|
{
|
||||||
|
await Task.Delay(3500);
|
||||||
|
toast.Dismissing = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await Task.Delay(300);
|
||||||
|
_toasts.Remove(toast);
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ToastService.OnShow -= ShowToast;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ToastItem
|
||||||
|
{
|
||||||
|
public string Message { get; set; } = "";
|
||||||
|
public bool Dismissing { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,47 +1,3 @@
|
|||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<DataAnnotationsValidator />
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
@code {
|
|
||||||
[CascadingParameter]
|
|
||||||
private EditContext? CurrentEditContext { get; set; }
|
|
||||||
|
|
||||||
[Inject]
|
|
||||||
private ToastService ToastService { get; set; } = default!;
|
|
||||||
|
|
||||||
private bool validationRequested;
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
if (CurrentEditContext is not null)
|
|
||||||
{
|
|
||||||
CurrentEditContext.OnValidationRequested += OnValidationRequested;
|
|
||||||
CurrentEditContext.OnValidationStateChanged += OnValidationStateChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
|
|
||||||
{
|
|
||||||
validationRequested = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (!validationRequested || CurrentEditContext is null) return;
|
|
||||||
validationRequested = false;
|
|
||||||
|
|
||||||
foreach (var message in CurrentEditContext.GetValidationMessages())
|
|
||||||
{
|
|
||||||
ToastService.Show(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (CurrentEditContext is not null)
|
|
||||||
{
|
|
||||||
CurrentEditContext.OnValidationRequested -= OnValidationRequested;
|
|
||||||
CurrentEditContext.OnValidationStateChanged -= OnValidationStateChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using BudgetApp.Services;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Forms;
|
||||||
|
|
||||||
|
namespace BudgetApp.Components;
|
||||||
|
|
||||||
|
public partial class ToastValidator
|
||||||
|
{
|
||||||
|
[CascadingParameter] private EditContext? CurrentEditContext { get; set; }
|
||||||
|
|
||||||
|
[Inject] private ToastService ToastService { get; set; } = default!;
|
||||||
|
|
||||||
|
private bool _validationRequested;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (CurrentEditContext is not null)
|
||||||
|
{
|
||||||
|
CurrentEditContext.OnValidationRequested += OnValidationRequested;
|
||||||
|
CurrentEditContext.OnValidationStateChanged += OnValidationStateChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidationRequested(object? sender, ValidationRequestedEventArgs e)
|
||||||
|
{
|
||||||
|
_validationRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnValidationStateChanged(object? sender, ValidationStateChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_validationRequested || CurrentEditContext is null) return;
|
||||||
|
_validationRequested = false;
|
||||||
|
|
||||||
|
foreach (var message in CurrentEditContext.GetValidationMessages())
|
||||||
|
{
|
||||||
|
ToastService.Show(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (CurrentEditContext is not null)
|
||||||
|
{
|
||||||
|
CurrentEditContext.OnValidationRequested -= OnValidationRequested;
|
||||||
|
CurrentEditContext.OnValidationStateChanged -= OnValidationStateChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,6 @@
|
|||||||
@using BudgetApp.Components
|
@using BudgetApp.Components
|
||||||
@using BudgetApp.Components.Layout
|
@using BudgetApp.Components.Layout
|
||||||
@using BudgetApp.Data
|
@using BudgetApp.Data
|
||||||
|
@using BudgetApp.Data.Interfaces
|
||||||
|
@using BudgetApp.Data.Repositories
|
||||||
@using BudgetApp.Services
|
@using BudgetApp.Services
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class InitialCreate : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetRoles",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
|
||||||
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
|
||||||
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUsers",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
|
||||||
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
|
||||||
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
|
||||||
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
|
||||||
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
|
|
||||||
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
|
|
||||||
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
|
||||||
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
|
||||||
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
|
||||||
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetRoleClaims",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
RoleId = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
|
||||||
column: x => x.RoleId,
|
|
||||||
principalTable: "AspNetRoles",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Accounts",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
|
||||||
CurrentBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
|
||||||
Color = table.Column<string>(type: "TEXT", maxLength: 7, nullable: false),
|
|
||||||
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Accounts", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Accounts_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUserClaims",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUserLogins",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
|
|
||||||
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUserRoles",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
RoleId = table.Column<string>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
|
||||||
column: x => x.RoleId,
|
|
||||||
principalTable: "AspNetRoles",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AspNetUserTokens",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
|
||||||
Value = table.Column<string>(type: "TEXT", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Categories",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
|
|
||||||
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Categories", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Categories_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Transactions",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
Description = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
|
||||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
|
||||||
Date = table.Column<DateTime>(type: "TEXT", nullable: false),
|
|
||||||
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
CategoryId = table.Column<int>(type: "INTEGER", nullable: true)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Transactions", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Transactions_Accounts_AccountId",
|
|
||||||
column: x => x.AccountId,
|
|
||||||
principalTable: "Accounts",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Transactions_Categories_CategoryId",
|
|
||||||
column: x => x.CategoryId,
|
|
||||||
principalTable: "Categories",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.SetNull);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Accounts_UserId",
|
|
||||||
table: "Accounts",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AspNetRoleClaims_RoleId",
|
|
||||||
table: "AspNetRoleClaims",
|
|
||||||
column: "RoleId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "RoleNameIndex",
|
|
||||||
table: "AspNetRoles",
|
|
||||||
column: "NormalizedName",
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AspNetUserClaims_UserId",
|
|
||||||
table: "AspNetUserClaims",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AspNetUserLogins_UserId",
|
|
||||||
table: "AspNetUserLogins",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AspNetUserRoles_RoleId",
|
|
||||||
table: "AspNetUserRoles",
|
|
||||||
column: "RoleId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "EmailIndex",
|
|
||||||
table: "AspNetUsers",
|
|
||||||
column: "NormalizedEmail");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "UserNameIndex",
|
|
||||||
table: "AspNetUsers",
|
|
||||||
column: "NormalizedUserName",
|
|
||||||
unique: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Categories_UserId",
|
|
||||||
table: "Categories",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Transactions_AccountId",
|
|
||||||
table: "Transactions",
|
|
||||||
column: "AccountId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Transactions_CategoryId",
|
|
||||||
table: "Transactions",
|
|
||||||
column: "CategoryId");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetRoleClaims");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUserClaims");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUserLogins");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUserRoles");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUserTokens");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Transactions");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetRoles");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Accounts");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Categories");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AspNetUsers");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddRecurringTransactions : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "ParentTransactionId",
|
|
||||||
table: "Transactions",
|
|
||||||
type: "INTEGER",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<DateTime>(
|
|
||||||
name: "RecurrenceEndDate",
|
|
||||||
table: "Transactions",
|
|
||||||
type: "TEXT",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "RecurrenceType",
|
|
||||||
table: "Transactions",
|
|
||||||
type: "INTEGER",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: 0);
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Transactions_ParentTransactionId",
|
|
||||||
table: "Transactions",
|
|
||||||
column: "ParentTransactionId");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_Transactions_Transactions_ParentTransactionId",
|
|
||||||
table: "Transactions",
|
|
||||||
column: "ParentTransactionId",
|
|
||||||
principalTable: "Transactions",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.SetNull);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_Transactions_Transactions_ParentTransactionId",
|
|
||||||
table: "Transactions");
|
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_Transactions_ParentTransactionId",
|
|
||||||
table: "Transactions");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "ParentTransactionId",
|
|
||||||
table: "Transactions");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "RecurrenceEndDate",
|
|
||||||
table: "Transactions");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "RecurrenceType",
|
|
||||||
table: "Transactions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddCurrencyCodeAndCategoryIcon : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Icon",
|
|
||||||
table: "Categories",
|
|
||||||
type: "TEXT",
|
|
||||||
maxLength: 10,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "CurrencyCode",
|
|
||||||
table: "Accounts",
|
|
||||||
type: "TEXT",
|
|
||||||
maxLength: 3,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Icon",
|
|
||||||
table: "Categories");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "CurrencyCode",
|
|
||||||
table: "Accounts");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddIsPaidToTransaction : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
|
||||||
name: "IsPaid",
|
|
||||||
table: "Transactions",
|
|
||||||
type: "INTEGER",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "IsPaid",
|
|
||||||
table: "Transactions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddSalary : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<int>(
|
|
||||||
name: "SalaryId",
|
|
||||||
table: "Transactions",
|
|
||||||
type: "INTEGER",
|
|
||||||
nullable: true);
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Salaries",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
|
||||||
.Annotation("Sqlite:Autoincrement", true),
|
|
||||||
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
|
||||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
|
||||||
RecurrenceType = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
StartDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
|
||||||
AccountId = table.Column<int>(type: "INTEGER", nullable: false),
|
|
||||||
CategoryId = table.Column<int>(type: "INTEGER", nullable: true),
|
|
||||||
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_Salaries", x => x.Id);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Salaries_Accounts_AccountId",
|
|
||||||
column: x => x.AccountId,
|
|
||||||
principalTable: "Accounts",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Salaries_AspNetUsers_UserId",
|
|
||||||
column: x => x.UserId,
|
|
||||||
principalTable: "AspNetUsers",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.Cascade);
|
|
||||||
table.ForeignKey(
|
|
||||||
name: "FK_Salaries_Categories_CategoryId",
|
|
||||||
column: x => x.CategoryId,
|
|
||||||
principalTable: "Categories",
|
|
||||||
principalColumn: "Id");
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Transactions_SalaryId",
|
|
||||||
table: "Transactions",
|
|
||||||
column: "SalaryId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Salaries_AccountId",
|
|
||||||
table: "Salaries",
|
|
||||||
column: "AccountId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Salaries_CategoryId",
|
|
||||||
table: "Salaries",
|
|
||||||
column: "CategoryId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_Salaries_UserId",
|
|
||||||
table: "Salaries",
|
|
||||||
column: "UserId");
|
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
|
||||||
name: "FK_Transactions_Salaries_SalaryId",
|
|
||||||
table: "Transactions",
|
|
||||||
column: "SalaryId",
|
|
||||||
principalTable: "Salaries",
|
|
||||||
principalColumn: "Id",
|
|
||||||
onDelete: ReferentialAction.SetNull);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropForeignKey(
|
|
||||||
name: "FK_Transactions_Salaries_SalaryId",
|
|
||||||
table: "Transactions");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Salaries");
|
|
||||||
|
|
||||||
migrationBuilder.DropIndex(
|
|
||||||
name: "IX_Transactions_SalaryId",
|
|
||||||
table: "Transactions");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "SalaryId",
|
|
||||||
table: "Transactions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace BudgetApp.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddIsDefaultToCategory : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
|
||||||
name: "IsDefault",
|
|
||||||
table: "Categories",
|
|
||||||
type: "INTEGER",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "IsDefault",
|
|
||||||
table: "Categories");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+10
-7
@@ -1,16 +1,19 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using BudgetApp.Components;
|
using BudgetApp.Components;
|
||||||
using BudgetApp.Data;
|
using BudgetApp.Data;
|
||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Data.Repositories;
|
||||||
using BudgetApp.Services;
|
using BudgetApp.Services;
|
||||||
|
using BudgetApp.Data.Interfaces;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
builder.Services.AddDbContext<ApplicationDbContext>(options =>
|
||||||
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
|
options.UseSqlite(
|
||||||
?? "Data Source=budgetapp.db"));
|
builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=budgetapp.db",
|
||||||
|
o => o.MigrationsAssembly("BudgetApp.Data")));
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
||||||
@@ -69,11 +72,11 @@ if (!string.IsNullOrEmpty(facebookSection["ClientId"]) && !string.IsNullOrEmpty(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Application services
|
// Application services
|
||||||
builder.Services.AddScoped<AccountService>();
|
builder.Services.AddScoped<IAccountRepository, AccountRepository>();
|
||||||
builder.Services.AddScoped<TransactionService>();
|
builder.Services.AddScoped<ITransactionRepository, TransactionRepository>();
|
||||||
builder.Services.AddScoped<CategoryService>();
|
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
|
||||||
|
builder.Services.AddScoped<ISalaryRepository, SalaryRepository>();
|
||||||
builder.Services.AddScoped<DashboardService>();
|
builder.Services.AddScoped<DashboardService>();
|
||||||
builder.Services.AddScoped<SalaryService>();
|
|
||||||
builder.Services.AddScoped<ToastService>();
|
builder.Services.AddScoped<ToastService>();
|
||||||
builder.Services.AddScoped<AppState>();
|
builder.Services.AddScoped<AppState>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
using BudgetApp.Data;
|
using BudgetApp.Data.Entities;
|
||||||
|
using BudgetApp.Data.Interfaces;
|
||||||
|
using BudgetApp.Services.Models;
|
||||||
|
|
||||||
namespace BudgetApp.Services;
|
namespace BudgetApp.Services;
|
||||||
|
|
||||||
public class DashboardService(TransactionService transactionService, AccountService accountService, CategoryService categoryService)
|
public class DashboardService(ITransactionRepository transactionRepository, IAccountRepository accountRepository, ICategoryRepository categoryRepository)
|
||||||
{
|
{
|
||||||
private readonly TransactionService _transactionService = transactionService;
|
|
||||||
private readonly AccountService _accountService = accountService;
|
|
||||||
private readonly CategoryService _categoryService = categoryService;
|
|
||||||
|
|
||||||
public async Task<DashboardData> GetDashboardDataAsync(string userId, DateTime? from = null, DateTime? to = null)
|
public async Task<DashboardData> GetDashboardDataAsync(string userId, DateTime? from = null, DateTime? to = null)
|
||||||
{
|
{
|
||||||
await _categoryService.EnsureDefaultCategoriesAsync(userId);
|
await categoryRepository.EnsureDefaultCategoriesAsync(userId);
|
||||||
var windowEnd = to ?? DateTime.Today.AddDays(30);
|
var windowEnd = to ?? DateTime.Today.AddDays(30);
|
||||||
await _transactionService.MaterializeRecurringTransactionsAsync(userId, windowEnd);
|
await transactionRepository.MaterializeRecurringTransactionsAsync(userId, windowEnd);
|
||||||
|
|
||||||
var accounts = await _accountService.GetAccountsAsync(userId);
|
var accounts = await accountRepository.GetAccountsAsync(userId);
|
||||||
var categories = await _categoryService.GetCategoriesAsync(userId);
|
var categories = await categoryRepository.GetCategoriesAsync(userId);
|
||||||
|
|
||||||
var windowStart = from ?? DateTime.Today.AddDays(-30);
|
var windowStart = from ?? DateTime.Today.AddDays(-30);
|
||||||
|
|
||||||
var fetchStart = windowStart < DateTime.Today ? windowStart : DateTime.Today;
|
var fetchStart = windowStart < DateTime.Today ? windowStart : DateTime.Today;
|
||||||
var fetchEnd = windowEnd > DateTime.Today ? windowEnd : DateTime.Today;
|
var fetchEnd = windowEnd > DateTime.Today ? windowEnd : DateTime.Today;
|
||||||
var allTransactions = await _transactionService.GetTransactionsInRangeAsync(userId, fetchStart, fetchEnd);
|
var allTransactions = await transactionRepository.GetTransactionsInRangeAsync(userId, fetchStart, fetchEnd);
|
||||||
|
|
||||||
var recent = allTransactions
|
var recent = allTransactions
|
||||||
.Where(t => (t.Date.Date >= windowStart.Date && t.Date.Date <= DateTime.Today)
|
.Where(t => (t.Date.Date >= windowStart.Date && t.Date.Date <= DateTime.Today)
|
||||||
@@ -99,10 +97,7 @@ public class DashboardService(TransactionService transactionService, AccountServ
|
|||||||
for (var d = startDate; d <= endDate; d = d.AddDays(1))
|
for (var d = startDate; d <= endDate; d = d.AddDays(1))
|
||||||
{
|
{
|
||||||
var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date && !(t.IsPaid && t.Date.Date > today));
|
var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date && !(t.IsPaid && t.Date.Date > today));
|
||||||
foreach (var t in dayTxns)
|
runningBalance += dayTxns.Sum(t => t.Type == TransactionType.Income ? t.Amount : -t.Amount);
|
||||||
{
|
|
||||||
runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
|
|
||||||
}
|
|
||||||
dataPoints.Add(runningBalance);
|
dataPoints.Add(runningBalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,28 +118,3 @@ public class DashboardService(TransactionService transactionService, AccountServ
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DashboardData
|
|
||||||
{
|
|
||||||
public List<Account> Accounts { get; set; } = [];
|
|
||||||
public List<Category> Categories { get; set; } = [];
|
|
||||||
public List<Transaction> RecentTransactions { get; set; } = [];
|
|
||||||
public List<Transaction> UpcomingTransactions { get; set; } = [];
|
|
||||||
public ChartData ChartData { get; set; } = new();
|
|
||||||
public string CurrencySymbol { get; set; } = "$";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ChartData
|
|
||||||
{
|
|
||||||
public List<string> Labels { get; set; } = [];
|
|
||||||
public List<ChartDataset> Datasets { get; set; } = [];
|
|
||||||
public List<List<string>> DayTransactionLabels { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ChartDataset
|
|
||||||
{
|
|
||||||
public string Label { get; set; } = string.Empty;
|
|
||||||
public string Color { get; set; } = "#1b6ec2";
|
|
||||||
public List<decimal> Data { get; set; } = [];
|
|
||||||
public string CurrencySymbol { get; set; } = "$";
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace BudgetApp.Services.Models;
|
||||||
|
|
||||||
|
public class ChartData
|
||||||
|
{
|
||||||
|
public List<string> Labels { get; set; } = [];
|
||||||
|
public List<ChartDataset> Datasets { get; set; } = [];
|
||||||
|
public List<List<string>> DayTransactionLabels { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace BudgetApp.Services.Models;
|
||||||
|
|
||||||
|
public class ChartDataset
|
||||||
|
{
|
||||||
|
public string Label { get; set; } = string.Empty;
|
||||||
|
public string Color { get; set; } = "#1b6ec2";
|
||||||
|
public List<decimal> Data { get; set; } = [];
|
||||||
|
public string CurrencySymbol { get; set; } = "$";
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
using BudgetApp.Data.Entities;
|
||||||
|
|
||||||
|
namespace BudgetApp.Services.Models;
|
||||||
|
|
||||||
|
public class DashboardData
|
||||||
|
{
|
||||||
|
public List<Account> Accounts { get; set; } = [];
|
||||||
|
public List<Category> Categories { get; set; } = [];
|
||||||
|
public List<Transaction> RecentTransactions { get; set; } = [];
|
||||||
|
public List<Transaction> UpcomingTransactions { get; set; } = [];
|
||||||
|
public ChartData ChartData { get; set; } = new();
|
||||||
|
public string CurrencySymbol { get; set; } = "$";
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user