Refactor. Add data layer

This commit is contained in:
rossmill76 committed 2026-03-06 11:05:01 +00:00
1 parent 3b7e4e6ec5
commit b6b794f0b4
82 files changed
+2930 -2811

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>();
+22
View File
@@ -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
{ {
+10
View File
@@ -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);
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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")]
+102
View File
@@ -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");
}
}
@@ -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
@@ -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")]
+321
View File
@@ -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");
}
}
@@ -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();
} }
} }
} }
@@ -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();
} }
} }
} }
@@ -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)
@@ -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
}; };
} }
+1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
using BudgetApp.Data; using BudgetApp.Data.Entities;
using Xunit; using Xunit;
namespace BudgetApp.Tests.Models; namespace BudgetApp.Tests.Models;
+13 -13
View File
@@ -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
View File
@@ -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>
+5 -5
View File
@@ -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>
+4 -44
View File
@@ -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>
@@ -88,44 +88,4 @@
} }
</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");
}
}
+3 -33
View File
@@ -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
@@ -24,34 +24,4 @@
<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>"));
}
}
+38
View File
@@ -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>"));
}
}
+18 -144
View File
@@ -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";
}
}
+12 -121
View File
@@ -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; } = "??";
}
}
+59 -412
View File
@@ -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)]);
}
}
}
}
} }
+371
View File
@@ -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)]);
}
}
}
}
}
+7 -73
View File
@@ -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>
@@ -59,71 +60,4 @@
<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;
}
}
+74
View File
@@ -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
View File
@@ -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
+4 -43
View File
@@ -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">
@@ -40,44 +41,4 @@
<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;
}
}
+28 -234
View File
@@ -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>
@@ -165,210 +165,4 @@ 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;
}
}
+218
View File
@@ -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;
}
}
+19 -121
View File
@@ -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;
}
}
} }
+113
View File
@@ -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;
}
}
}
+21 -177
View File
@@ -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;
}
}
+67 -399
View File
@@ -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)]);
}
}
}
}
}
-64
View File
@@ -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);
}
}
+2 -40
View File
@@ -3,49 +3,11 @@
@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>
<span class="toast-message">@toast.Message</span> <span class="toast-message">@toast.Message</span>
</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 -45
View File
@@ -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;
}
}
}
+2
View File
@@ -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
View File
@@ -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>();
+11 -41
View File
@@ -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);
} }
@@ -122,29 +117,4 @@ public class DashboardService(TransactionService transactionService, AccountServ
DayTransactionLabels = dayTransactionLabels DayTransactionLabels = dayTransactionLabels
}; };
} }
} }
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; } = "$";
}
+8
View File
@@ -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.