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
+2921 -2802

No files matched your search

@@ -1,15 +1,12 @@
using BudgetApp.Data.Entities;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
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<Transaction> Transactions => Set<Transaction>();
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;
namespace BudgetApp.Data;
namespace BudgetApp.Data.Entities;
public class Account
{
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Identity;
namespace BudgetApp.Data;
namespace BudgetApp.Data.Entities;
public class ApplicationUser : IdentityUser
{
@@ -1,6 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace BudgetApp.Data;
namespace BudgetApp.Data.Entities;
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;
namespace BudgetApp.Data;
namespace BudgetApp.Data.Entities;
public class Salary
{
@@ -1,21 +1,6 @@
using System.ComponentModel.DataAnnotations;
namespace BudgetApp.Data;
public enum TransactionType
{
Income,
Expense
}
public enum RecurrenceType
{
None,
Daily,
Weekly,
Monthly,
Yearly
}
namespace BudgetApp.Data.Entities;
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 BudgetApp.Data;
using Microsoft.EntityFrameworkCore;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BudgetApp.Migrations
namespace BudgetApp.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[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 BudgetApp.Data;
using Microsoft.EntityFrameworkCore;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BudgetApp.Migrations
namespace BudgetApp.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[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 BudgetApp.Data;
using Microsoft.EntityFrameworkCore;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BudgetApp.Migrations
namespace BudgetApp.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[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 BudgetApp.Data;
using Microsoft.EntityFrameworkCore;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BudgetApp.Migrations
namespace BudgetApp.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[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 BudgetApp.Data;
using Microsoft.EntityFrameworkCore;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BudgetApp.Migrations
namespace BudgetApp.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[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 BudgetApp.Data;
using Microsoft.EntityFrameworkCore;
@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BudgetApp.Migrations
namespace BudgetApp.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
@@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using BudgetApp.Data;
using Microsoft.EntityFrameworkCore;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BudgetApp.Migrations
namespace BudgetApp.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[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 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)
{
return await _db.Accounts
return await db.Accounts
.Where(a => a.UserId == userId)
.OrderBy(a => a.Name)
.ToListAsync();
@@ -17,54 +16,54 @@ public class AccountService(ApplicationDbContext db)
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);
}
public async Task CreateAccountAsync(Account account)
{
_db.Accounts.Add(account);
await _db.SaveChangesAsync();
db.Accounts.Add(account);
await db.SaveChangesAsync();
}
public async Task UpdateAccountAsync(Account account)
{
_db.Accounts.Update(account);
await _db.SaveChangesAsync();
db.Accounts.Update(account);
await db.SaveChangesAsync();
}
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);
if (account is not null)
{
account.CurrentBalance = newBalance;
await _db.SaveChangesAsync();
await db.SaveChangesAsync();
}
}
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);
if (account is not null)
{
account.Name = name;
account.Color = color;
account.CurrencyCode = currencyCode;
await _db.SaveChangesAsync();
await db.SaveChangesAsync();
}
}
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);
if (account is not null)
{
_db.Accounts.Remove(account);
await _db.SaveChangesAsync();
db.Accounts.Remove(account);
await db.SaveChangesAsync();
}
}
}
@@ -1,12 +1,11 @@
using BudgetApp.Data.Entities;
using BudgetApp.Data.Interfaces;
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 =
[
("Groceries", "🛒"),
@@ -31,7 +30,7 @@ public class CategoryService(ApplicationDbContext db)
public async Task<List<Category>> GetCategoriesAsync(string userId)
{
return await _db.Categories
return await db.Categories
.Where(c => c.UserId == userId)
.OrderBy(c => c.Name)
.ToListAsync();
@@ -39,7 +38,7 @@ public class CategoryService(ApplicationDbContext db)
public async Task EnsureDefaultCategoriesAsync(string userId)
{
var existing = await _db.Categories
var existing = await db.Categories
.Where(c => c.UserId == userId)
.ToListAsync();
@@ -53,8 +52,8 @@ public class CategoryService(ApplicationDbContext db)
UserId = userId
});
_db.Categories.AddRange(defaults);
await _db.SaveChangesAsync();
db.Categories.AddRange(defaults);
await db.SaveChangesAsync();
return;
}
@@ -78,38 +77,38 @@ public class CategoryService(ApplicationDbContext db)
if (updated)
{
await _db.SaveChangesAsync();
await db.SaveChangesAsync();
}
}
public async Task CreateCategoryAsync(Category category)
{
_db.Categories.Add(category);
await _db.SaveChangesAsync();
db.Categories.Add(category);
await db.SaveChangesAsync();
}
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);
if (category is not null)
{
if (category.IsDefault) return;
category.Name = name;
category.Icon = icon;
await _db.SaveChangesAsync();
await db.SaveChangesAsync();
}
}
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);
if (category is not null)
{
if (category.IsDefault) return;
_db.Categories.Remove(category);
await _db.SaveChangesAsync();
db.Categories.Remove(category);
await db.SaveChangesAsync();
}
}
}
@@ -1,15 +1,14 @@
using BudgetApp.Data.Entities;
using BudgetApp.Data.Interfaces;
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)
{
return await _db.Salaries
return await db.Salaries
.Include(s => s.Account)
.Include(s => s.Category)
.Where(s => s.UserId == userId)
@@ -19,35 +18,35 @@ public class SalaryService(ApplicationDbContext db)
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")
.Select(c => (int?)c.Id)
.FirstOrDefaultAsync();
salary.CategoryId = salaryCategoryId;
_db.Salaries.Add(salary);
await _db.SaveChangesAsync();
db.Salaries.Add(salary);
await db.SaveChangesAsync();
await MaterializeSalaryTransactionsAsync(salary.UserId, windowEnd);
}
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;
await using var transaction = await _db.Database.BeginTransactionAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
// 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)
.ExecuteDeleteAsync();
// Unlink past / paid transactions so they stay in history without the salary reference
await _db.Transactions
await db.Transactions
.Where(t => t.SalaryId == id)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SalaryId, (int?)null));
await _db.Salaries
await db.Salaries
.Where(s => s.Id == id && s.UserId == userId)
.ExecuteDeleteAsync();
@@ -56,7 +55,7 @@ public class SalaryService(ApplicationDbContext db)
public async Task MaterializeSalaryTransactionsAsync(string userId, DateTime windowEnd)
{
var salaries = await _db.Salaries
var salaries = await db.Salaries
.Where(s => s.UserId == userId)
.ToListAsync();
@@ -64,7 +63,7 @@ public class SalaryService(ApplicationDbContext db)
foreach (var salary in salaries)
{
var latestDate = await _db.Transactions
var latestDate = await db.Transactions
.Where(t => t.SalaryId == salary.Id)
.OrderByDescending(t => t.Date)
.Select(t => (DateTime?)t.Date)
@@ -93,14 +92,14 @@ public class SalaryService(ApplicationDbContext db)
if (newTransactions.Count > 0)
{
_db.Transactions.AddRange(newTransactions);
await _db.SaveChangesAsync();
db.Transactions.AddRange(newTransactions);
await db.SaveChangesAsync();
}
}
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)
.OrderBy(t => t.Date)
.Select(t => (DateTime?)t.Date)
@@ -1,15 +1,14 @@
using BudgetApp.Data.Entities;
using BudgetApp.Data.Interfaces;
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)
{
return await _db.Transactions
return await db.Transactions
.Include(t => t.Account)
.Include(t => t.Category)
.Include(t => t.ParentTransaction)
@@ -20,7 +19,7 @@ public class TransactionService(ApplicationDbContext db)
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.Category)
.Include(t => t.ParentTransaction)
@@ -32,7 +31,7 @@ public class TransactionService(ApplicationDbContext db)
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.Category)
.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)
{
return await _db.Transactions
return await db.Transactions
.Include(t => t.Account)
.Include(t => t.Category)
.Include(t => t.ParentTransaction)
@@ -55,7 +54,7 @@ public class TransactionService(ApplicationDbContext db)
public async Task<List<Transaction>> GetTransactionsByAccountAsync(int accountId, string userId)
{
return await _db.Transactions
return await db.Transactions
.Include(t => t.Category)
.Where(t => t.AccountId == accountId && t.Account.UserId == userId)
.OrderByDescending(t => t.Date)
@@ -74,8 +73,8 @@ public class TransactionService(ApplicationDbContext db)
}
}
_db.Transactions.Add(transaction);
await _db.SaveChangesAsync();
db.Transactions.Add(transaction);
await db.SaveChangesAsync();
// Immediately materialize child occurrences for recurring transactions
if (transaction.RecurrenceType != RecurrenceType.None)
@@ -102,15 +101,17 @@ public class TransactionService(ApplicationDbContext db)
if (newTransactions.Count > 0)
{
_db.Transactions.AddRange(newTransactions);
await _db.SaveChangesAsync();
db.Transactions.AddRange(newTransactions);
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)
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
@@ -128,11 +129,10 @@ public class TransactionService(ApplicationDbContext db)
if (isPartOfSeries)
{
// Cascade edits to all future occurrences in the series
var parentId = transaction.ParentTransactionId ?? transaction.Id;
var cutoffDate = transaction.Date.Date;
var futureSiblings = await _db.Transactions
var futureSiblings = await db.Transactions
.Where(t => (t.Id == parentId || t.ParentTransactionId == parentId)
&& t.Id != id
&& t.Date.Date >= cutoffDate)
@@ -149,92 +149,84 @@ public class TransactionService(ApplicationDbContext db)
}
else
{
// Standalone transaction — preserve existing recurrence state (None)
transaction.RecurrenceType = recurrenceType;
transaction.RecurrenceEndDate = recurrenceEndDate;
}
await _db.SaveChangesAsync();
await db.SaveChangesAsync();
}
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);
if (transaction is not null)
{
_db.Transactions.Remove(transaction);
await _db.SaveChangesAsync();
db.Transactions.Remove(transaction);
await db.SaveChangesAsync();
}
}
public async Task DeleteTransactionSeriesAsync(int id, string userId)
{
var transaction = await _db.Transactions
var transaction = await db.Transactions
.Include(t => t.Account)
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
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;
// Use the clicked transaction's date as the cutoff — delete this one and everything after it
var cutoffDate = transaction.Date.Date;
var allChildren = await _db.Transactions
var allChildren = await db.Transactions
.Where(t => t.ParentTransactionId == parentId)
.ToListAsync();
var toDelete = 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
? transaction
: await _db.Transactions
: await db.Transactions
.FirstOrDefaultAsync(t => t.Id == parentId && t.Account.UserId == userId);
if (parent is not null)
{
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)
{
child.ParentTransactionId = null;
}
_db.Transactions.Remove(parent);
db.Transactions.Remove(parent);
}
else
{
// Parent is before the cutoff — cap recurrence so materialization stops
parent.RecurrenceEndDate = cutoffDate.AddDays(-1);
}
}
await _db.SaveChangesAsync();
await db.SaveChangesAsync();
}
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)
.FirstOrDefaultAsync(t => t.Id == id && t.Account.UserId == userId);
if (transaction is null) return;
transaction.IsPaid = isPaid;
await _db.SaveChangesAsync();
await db.SaveChangesAsync();
}
public async Task MaterializeRecurringTransactionsAsync(string userId, DateTime? windowEnd = null)
{
var recurringParents = await _db.Transactions
var recurringParents = await db.Transactions
.Include(t => t.Account)
.Where(t => t.Account.UserId == userId
&& t.RecurrenceType != RecurrenceType.None
@@ -251,7 +243,7 @@ public class TransactionService(ApplicationDbContext db)
? parent.RecurrenceEndDate.Value
: horizon;
var latestChildDate = await _db.Transactions
var latestChildDate = await db.Transactions
.Where(t => t.ParentTransactionId == parent.Id)
.OrderByDescending(t => t.Date)
.Select(t => (DateTime?)t.Date)
@@ -279,17 +271,17 @@ public class TransactionService(ApplicationDbContext db)
if (newTransactions.Count > 0)
{
_db.Transactions.AddRange(newTransactions);
await _db.SaveChangesAsync();
db.Transactions.AddRange(newTransactions);
await db.SaveChangesAsync();
}
}
internal static DateTime GetNextOccurrence(DateTime current, RecurrenceType recurrence) => recurrence switch
{
RecurrenceType.Daily => current.AddDays(1),
RecurrenceType.Weekly => current.AddDays(7),
RecurrenceType.Daily => current.AddDays(1),
RecurrenceType.Weekly => current.AddDays(7),
RecurrenceType.Monthly => current.AddMonths(1),
RecurrenceType.Yearly => current.AddYears(1),
_ => DateTime.MaxValue
RecurrenceType.Yearly => current.AddYears(1),
_ => DateTime.MaxValue
};
}
+1
View File
@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\BudgetApp\BudgetApp.csproj" />
<ProjectReference Include="..\BudgetApp.Data\BudgetApp.Data.csproj" />
</ItemGroup>
</Project>
@@ -1,6 +1,7 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using BudgetApp.Data;
using BudgetApp.Data.Entities;
namespace BudgetApp.Tests.Helpers;
+1 -1
View File
@@ -1,4 +1,4 @@
using BudgetApp.Data;
using BudgetApp.Data.Entities;
using Xunit;
namespace BudgetApp.Tests.Models;
+13 -13
View File
@@ -1,5 +1,5 @@
using BudgetApp.Data;
using BudgetApp.Services;
using BudgetApp.Data.Entities;
using BudgetApp.Data.Repositories;
using BudgetApp.Tests.Helpers;
using Xunit;
@@ -20,7 +20,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
var result = await service.GetAccountsAsync(TestDbContextFactory.TestUserId);
@@ -41,7 +41,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
var result = await service.GetAccountsAsync(TestDbContextFactory.TestUserId);
@@ -63,7 +63,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
var result = await service.GetAccountAsync(accountId, TestDbContextFactory.TestUserId);
@@ -85,7 +85,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
var result = await service.GetAccountAsync(accountId, TestDbContextFactory.OtherUserId);
@@ -97,7 +97,7 @@ public class AccountServiceTests
{
using var factory = new TestDbContextFactory();
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" };
await service.CreateAccountAsync(account);
@@ -120,7 +120,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
await service.UpdateBalanceAsync(accountId, TestDbContextFactory.TestUserId, 250m);
using var verifyCtx = factory.CreateContext();
@@ -142,7 +142,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
await service.UpdateBalanceAsync(accountId, TestDbContextFactory.OtherUserId, 999m);
using var verifyCtx = factory.CreateContext();
@@ -163,7 +163,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
await service.UpdateAccountDetailsAsync(accountId, TestDbContextFactory.TestUserId, "New", "#fff", "USD");
using var verifyCtx = factory.CreateContext();
@@ -187,7 +187,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
await service.UpdateAccountDetailsAsync(accountId, TestDbContextFactory.OtherUserId, "Hacked", "#fff", "USD");
using var verifyCtx = factory.CreateContext();
@@ -208,7 +208,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
await service.DeleteAccountAsync(accountId, TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -229,7 +229,7 @@ public class AccountServiceTests
}
using var context = factory.CreateContext();
var service = new AccountService(context);
var service = new AccountRepository(context);
await service.DeleteAccountAsync(accountId, TestDbContextFactory.OtherUserId);
using var verifyCtx = factory.CreateContext();
@@ -1,5 +1,5 @@
using BudgetApp.Data;
using BudgetApp.Services;
using BudgetApp.Data.Entities;
using BudgetApp.Data.Repositories;
using BudgetApp.Tests.Helpers;
using Microsoft.EntityFrameworkCore;
using Xunit;
@@ -21,7 +21,7 @@ public class CategoryServiceTests
}
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
var result = await service.GetCategoriesAsync(TestDbContextFactory.TestUserId);
@@ -42,7 +42,7 @@ public class CategoryServiceTests
}
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
var result = await service.GetCategoriesAsync(TestDbContextFactory.TestUserId);
@@ -55,7 +55,7 @@ public class CategoryServiceTests
{
using var factory = new TestDbContextFactory();
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
@@ -77,13 +77,13 @@ public class CategoryServiceTests
using (var ctx = factory.CreateContext())
{
var service = new CategoryService(ctx);
var service = new CategoryRepository(ctx);
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
}
using (var ctx = factory.CreateContext())
{
var service = new CategoryService(ctx);
var service = new CategoryRepository(ctx);
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
}
@@ -111,7 +111,7 @@ public class CategoryServiceTests
}
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -137,7 +137,7 @@ public class CategoryServiceTests
}
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -154,13 +154,13 @@ public class CategoryServiceTests
using (var ctx = factory.CreateContext())
{
var service = new CategoryService(ctx);
var service = new CategoryRepository(ctx);
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.TestUserId);
}
using (var ctx = factory.CreateContext())
{
var service = new CategoryService(ctx);
var service = new CategoryRepository(ctx);
await service.EnsureDefaultCategoriesAsync(TestDbContextFactory.OtherUserId);
}
@@ -174,7 +174,7 @@ public class CategoryServiceTests
{
using var factory = new TestDbContextFactory();
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
await service.CreateCategoryAsync(new Category
{
@@ -201,7 +201,7 @@ public class CategoryServiceTests
}
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
await service.UpdateCategoryAsync(categoryId, TestDbContextFactory.TestUserId, "New", "🎯");
using var verifyCtx = factory.CreateContext();
@@ -224,7 +224,7 @@ public class CategoryServiceTests
}
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
await service.UpdateCategoryAsync(categoryId, TestDbContextFactory.OtherUserId, "Hacked", "💀");
using var verifyCtx = factory.CreateContext();
@@ -245,7 +245,7 @@ public class CategoryServiceTests
}
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
await service.DeleteCategoryAsync(categoryId, TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -266,7 +266,7 @@ public class CategoryServiceTests
}
using var context = factory.CreateContext();
var service = new CategoryService(context);
var service = new CategoryRepository(context);
await service.DeleteCategoryAsync(categoryId, TestDbContextFactory.OtherUserId);
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.Tests.Helpers;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace BudgetApp.Tests.Services;
@@ -181,9 +181,9 @@ public class DashboardServiceTests
}
using var context = factory.CreateContext();
var transactionService = new TransactionService(context);
var accountService = new AccountService(context);
var categoryService = new CategoryService(context);
var transactionService = new TransactionRepository(context);
var accountService = new AccountRepository(context);
var categoryService = new CategoryRepository(context);
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId);
@@ -214,9 +214,9 @@ public class DashboardServiceTests
}
using var context = factory.CreateContext();
var transactionService = new TransactionService(context);
var accountService = new AccountService(context);
var categoryService = new CategoryService(context);
var transactionService = new TransactionRepository(context);
var accountService = new AccountRepository(context);
var categoryService = new CategoryRepository(context);
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
var result = await dashboardService.GetDashboardDataAsync(TestDbContextFactory.TestUserId);
@@ -243,9 +243,9 @@ public class DashboardServiceTests
}
using var context = factory.CreateContext();
var transactionService = new TransactionService(context);
var accountService = new AccountService(context);
var categoryService = new CategoryService(context);
var transactionService = new TransactionRepository(context);
var accountService = new AccountRepository(context);
var categoryService = new CategoryRepository(context);
var dashboardService = new DashboardService(transactionService, accountService, categoryService);
var from = DateTime.Today.AddDays(-10);
@@ -1,5 +1,6 @@
using BudgetApp.Data;
using BudgetApp.Services;
using BudgetApp.Data.Entities;
using BudgetApp.Data.Repositories;
using BudgetApp.Tests.Helpers;
using Microsoft.EntityFrameworkCore;
using Xunit;
@@ -32,7 +33,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
Assert.Equal(2, result.Count);
@@ -55,7 +56,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
Assert.Single(result);
@@ -85,7 +86,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetTransactionsAsync(TestDbContextFactory.TestUserId);
Assert.Single(result);
@@ -113,7 +114,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetRecentTransactionsAsync(TestDbContextFactory.TestUserId, 10);
Assert.Equal(2, result.Count);
@@ -141,7 +142,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetRecentTransactionsAsync(TestDbContextFactory.TestUserId, 3);
Assert.Equal(3, result.Count);
@@ -166,7 +167,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetUpcomingTransactionsAsync(TestDbContextFactory.TestUserId, 10);
Assert.Single(result);
@@ -197,7 +198,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetTransactionsInRangeAsync(TestDbContextFactory.TestUserId, from, to);
Assert.Equal(3, result.Count);
@@ -229,7 +230,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetTransactionsByAccountAsync(accountId, TestDbContextFactory.TestUserId);
Assert.Single(result);
@@ -250,7 +251,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
var result = await service.GetTransactionsByAccountAsync(accountId, TestDbContextFactory.OtherUserId);
Assert.Empty(result);
@@ -271,7 +272,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.CreateTransactionAsync(new Transaction
{
Description = "Groceries",
@@ -296,7 +297,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.CreateTransactionAsync(new Transaction
{
Description = "One-off",
@@ -323,7 +324,7 @@ public class TransactionServiceTests
var startDate = new DateTime(2025, 1, 1);
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.CreateTransactionAsync(new Transaction
{
Description = "Recurring",
@@ -353,7 +354,7 @@ public class TransactionServiceTests
var startDate = new DateTime(2025, 1, 1);
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.CreateTransactionAsync(new Transaction
{
Description = "Recurring",
@@ -383,7 +384,7 @@ public class TransactionServiceTests
var startDate = new DateTime(2025, 1, 1);
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.CreateTransactionAsync(new Transaction
{
Description = "Weekly",
@@ -421,7 +422,7 @@ public class TransactionServiceTests
var startDate = new DateTime(2025, 1, 15);
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.CreateTransactionAsync(new Transaction
{
Description = "Monthly",
@@ -481,7 +482,7 @@ public class TransactionServiceTests
var newDate = DateTime.Today.AddDays(5);
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.UpdateTransactionAsync(
transactionId, TestDbContextFactory.TestUserId,
"Updated", 99m, newDate, TransactionType.Income,
@@ -520,7 +521,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.UpdateTransactionAsync(
transactionId, TestDbContextFactory.OtherUserId,
"Hacked", 999m, DateTime.Today, TransactionType.Income, 1, null);
@@ -548,7 +549,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.DeleteTransactionAsync(transactionId, TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -570,7 +571,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.DeleteTransactionAsync(transactionId, TestDbContextFactory.OtherUserId);
using var verifyCtx = factory.CreateContext();
@@ -623,7 +624,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.DeleteTransactionSeriesAsync(childId, TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -672,7 +673,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.DeleteTransactionSeriesAsync(childId, TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -709,7 +710,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.DeleteTransactionSeriesAsync(parentId, TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -731,7 +732,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.DeleteTransactionSeriesAsync(transactionId, TestDbContextFactory.OtherUserId);
using var verifyCtx = factory.CreateContext();
@@ -772,7 +773,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId, startDate.AddDays(100));
using var verifyCtx = factory.CreateContext();
@@ -808,7 +809,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId, startDate.AddDays(365));
using var verifyCtx = factory.CreateContext();
@@ -839,7 +840,7 @@ public class TransactionServiceTests
}
using var context = factory.CreateContext();
var service = new TransactionService(context);
var service = new TransactionRepository(context);
await service.MaterializeRecurringTransactionsAsync(TestDbContextFactory.TestUserId);
using var verifyCtx = factory.CreateContext();
@@ -860,7 +861,7 @@ public class TransactionServiceTests
var current = new DateTime(2025, 6, 15);
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);
}
@@ -868,7 +869,7 @@ public class TransactionServiceTests
[Fact]
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);
}
+1
View File
@@ -1,4 +1,5 @@
<Solution>
<Project Path="BudgetApp.Data/BudgetApp.Data.csproj" />
<Project Path="BudgetApp.Tests/BudgetApp.Tests.csproj" />
<Project Path="BudgetApp/BudgetApp.csproj" />
</Solution>
+5 -5
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -12,15 +12,15 @@
<InternalsVisibleTo Include="BudgetApp.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BudgetApp.Data\BudgetApp.Data.csproj" />
</ItemGroup>
<ItemGroup>
<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.MicrosoftAccount" 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" />
</ItemGroup>
+3 -43
View File
@@ -1,4 +1,4 @@
@inject AccountService AccountService
@inject IAccountRepository AccountService
@inject AuthenticationStateProvider AuthStateProvider
@inject AppState AppState
@inject NavigationManager Navigation
@@ -70,11 +70,11 @@
</div>
</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="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-color" style="background-color: @account.Color;"></div>
@@ -89,43 +89,3 @@
</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");
}
}
+2 -32
View File
@@ -10,10 +10,10 @@
A personal finance dashboard built with Blazor Server.
</p>
@if (links.Count > 0)
@if (_links.Count > 0)
{
<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">
@((MarkupString)link.Icon) @link.Label
@@ -25,33 +25,3 @@
<p class="text-center">
<a href="/login">Sign in</a> or <a href="/register">Register</a>
</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"
@using BudgetApp.Data.Entities
@attribute [Authorize]
@inject AccountService AccountService
@inject IAccountRepository AccountService
@inject AuthenticationStateProvider AuthStateProvider
@inject AppState AppState
@rendermode InteractiveServer
@@ -15,20 +16,20 @@
<h5 class="mb-0">Add Account</h5>
</div>
<div class="card-body">
<EditForm Model="formModel" OnValidSubmit="AddAccount" FormName="addAccount">
<EditForm Model="_formModel" OnValidSubmit="AddAccount" FormName="addAccount">
<ToastValidator />
<div class="row g-3 align-items-end">
<div class="col-md-3">
<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 class="col-md-2">
<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 class="col-md-2">
<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)
{
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
@@ -37,7 +38,7 @@
</div>
<div class="col-md-2">
<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 class="col-md-2">
<button type="submit" class="btn btn-primary w-100">Add</button>
@@ -47,31 +48,31 @@
</div>
</div>
@if (accounts is null)
@if (_accounts is null)
{
<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>
}
else
{
<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="card h-100" style="border-left: 4px solid @account.Color;">
<div class="card-body">
@if (editingAccountId == account.Id)
@if (_editingAccountId == account.Id)
{
<div class="mb-2">
<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 class="mb-2">
<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)
{
<option value="@kvp.Key">@kvp.Key (@kvp.Value)</option>
@@ -80,7 +81,7 @@ else
</div>
<div class="mb-2">
<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 class="d-flex gap-1">
<button class="btn btn-success btn-sm" @onclick="() => SaveAccountDetails(account.Id)">Save</button>
@@ -100,13 +101,13 @@ else
</div>
</div>
@if (editingBalanceAccountId == account.Id)
@if (_editingBalanceAccountId == account.Id)
{
<div class="mt-2">
<div class="input-group input-group-sm">
<span class="input-group-text">@account.GetSymbol()</span>
<input type="number" class="form-control numeric-input" step="0.01"
@bind="editingBalanceValue" />
@bind="_editingBalanceValue" />
</div>
<div class="d-flex gap-1 mt-2">
<button class="btn btn-success btn-sm flex-fill" @onclick="() => SaveBalance(account.Id)">Save</button>
@@ -131,7 +132,7 @@ else
</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-dialog">
@@ -141,7 +142,7 @@ else
<button type="button" class="btn-close" @onclick="CancelDeleteAccount"></button>
</div>
<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>
</div>
<div class="modal-footer">
@@ -154,138 +155,11 @@ else
}
@code {
private List<Account>? accounts;
private AccountForm formModel = new();
private string? userId;
// Balance editing state
private int? editingBalanceAccountId;
private decimal editingBalanceValue;
// Account details editing state
private int? editingAccountId;
private string editName = string.Empty;
private string editColor = "#1b6ec2";
private string editCurrencyCode = "GBP";
// 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"
@attribute [Authorize]
@inject CategoryService CategoryService
@inject ICategoryRepository CategoryService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
@@ -14,17 +14,17 @@
<h5 class="mb-0">Add Category</h5>
</div>
<div class="card-body">
<EditForm Model="formModel" OnValidSubmit="AddCategory" FormName="addCategory">
<EditForm Model="_formModel" OnValidSubmit="AddCategory" FormName="addCategory">
<ToastValidator />
<div class="row g-3 align-items-end">
<div class="col-md-6">
<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 class="col-md-2">
<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;"
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 class="col-md-4">
<button type="submit" class="btn btn-primary w-100">Add</button>
@@ -34,29 +34,29 @@
</div>
</div>
@if (categories is null)
@if (_categories is null)
{
<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>
}
else
{
<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="card h-100 text-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;"
@onclick="OpenEditIconPicker">@(string.IsNullOrEmpty(editIcon) ? "📁" : editIcon)</button>
@onclick="OpenEditIconPicker">@(string.IsNullOrEmpty(_editIcon) ? "📁" : _editIcon)</button>
<div class="mt-2" style="height: 31px; width: 100%;">
<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>
}
else
@@ -73,7 +73,7 @@ else
{
<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-secondary btn-sm" @onclick="CancelCategoryEdit">Cancel</button>
@@ -91,7 +91,7 @@ else
</div>
}
@if (showIconModal)
@if (_showIconModal)
{
<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">
@@ -115,118 +115,9 @@ else
}
@code {
private List<Category>? categories;
private CategoryForm formModel = new();
private string? userId;
// Edit state
private int? editingCategoryId;
private string editCategoryName = string.Empty;
private string editIcon = string.Empty;
// 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 "/"
@attribute [Authorize]
@inject DashboardService DashboardService
@inject TransactionService TransactionService
@inject SalaryService SalaryService
@inject ITransactionRepository TransactionService
@inject ISalaryRepository SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@inject IJSRuntime Js
@using BudgetApp.Data.Entities
@implements IAsyncDisposable
@rendermode InteractiveServer
@@ -13,11 +14,11 @@
<h3 class="mb-2">Dashboard</h3>
<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>
}
else if (data.Accounts.Count == 0)
else if (_data.Accounts.Count == 0)
{
<div class="alert alert-info">
<h5>Welcome!</h5>
@@ -31,16 +32,16 @@ else
<h5 class="mb-0">Balance Trend</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
<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 == "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 == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</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 == "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>
</div>
<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>
<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 class="card-body">
@@ -51,41 +52,41 @@ else
</div>
@* ── Edit / Add Modal ── *@
@if (showModal)
@if (_showModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<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>
</div>
<EditForm Model="modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
<EditForm Model="_modalForm" OnValidSubmit="SaveModal" FormName="dashboardModal">
<ToastValidator />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Description</label>
<InputText @bind-Value="modalForm.Description" class="form-control" />
<InputText @bind-Value="_modalForm.Description" class="form-control" />
</div>
<div class="mb-3">
<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 class="mb-3">
<label class="form-label">Date</label>
<InputDate @bind-Value="modalForm.Date" class="form-control" />
<InputDate @bind-Value="_modalForm.Date" class="form-control" />
</div>
<div class="mb-3">
<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.Income">Income</option>
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Account</label>
<InputSelect @bind-Value="modalForm.AccountId" class="form-select">
@foreach (var a in data.Accounts)
<InputSelect @bind-Value="_modalForm.AccountId" class="form-select">
@foreach (var a in _data.Accounts)
{
<option value="@a.Id">@a.Name</option>
}
@@ -93,19 +94,19 @@ else
</div>
<div class="mb-3">
<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>
@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>
}
</InputSelect>
</div>
@if (editingTransactionId is null)
@if (_editingTransactionId is null)
{
<div class="mb-3">
<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.Daily">Daily</option>
<option value="@RecurrenceType.Weekly">Weekly</option>
@@ -113,15 +114,15 @@ else
<option value="@RecurrenceType.Yearly">Yearly</option>
</InputSelect>
</div>
@if (modalForm.RecurrenceType != RecurrenceType.None)
@if (_modalForm.RecurrenceType != RecurrenceType.None)
{
<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>
<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>
}
}
else if (editIsPartOfSeries)
else if (_editIsPartOfSeries)
{
<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.
@@ -142,31 +143,31 @@ else
<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;">
<li class="nav-item">
<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>
<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>
</button>
</li>
<li class="nav-item">
<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>
<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>
</button>
</li>
</ul>
<div class="d-flex align-items-center gap-2">
<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>
</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">
<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" : "")"
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")"
<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;")"
title="@(_hiddenAccountIds.Contains(a.Id) ? $"Show {a.Name} transactions" : $"Hide {a.Name} transactions")"
@onclick="() => ToggleAccountFilter(a.Id)">
@a.Name
</button>
@@ -174,18 +175,18 @@ else
</div>
}
<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>
}
else
{
<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");"
@onclick="() => SelectTransaction(t.Id)">
<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>
</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">
@if (t.IsPaid)
@@ -240,25 +241,25 @@ else
@if (RecentTotalPages > 1)
{
<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>
<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 <= 1 ? true : (bool?)null)" @onclick="() => _recentPage--">← Prev</button>
<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>
</div>
}
}
}
else
{
@if (filteredUpcoming.Count == 0)
@if (FilteredUpcoming.Count == 0)
{
<p class="p-3 text-muted mb-0">No upcoming transactions.</p>
}
else
{
<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");"
@onclick="() => SelectTransaction(t.Id)">
<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;">
@(t.Type == TransactionType.Income ? "+" : "−")@t.Account.FormatAmount(t.Amount)
</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;">
Projected: @t.Account.FormatAmount(projected)
@@ -295,7 +296,7 @@ else
}
</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">
<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)
{
<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>
<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 <= 1 ? true : (bool?)null)" @onclick="() => _upcomingPage--">← Prev</button>
<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>
</div>
}
}
@@ -321,18 +322,18 @@ else
</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-dialog">
<div class="modal-content">
<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>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>@pendingDeleteTransaction.Description</strong>?</p>
@if (pendingDeleteIsRecurring)
<p>Are you sure you want to delete <strong>@_pendingDeleteTransaction.Description</strong>?</p>
@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>
}
@@ -343,7 +344,7 @@ else
</div>
<div class="modal-footer">
<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-danger" @onclick="DeleteSeries">All Future in Series</button>
@@ -360,371 +361,17 @@ else
}
@code {
private DashboardData? data;
private string? userId;
private int? selectedTransactionId;
private string activeTab = "recent";
private Dictionary<int, decimal> projectedBalances = new();
// Modal state
private bool showModal;
private int? editingTransactionId;
private bool editIsPartOfSeries;
private ModalTransactionForm modalForm = new();
// Chart redraw tracking
private int chartDataVersion;
private int lastRenderedChartVersion;
// Time window
private DateTime windowStart = DateTime.Today;
private DateTime windowEnd = DateTime.Today.AddDays(30);
private string windowPreset = "30d";
// 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
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
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)]);
}
}
}
}
}
+6 -72
View File
@@ -1,5 +1,6 @@
@page "/login"
@layout AuthLayout
@using BudgetApp.Data.Entities
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@inject SignInManager<ApplicationUser> SignInManager
@@ -17,9 +18,9 @@
<EditForm Model="Model" OnValidSubmit="HandleLogin" FormName="login">
<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">
@@ -37,17 +38,17 @@
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</EditForm>
@if (externalProviders.Count > 0)
@if (_externalProviders.Count > 0)
{
<hr class="my-4" />
<p class="text-center text-muted mb-3">Or sign in with</p>
<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">
<input type="hidden" name="provider" value="@provider.Name" />
<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">
@GetProviderIcon(provider.DisplayName ?? provider.Name)
<span>@(provider.DisplayName ?? provider.Name)</span>
@@ -60,70 +61,3 @@
<p class="text-center mt-3">
Don't have an account? <a href="/register">Register</a>
</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"
@using BudgetApp.Data.Entities
@using Microsoft.AspNetCore.Identity
@inject SignInManager<ApplicationUser> SignInManager
@inject NavigationManager Navigation
+3 -42
View File
@@ -1,5 +1,6 @@
@page "/register"
@layout AuthLayout
@using BudgetApp.Data.Entities
@using Microsoft.AspNetCore.Identity
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@@ -12,9 +13,9 @@
<EditForm Model="Model" OnValidSubmit="HandleRegister" FormName="register">
<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">
@@ -41,43 +42,3 @@
<p class="text-center mt-3">
Already have an account? <a href="/login">Sign in</a>
</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;
}
}
+27 -233
View File
@@ -1,11 +1,11 @@
@page "/reports"
@attribute [Authorize]
@inject TransactionService TransactionService
@inject AccountService AccountService
@inject CategoryService CategoryService
@inject SalaryService SalaryService
@inject ITransactionRepository TransactionService
@inject IAccountRepository AccountService
@inject ICategoryRepository CategoryService
@inject ISalaryRepository SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@inject IJSRuntime JS
@inject IJSRuntime Js
@implements IAsyncDisposable
@rendermode InteractiveServer
@@ -14,11 +14,11 @@
<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>
@if (loading)
@if (_loading)
{
<p><em>Loading...</em></p>
}
else if (accounts.Count == 0)
else if (_accounts.Count == 0)
{
<div class="alert alert-info">
<h5>No data yet</h5>
@@ -33,23 +33,23 @@ else
<h5 class="mb-0">Filters</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
<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 == "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 == "1y" ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SetPreset(365)">1y</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 == "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>
</div>
<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>
<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 class="card-body">
<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)">
<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background-color:@account.Color;"></span>
@account.Name
@@ -65,7 +65,7 @@ else
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Expenses by Category</h5></div>
<div class="card-body">
@if (categoryBreakdown.Count == 0)
@if (_categoryBreakdown.Count == 0)
{
<p class="text-muted">No expenses in this period.</p>
}
@@ -84,7 +84,7 @@ else
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Income by Category</h5></div>
<div class="card-body">
@if (incomeBreakdown.Count == 0)
@if (_incomeBreakdown.Count == 0)
{
<p class="text-muted">No income in this period.</p>
}
@@ -103,7 +103,7 @@ else
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Monthly Income vs Expenses</h5></div>
<div class="card-body">
@if (monthlyLabels.Count == 0)
@if (_monthlyLabels.Count == 0)
{
<p class="text-muted">No transactions in this period.</p>
}
@@ -120,7 +120,7 @@ else
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Category Summary</h5></div>
<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>
}
@@ -137,14 +137,14 @@ else
</tr>
</thead>
<tbody>
@foreach (var row in categorySummary)
@foreach (var row in _categorySummary)
{
<tr>
<td>@row.Icon @row.Name</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-success">@_currencySymbol@row.Income.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")">
@currencySymbol@row.Net.ToString("N2")
@_currencySymbol@row.Net.ToString("N2")
</td>
</tr>
}
@@ -152,11 +152,11 @@ else
<tfoot>
<tr class="fw-bold">
<td>Total</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>
@{ var totalNet = categorySummary.Sum(r => r.Net); }
<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>
@{ var totalNet = _categorySummary.Sum(r => r.Net); }
<td class="text-end @(totalNet >= 0 ? "text-success" : "text-danger")">
@currencySymbol@totalNet.ToString("N2")
@_currencySymbol@totalNet.ToString("N2")
</td>
</tr>
</tfoot>
@@ -166,209 +166,3 @@ else
</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"
@using BudgetApp.Data.Entities
@attribute [Authorize]
@inject SalaryService SalaryService
@inject AccountService AccountService
@inject ISalaryRepository SalaryService
@inject IAccountRepository AccountService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
@@ -15,7 +16,7 @@
<h5 class="mb-0">Add Income Source</h5>
</div>
<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">
You need to <a href="/accounts">create an account</a> before adding a salary.
@@ -23,23 +24,23 @@
}
else
{
<EditForm Model="formModel" OnValidSubmit="AddSalary" FormName="addSalary">
<EditForm Model="_formModel" OnValidSubmit="AddSalary" FormName="addSalary">
<ToastValidator />
<div class="row g-3">
<div class="col-md-3">
<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 class="col-md-2">
<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 class="col-md-2">
<label class="form-label">Account</label>
<InputSelect @bind-Value="formModel.AccountId" class="form-select">
@if (accountsList is not null)
<InputSelect @bind-Value="_formModel.AccountId" class="form-select">
@if (_accountsList is not null)
{
@foreach (var account in accountsList)
@foreach (var account in _accountsList)
{
<option value="@account.Id">@account.Name</option>
}
@@ -48,7 +49,7 @@
</div>
<div class="col-md-2">
<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.Monthly">Monthly</option>
<option value="@RecurrenceType.Yearly">Yearly</option>
@@ -56,7 +57,7 @@
</div>
<div class="col-md-3">
<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 class="col-md-12 col-lg-auto d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">Add</button>
@@ -67,11 +68,11 @@
</div>
</div>
@if (salaries is null)
@if (_salaries is null)
{
<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>
}
@@ -94,7 +95,7 @@ else
</tr>
</thead>
<tbody>
@foreach (var s in salaries)
@foreach (var s in _salaries)
{
<tr>
<td class="fw-semibold">💰 @s.Name</td>
@@ -108,7 +109,7 @@ else
<span class="badge bg-info text-nowrap">🔁 @s.GetPaydaySummary()</span>
</td>
<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>
}
@@ -126,12 +127,12 @@ else
</table>
</div>
<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>
}
@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-dialog">
@@ -141,7 +142,7 @@ else
<button type="button" class="btn-close" @onclick="CancelDelete"></button>
</div>
<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 class="text-danger mb-0">This action cannot be undone.</p>
</div>
@@ -155,110 +156,7 @@ else
}
@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
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;
}
}
}
+20 -176
View File
@@ -1,7 +1,7 @@
@page "/settings"
@attribute [Authorize]
@using BudgetApp.Data.Entities
@using Microsoft.AspNetCore.Identity
@using Microsoft.EntityFrameworkCore
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject AuthenticationStateProvider AuthStateProvider
@@ -24,30 +24,30 @@
<h5 class="mb-0">Change Password</h5>
</div>
<div class="card-body">
@if (passwordSuccess)
@if (_passwordSuccess)
{
<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 />
<div class="row g-3">
<div class="col-md-4">
<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 class="col-md-4">
<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 class="col-md-4">
<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 class="col-12">
<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.
</p>
@if (resetSuccess)
@if (_resetSuccess)
{
<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
</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.
</div>
<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="password" class="form-control mb-2" placeholder="Enter your password" @bind="resetPassword" />
<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" />
<div class="d-flex gap-2">
<button class="btn btn-warning" @onclick="ResetData">Confirm Reset</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.
</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
</button>
}
@@ -127,166 +127,10 @@
<strong>Are you sure?</strong> Type your password to confirm.
</div>
<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-secondary" @onclick="CancelDelete">Cancel</button>
</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"
@using BudgetApp.Data.Entities
@attribute [Authorize]
@inject TransactionService TransactionService
@inject AccountService AccountService
@inject CategoryService CategoryService
@inject SalaryService SalaryService
@inject ITransactionRepository TransactionService
@inject IAccountRepository AccountService
@inject ICategoryRepository CategoryService
@inject ISalaryRepository SalaryService
@inject AuthenticationStateProvider AuthStateProvider
@rendermode InteractiveServer
@@ -17,7 +18,7 @@
<h5 class="mb-0">Add Transaction</h5>
</div>
<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">
You need to <a href="/accounts">create an account</a> before adding transactions.
@@ -25,34 +26,34 @@
}
else
{
<EditForm Model="formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
<EditForm Model="_formModel" OnValidSubmit="AddTransaction" FormName="addTransaction">
<ToastValidator />
<div class="row g-3">
<div class="col-md-4">
<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 class="col-md-2">
<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 class="col-md-2">
<label class="form-label">Date</label>
<InputDate @bind-Value="formModel.Date" class="form-control" />
<InputDate @bind-Value="_formModel.Date" class="form-control" />
</div>
<div class="col-md-2">
<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.Income">Income</option>
</InputSelect>
</div>
<div class="col-md-2">
<label class="form-label">Account</label>
<InputSelect @bind-Value="formModel.AccountId" class="form-select">
@if (accountsList is not null)
<InputSelect @bind-Value="_formModel.AccountId" class="form-select">
@if (_accountsList is not null)
{
@foreach (var account in accountsList)
@foreach (var account in _accountsList)
{
<option value="@account.Id">@account.Name</option>
}
@@ -61,11 +62,11 @@
</div>
<div class="col-md-4">
<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>
@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>
}
@@ -74,7 +75,7 @@
</div>
<div class="col-md-2">
<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.Daily">Daily</option>
<option value="@RecurrenceType.Weekly">Weekly</option>
@@ -82,11 +83,11 @@
<option value="@RecurrenceType.Yearly">Yearly</option>
</InputSelect>
</div>
@if (formModel.RecurrenceType != RecurrenceType.None)
@if (_formModel.RecurrenceType != RecurrenceType.None)
{
<div class="col-md-2">
<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 class="col-md-2 d-flex align-items-end">
@@ -98,11 +99,11 @@
</div>
</div>
@if (transactions is null)
@if (_transactions is null)
{
<p><em>Loading...</em></p>
}
else if (transactions.Count == 0)
else if (_transactions.Count == 0)
{
<div class="alert alert-info">No transactions yet.</div>
}
@@ -113,16 +114,16 @@ else
<h5 class="mb-0">Filters</h5>
<div class="d-flex flex-wrap align-items-center gap-2">
<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 == "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 == "1y" ? "btn-primary" : "btn-outline-secondary")" title="Show next year" @onclick="() => SetWindow(365)">1y</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 == "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>
</div>
<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>
<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 class="card-body py-2">
@@ -130,25 +131,25 @@ else
<div class="col-12 col-sm-auto">
<select class="form-select form-select-sm" title="Filter transactions by account" @onchange="OnAccountFilterChanged">
<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>
</div>
<div class="col-12 col-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 class="col-auto d-flex align-items-center gap-2">
<label class="form-label mb-0 text-nowrap">Show:</label>
<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>
</div>
@@ -174,13 +175,13 @@ else
</tr>
</thead>
<tbody>
@foreach (var t in pagedTransactions)
@foreach (var t in PagedTransactions)
{
<tr>
<td>
@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
{
@@ -255,55 +256,55 @@ else
</tbody>
</table>
</div>
@if (totalPages > 1)
@if (TotalPages > 1)
{
<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">
@if (selectedIds.Count > 0)
@if (_selectedIds.Count > 0)
{
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
Delete @selectedIds.Count selected
Delete @_selectedIds.Count selected
</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>
<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>
}
else
{
<div class="card-footer d-flex flex-wrap justify-content-between align-items-center gap-2">
<small class="text-muted">@filteredTransactions.Count transactions</small>
@if (selectedIds.Count > 0)
<small class="text-muted">@FilteredTransactions.Count transactions</small>
@if (_selectedIds.Count > 0)
{
<button class="btn btn-sm btn-danger" @onclick="ConfirmBulkDelete">
Delete @selectedIds.Count selected
Delete @_selectedIds.Count selected
</button>
}
</div>
}
</div>
@if (showDeleteModal)
@if (_showDeleteModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog">
<div class="modal-content">
<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>
</div>
<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>
}
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>
@if (pendingDeleteIsRecurring)
<p>Are you sure you want to delete <strong>@_pendingDeleteTransaction.Description</strong>?</p>
@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>
}
@@ -315,11 +316,11 @@ else
</div>
<div class="modal-footer">
<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>
}
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-danger" @onclick="DeleteSeries">All Future in Series</button>
@@ -334,7 +335,7 @@ else
</div>
}
@if (showEditModal)
@if (_showEditModal)
{
<div class="modal d-block" tabindex="-1" style="background: rgba(0,0,0,.5);">
<div class="modal-dialog modal-dialog-scrollable">
@@ -343,34 +344,34 @@ else
<h5 class="modal-title">Edit Transaction</h5>
<button type="button" class="btn-close" @onclick="CloseEditModal"></button>
</div>
<EditForm Model="editModel" OnValidSubmit="SaveEdit" FormName="editTransaction">
<EditForm Model="_editModel" OnValidSubmit="SaveEdit" FormName="editTransaction">
<ToastValidator />
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Description</label>
<InputText @bind-Value="editModel.Description" class="form-control" />
<InputText @bind-Value="_editModel.Description" class="form-control" />
</div>
<div class="mb-3">
<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 class="mb-3">
<label class="form-label">Date</label>
<InputDate @bind-Value="editModel.Date" class="form-control" />
<InputDate @bind-Value="_editModel.Date" class="form-control" />
</div>
<div class="mb-3">
<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.Income">Income</option>
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Account</label>
<InputSelect @bind-Value="editModel.AccountId" class="form-select">
@if (accountsList is not null)
<InputSelect @bind-Value="_editModel.AccountId" class="form-select">
@if (_accountsList is not null)
{
@foreach (var account in accountsList)
@foreach (var account in _accountsList)
{
<option value="@account.Id">@account.Name</option>
}
@@ -379,18 +380,18 @@ else
</div>
<div class="mb-3">
<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>
@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>
}
}
</InputSelect>
</div>
@if (editIsPartOfSeries)
@if (_editIsPartOfSeries)
{
<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.
@@ -409,350 +410,17 @@ else
}
@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
private DateTime windowStart = DateTime.Today;
private DateTime windowEnd = DateTime.Today.AddDays(30);
private string? windowPreset = "30d";
// 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
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
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
private bool showDeleteModal;
private Transaction? pendingDeleteTransaction;
private bool pendingDeleteIsRecurring;
private int bulkDeleteCount;
private string deleteModalTitle = "Delete Transaction";
// 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);
}
}
+1 -39
View File
@@ -3,7 +3,7 @@
@rendermode InteractiveServer
<div class="toast-stack">
@foreach (var toast in toasts)
@foreach (var toast in _toasts)
{
<div class="toast-item @(toast.Dismissing ? "toast-dismiss" : "")">
<span class="toast-icon">⚠</span>
@@ -11,41 +11,3 @@
</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; }
}
}
-44
View File
@@ -1,47 +1,3 @@
@implements IDisposable
<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.Layout
@using BudgetApp.Data
@using BudgetApp.Data.Interfaces
@using BudgetApp.Data.Repositories
@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.EntityFrameworkCore;
using BudgetApp.Components;
using BudgetApp.Data;
using BudgetApp.Data.Entities;
using BudgetApp.Data.Repositories;
using BudgetApp.Services;
using BudgetApp.Data.Interfaces;
var builder = WebApplication.CreateBuilder(args);
// Database
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")
?? "Data Source=budgetapp.db"));
options.UseSqlite(
builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=budgetapp.db",
o => o.MigrationsAssembly("BudgetApp.Data")));
// Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
@@ -69,11 +72,11 @@ if (!string.IsNullOrEmpty(facebookSection["ClientId"]) && !string.IsNullOrEmpty(
}
// Application services
builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<TransactionService>();
builder.Services.AddScoped<CategoryService>();
builder.Services.AddScoped<IAccountRepository, AccountRepository>();
builder.Services.AddScoped<ITransactionRepository, TransactionRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
builder.Services.AddScoped<ISalaryRepository, SalaryRepository>();
builder.Services.AddScoped<DashboardService>();
builder.Services.AddScoped<SalaryService>();
builder.Services.AddScoped<ToastService>();
builder.Services.AddScoped<AppState>();
+10 -40
View File
@@ -1,27 +1,25 @@
using BudgetApp.Data;
using BudgetApp.Data.Entities;
using BudgetApp.Data.Interfaces;
using BudgetApp.Services.Models;
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)
{
await _categoryService.EnsureDefaultCategoriesAsync(userId);
await categoryRepository.EnsureDefaultCategoriesAsync(userId);
var windowEnd = to ?? DateTime.Today.AddDays(30);
await _transactionService.MaterializeRecurringTransactionsAsync(userId, windowEnd);
await transactionRepository.MaterializeRecurringTransactionsAsync(userId, windowEnd);
var accounts = await _accountService.GetAccountsAsync(userId);
var categories = await _categoryService.GetCategoriesAsync(userId);
var accounts = await accountRepository.GetAccountsAsync(userId);
var categories = await categoryRepository.GetCategoriesAsync(userId);
var windowStart = from ?? DateTime.Today.AddDays(-30);
var fetchStart = windowStart < DateTime.Today ? windowStart : 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
.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))
{
var dayTxns = accountTransactions.Where(t => t.Date.Date == d.Date && !(t.IsPaid && t.Date.Date > today));
foreach (var t in dayTxns)
{
runningBalance += t.Type == TransactionType.Income ? t.Amount : -t.Amount;
}
runningBalance += dayTxns.Sum(t => t.Type == TransactionType.Income ? t.Amount : -t.Amount);
dataPoints.Add(runningBalance);
}
@@ -123,28 +118,3 @@ public class DashboardService(TransactionService transactionService, AccountServ
};
}
}
public class DashboardData
{
public List<Account> Accounts { get; set; } = [];
public List<Category> Categories { get; set; } = [];
public List<Transaction> RecentTransactions { get; set; } = [];
public List<Transaction> UpcomingTransactions { get; set; } = [];
public ChartData ChartData { get; set; } = new();
public string CurrencySymbol { get; set; } = "$";
}
public class ChartData
{
public List<string> Labels { get; set; } = [];
public List<ChartDataset> Datasets { get; set; } = [];
public List<List<string>> DayTransactionLabels { get; set; } = [];
}
public class ChartDataset
{
public string Label { get; set; } = string.Empty;
public string Color { get; set; } = "#1b6ec2";
public List<decimal> Data { get; set; } = [];
public string CurrencySymbol { get; set; } = "$";
}
+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.