2861 lines
87 KiB
Markdown
2861 lines
87 KiB
Markdown
# Finance Tracker — Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a personal finance web app that imports ČSOB bank CSV exports and visualises spending through a two-column year/month dashboard.
|
|
|
|
**Architecture:** .NET 8 REST API backed by MySQL 8, consumed by a Vue 3 + Vuetify 3 SPA. NSwag generates a TypeScript API client from the .NET OpenAPI spec at build time. Three Docker containers (api, web, db) sit behind the user's existing Nginx reverse proxy.
|
|
|
|
**Tech Stack:** .NET 8, EF Core 8 + Pomelo MySQL provider, BCrypt.Net-Next, Polly, NSwag.MSBuild, NSwag.AspNetCore · Vue 3, Vuetify 3, Pinia, Vue Router 4, ApexCharts, vue3-apexcharts · MySQL 8 · Docker / docker-compose
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
```
|
|
account-tracking/
|
|
├── AccountTracking.sln
|
|
├── docker-compose.yml
|
|
├── .env.example
|
|
├── src/
|
|
│ ├── AccountTracking.Api/
|
|
│ │ ├── AccountTracking.Api.csproj
|
|
│ │ ├── Program.cs
|
|
│ │ ├── nswag.json ← generates openapi.json at dotnet build
|
|
│ │ ├── openapi.json ← NSwag output (committed)
|
|
│ │ ├── Controllers/
|
|
│ │ │ ├── AuthController.cs
|
|
│ │ │ ├── TransactionsController.cs
|
|
│ │ │ └── DashboardController.cs
|
|
│ │ ├── Services/
|
|
│ │ │ ├── CsvImportService.cs
|
|
│ │ │ └── DashboardService.cs
|
|
│ │ ├── Models/
|
|
│ │ │ ├── Transaction.cs
|
|
│ │ │ ├── ImportLog.cs
|
|
│ │ │ └── Dtos/
|
|
│ │ │ ├── LoginRequest.cs
|
|
│ │ │ ├── LoginResponse.cs
|
|
│ │ │ ├── ImportResult.cs
|
|
│ │ │ ├── TransactionDto.cs
|
|
│ │ │ ├── TransactionListResponse.cs
|
|
│ │ │ ├── SummaryDto.cs
|
|
│ │ │ ├── CategorySpendingDto.cs
|
|
│ │ │ ├── MonthlyBalanceDto.cs
|
|
│ │ │ └── CumulativeSpendingDto.cs
|
|
│ │ ├── Data/
|
|
│ │ │ └── AppDbContext.cs
|
|
│ │ └── Dockerfile
|
|
│ ├── AccountTracking.Api.Tests/
|
|
│ │ ├── AccountTracking.Api.Tests.csproj
|
|
│ │ ├── CsvImportServiceTests.cs
|
|
│ │ └── DashboardServiceTests.cs
|
|
│ └── AccountTracking.Web/
|
|
│ ├── package.json
|
|
│ ├── vite.config.ts
|
|
│ ├── tsconfig.json
|
|
│ ├── nswag.json ← reads openapi.json, generates src/api/
|
|
│ ├── index.html
|
|
│ ├── Dockerfile
|
|
│ └── src/
|
|
│ ├── main.ts
|
|
│ ├── App.vue
|
|
│ ├── api/
|
|
│ │ ├── apiClient.ts ← NSwag-generated (do not edit)
|
|
│ │ └── index.ts ← manually written: authenticated clients
|
|
│ ├── router/
|
|
│ │ └── index.ts
|
|
│ ├── stores/
|
|
│ │ ├── auth.ts
|
|
│ │ ├── dashPeriod.ts
|
|
│ │ └── txPeriod.ts
|
|
│ ├── views/
|
|
│ │ ├── LoginView.vue
|
|
│ │ ├── DashboardView.vue
|
|
│ │ └── TransactionsView.vue
|
|
│ └── components/
|
|
│ ├── YearSummary.vue
|
|
│ ├── MonthSummary.vue
|
|
│ ├── DonutChart.vue
|
|
│ ├── MonthlyBalancesChart.vue
|
|
│ ├── CumulativeSpendingChart.vue
|
|
│ └── UploadCsvButton.vue
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Solution & Project Scaffold
|
|
|
|
**Files:**
|
|
- Create: `AccountTracking.sln`
|
|
- Create: `src/AccountTracking.Api/AccountTracking.Api.csproj`
|
|
- Create: `src/AccountTracking.Api.Tests/AccountTracking.Api.Tests.csproj`
|
|
|
|
- [ ] **Step 1: Create the .NET solution**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
dotnet new sln -n AccountTracking
|
|
dotnet new webapi -n AccountTracking.Api -o src/AccountTracking.Api --no-openapi
|
|
dotnet new xunit -n AccountTracking.Api.Tests -o src/AccountTracking.Api.Tests
|
|
dotnet sln add src/AccountTracking.Api/AccountTracking.Api.csproj
|
|
dotnet sln add src/AccountTracking.Api.Tests/AccountTracking.Api.Tests.csproj
|
|
```
|
|
|
|
- [ ] **Step 2: Add NuGet packages to API project**
|
|
|
|
```bash
|
|
cd src/AccountTracking.Api
|
|
dotnet add package Pomelo.EntityFrameworkCore.MySql --version 8.0.2
|
|
dotnet add package Microsoft.EntityFrameworkCore.Design --version 8.0.10
|
|
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 8.0.10
|
|
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.10
|
|
dotnet add package NSwag.AspNetCore --version 14.1.0
|
|
dotnet add package NSwag.MSBuild --version 14.1.0
|
|
dotnet add package BCrypt.Net-Next --version 4.0.3
|
|
dotnet add package Polly --version 8.4.1
|
|
```
|
|
|
|
- [ ] **Step 3: Add NuGet packages to test project**
|
|
|
|
```bash
|
|
cd ../AccountTracking.Api.Tests
|
|
dotnet add reference ../AccountTracking.Api/AccountTracking.Api.csproj
|
|
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 8.0.10
|
|
```
|
|
|
|
- [ ] **Step 4: Configure the API `.csproj` for NSwag MSBuild generation**
|
|
|
|
Replace the contents of `src/AccountTracking.Api/AccountTracking.Api.csproj`:
|
|
|
|
```xml
|
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
|
|
<PropertyGroup>
|
|
<TargetFramework>net8.0</TargetFramework>
|
|
<Nullable>enable</Nullable>
|
|
<ImplicitUsings>enable</ImplicitUsings>
|
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
|
</PropertyGroup>
|
|
|
|
<ItemGroup>
|
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
|
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
<PrivateAssets>all</PrivateAssets>
|
|
</PackageReference>
|
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
|
|
<PackageReference Include="NSwag.AspNetCore" Version="14.1.0" />
|
|
<PackageReference Include="NSwag.MSBuild" Version="14.1.0">
|
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
<PrivateAssets>all</PrivateAssets>
|
|
</PackageReference>
|
|
<PackageReference Include="Polly" Version="8.4.1" />
|
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
|
</ItemGroup>
|
|
|
|
<Target Name="NSwag" AfterTargets="Build" Condition="'$(GenerateOpenApiSpec)' == 'true'">
|
|
<Exec Command="$(NSwagExe_Net80) run nswag.json /variables:Configuration=$(Configuration)"
|
|
WorkingDirectory="$(ProjectDir)" />
|
|
</Target>
|
|
|
|
</Project>
|
|
```
|
|
|
|
Note: The NSwag target is gated by `GenerateOpenApiSpec=true` so it only runs when explicitly requested (not on every build, which would slow the inner loop).
|
|
|
|
- [ ] **Step 5: Delete the template boilerplate files**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking/src/AccountTracking.Api
|
|
rm -f WeatherForecast.cs Controllers/WeatherForecastController.cs
|
|
```
|
|
|
|
- [ ] **Step 6: Verify solution builds**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
dotnet build
|
|
```
|
|
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add AccountTracking.sln src/AccountTracking.Api/ src/AccountTracking.Api.Tests/
|
|
git commit -m "feat: scaffold .NET solution with API and test projects"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Data Models, EF Core Context & Migrations
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Api/Models/Transaction.cs`
|
|
- Create: `src/AccountTracking.Api/Models/ImportLog.cs`
|
|
- Create: `src/AccountTracking.Api/Models/Dtos/` (all DTO files)
|
|
- Create: `src/AccountTracking.Api/Data/AppDbContext.cs`
|
|
|
|
- [ ] **Step 1: Create the Transaction entity**
|
|
|
|
`src/AccountTracking.Api/Models/Transaction.cs`:
|
|
```csharp
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.ComponentModel.DataAnnotations.Schema;
|
|
|
|
namespace AccountTracking.Api.Models;
|
|
|
|
[Table("transactions")]
|
|
public class Transaction
|
|
{
|
|
[Key]
|
|
[Column("id")]
|
|
public int Id { get; set; }
|
|
|
|
[Column("account_number")]
|
|
[MaxLength(30)]
|
|
public string AccountNumber { get; set; } = "";
|
|
|
|
[Column("booking_date")]
|
|
public DateOnly BookingDate { get; set; }
|
|
|
|
[Column("amount")]
|
|
[Precision(15, 2)]
|
|
public decimal Amount { get; set; }
|
|
|
|
[Column("currency")]
|
|
[MaxLength(3)]
|
|
public string Currency { get; set; } = "CZK";
|
|
|
|
[Column("balance")]
|
|
[Precision(15, 2)]
|
|
public decimal Balance { get; set; }
|
|
|
|
[Column("counter_party_name")]
|
|
[MaxLength(255)]
|
|
public string? CounterPartyName { get; set; }
|
|
|
|
[Column("operation_description")]
|
|
[MaxLength(255)]
|
|
public string? OperationDescription { get; set; }
|
|
|
|
[Column("message")]
|
|
public string? Message { get; set; }
|
|
|
|
[Column("category")]
|
|
[MaxLength(100)]
|
|
public string? Category { get; set; }
|
|
|
|
[Column("variable_symbol")]
|
|
[MaxLength(30)]
|
|
public string? VariableSymbol { get; set; }
|
|
|
|
[Column("bank_note")]
|
|
[MaxLength(255)]
|
|
public string? BankNote { get; set; }
|
|
|
|
[Column("transaction_id")]
|
|
[MaxLength(512)]
|
|
public string TransactionId { get; set; } = "";
|
|
|
|
// Extra CSV columns stored but not used in UI
|
|
[Column("counter_bank_code")] [MaxLength(255)] public string? CounterBankCode { get; set; }
|
|
[Column("constant_symbol")] [MaxLength(255)] public string? ConstantSymbol { get; set; }
|
|
[Column("specific_symbol")] [MaxLength(255)] public string? SpecificSymbol { get; set; }
|
|
[Column("order_name")] [MaxLength(255)] public string? OrderName { get; set; }
|
|
[Column("exchange_rate")] [MaxLength(255)] public string? ExchangeRate { get; set; }
|
|
[Column("e2e_id")] [MaxLength(255)] public string? E2EId { get; set; }
|
|
[Column("payer_reference")] [MaxLength(255)] public string? PayerReference { get; set; }
|
|
[Column("original_payer")] [MaxLength(255)] public string? OriginalPayer { get; set; }
|
|
[Column("final_recipient")] [MaxLength(255)] public string? FinalRecipient { get; set; }
|
|
[Column("original_transaction")] [MaxLength(255)] public string? OriginalTransaction { get; set; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create the ImportLog entity**
|
|
|
|
`src/AccountTracking.Api/Models/ImportLog.cs`:
|
|
```csharp
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.ComponentModel.DataAnnotations.Schema;
|
|
|
|
namespace AccountTracking.Api.Models;
|
|
|
|
[Table("import_logs")]
|
|
public class ImportLog
|
|
{
|
|
[Key]
|
|
[Column("id")]
|
|
public int Id { get; set; }
|
|
|
|
[Column("imported_at")]
|
|
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
|
|
|
[Column("filename")]
|
|
[MaxLength(255)]
|
|
public string Filename { get; set; } = "";
|
|
|
|
[Column("records_imported")]
|
|
public int RecordsImported { get; set; }
|
|
|
|
[Column("records_skipped")]
|
|
public int RecordsSkipped { get; set; }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create all DTOs**
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/LoginRequest.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record LoginRequest(string Username, string Password);
|
|
```
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/LoginResponse.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record LoginResponse(string Token, string ExpiresAt);
|
|
```
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/ImportResult.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record ImportResult(int RecordsImported, int RecordsSkipped);
|
|
public record ErrorResult(string Error);
|
|
```
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/TransactionDto.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record TransactionDto(
|
|
int Id,
|
|
DateOnly BookingDate,
|
|
string? CounterPartyName,
|
|
string? Category,
|
|
decimal Amount,
|
|
decimal Balance,
|
|
string? Message
|
|
);
|
|
```
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/TransactionListResponse.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record TransactionListResponse(
|
|
IEnumerable<TransactionDto> Items,
|
|
int TotalCount,
|
|
int Page,
|
|
int PageSize
|
|
);
|
|
```
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/SummaryDto.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record SummaryDto(decimal TotalSpent, decimal TotalIncome);
|
|
```
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/CategorySpendingDto.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record CategorySpendingDto(string Category, decimal Total);
|
|
```
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/MonthlyBalanceDto.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record MonthlyBalanceDto(int Month, decimal ClosingBalance);
|
|
```
|
|
|
|
`src/AccountTracking.Api/Models/Dtos/CumulativeSpendingDto.cs`:
|
|
```csharp
|
|
namespace AccountTracking.Api.Models.Dtos;
|
|
public record CumulativeSpendingDto(int Day, decimal CumulativeSpent);
|
|
```
|
|
|
|
- [ ] **Step 4: Create AppDbContext**
|
|
|
|
`src/AccountTracking.Api/Data/AppDbContext.cs`:
|
|
```csharp
|
|
using AccountTracking.Api.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace AccountTracking.Api.Data;
|
|
|
|
public class AppDbContext : DbContext
|
|
{
|
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
|
|
|
public DbSet<Transaction> Transactions => Set<Transaction>();
|
|
public DbSet<ImportLog> ImportLogs => Set<ImportLog>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
{
|
|
// transaction_id: case-sensitive unique index using utf8mb4_bin
|
|
modelBuilder.Entity<Transaction>()
|
|
.HasIndex(t => t.TransactionId)
|
|
.IsUnique();
|
|
|
|
modelBuilder.Entity<Transaction>()
|
|
.Property(t => t.TransactionId)
|
|
.UseCollation("utf8mb4_bin");
|
|
|
|
// ImportedAt stored as UTC
|
|
modelBuilder.Entity<ImportLog>()
|
|
.Property(l => l.ImportedAt)
|
|
.HasConversion(
|
|
v => DateTime.SpecifyKind(v, DateTimeKind.Utc),
|
|
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Verify it compiles**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
dotnet build
|
|
```
|
|
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/Models/ src/AccountTracking.Api/Data/
|
|
git commit -m "feat: add EF Core models, DTOs and AppDbContext"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Application Startup (Program.cs)
|
|
|
|
**Files:**
|
|
- Create/replace: `src/AccountTracking.Api/Program.cs`
|
|
|
|
This task wires up all middleware: CORS, JWT auth, NSwag/Swagger, database registration with InMemory fallback for NSwag generation, and Polly migration retry.
|
|
|
|
- [ ] **Step 1: Write Program.cs**
|
|
|
|
`src/AccountTracking.Api/Program.cs`:
|
|
```csharp
|
|
using System.Text;
|
|
using AccountTracking.Api.Data;
|
|
using AccountTracking.Api.Services;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Polly;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// ── Configuration ──────────────────────────────────────────────────────────
|
|
var connectionString = builder.Configuration["DB_CONNECTION"];
|
|
var jwtSecret = builder.Configuration["JWT_SECRET"]
|
|
?? throw new InvalidOperationException("JWT_SECRET is required");
|
|
var appUsername = builder.Configuration["APP_USERNAME"]
|
|
?? throw new InvalidOperationException("APP_USERNAME is required");
|
|
var appPassword = builder.Configuration["APP_PASSWORD"]
|
|
?? throw new InvalidOperationException("APP_PASSWORD is required");
|
|
var allowedOrigin = builder.Configuration["ALLOWED_ORIGIN"] ?? "http://localhost:3000";
|
|
|
|
// ── Database ────────────────────────────────────────────────────────────────
|
|
if (!string.IsNullOrEmpty(connectionString))
|
|
{
|
|
builder.Services.AddDbContext<AppDbContext>(opts =>
|
|
opts.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
|
|
}
|
|
else
|
|
{
|
|
// NSwag build-time spec generation: no real DB available
|
|
builder.Services.AddDbContext<AppDbContext>(opts =>
|
|
opts.UseInMemoryDatabase("nswag_gen"));
|
|
}
|
|
|
|
// ── Services ────────────────────────────────────────────────────────────────
|
|
builder.Services.AddScoped<CsvImportService>();
|
|
builder.Services.AddScoped<DashboardService>();
|
|
|
|
// ── Auth ────────────────────────────────────────────────────────────────────
|
|
var keyBytes = Convert.FromBase64String(jwtSecret);
|
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
.AddJwtBearer(opts =>
|
|
{
|
|
opts.TokenValidationParameters = new TokenValidationParameters
|
|
{
|
|
ValidateIssuerSigningKey = true,
|
|
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
|
|
ValidateIssuer = false,
|
|
ValidateAudience = false,
|
|
ClockSkew = TimeSpan.Zero
|
|
};
|
|
});
|
|
builder.Services.AddAuthorization();
|
|
|
|
// Store credentials for use by AuthController
|
|
builder.Services.AddSingleton(new AppCredentials(appUsername, appPassword, keyBytes));
|
|
|
|
// ── CORS ────────────────────────────────────────────────────────────────────
|
|
builder.Services.AddCors(opts =>
|
|
{
|
|
opts.AddDefaultPolicy(policy =>
|
|
policy.WithOrigins(allowedOrigin, "http://localhost:3000")
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod());
|
|
});
|
|
|
|
// ── Controllers & OpenAPI ───────────────────────────────────────────────────
|
|
builder.Services.AddControllers();
|
|
builder.Services.AddOpenApiDocument(config =>
|
|
{
|
|
config.Title = "AccountTracking API";
|
|
config.Version = "v1";
|
|
config.AddSecurity("Bearer", new NSwag.OpenApiSecurityScheme
|
|
{
|
|
Type = NSwag.OpenApiSecuritySchemeType.Http,
|
|
Scheme = "bearer",
|
|
BearerFormat = "JWT"
|
|
});
|
|
});
|
|
|
|
// ── Kestrel: limit upload to 10 MB ─────────────────────────────────────────
|
|
builder.WebHost.ConfigureKestrel(opts =>
|
|
opts.Limits.MaxRequestBodySize = 10 * 1024 * 1024);
|
|
|
|
var app = builder.Build();
|
|
|
|
// ── Migrate DB (skip when running without a real DB) ───────────────────────
|
|
if (!string.IsNullOrEmpty(connectionString))
|
|
{
|
|
var retryPolicy = Policy
|
|
.Handle<Exception>()
|
|
.WaitAndRetry(
|
|
5,
|
|
attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)),
|
|
(ex, delay, attempt, _) =>
|
|
Console.WriteLine($"DB migration attempt {attempt} failed: {ex.Message}. Retrying in {delay.TotalSeconds}s..."));
|
|
|
|
using var scope = app.Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
retryPolicy.Execute(() => db.Database.Migrate());
|
|
}
|
|
|
|
// ── Middleware pipeline ─────────────────────────────────────────────────────
|
|
app.UseCors();
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseOpenApi();
|
|
app.UseSwaggerUi();
|
|
app.MapControllers();
|
|
|
|
app.Run();
|
|
|
|
// Accessible by tests
|
|
public partial class Program { }
|
|
|
|
// Credential container registered in DI
|
|
public record AppCredentials(string Username, string PasswordHash, byte[] JwtKeyBytes);
|
|
```
|
|
|
|
- [ ] **Step 2: Verify it builds**
|
|
|
|
```bash
|
|
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj
|
|
```
|
|
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/Program.cs
|
|
git commit -m "feat: configure startup — CORS, JWT, NSwag, Polly migration retry"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Auth Controller
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Api/Controllers/AuthController.cs`
|
|
|
|
- [ ] **Step 1: Create AuthController**
|
|
|
|
`src/AccountTracking.Api/Controllers/AuthController.cs`:
|
|
```csharp
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using AccountTracking.Api.Models.Dtos;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
|
|
namespace AccountTracking.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/auth")]
|
|
public class AuthController : ControllerBase
|
|
{
|
|
private readonly AppCredentials _credentials;
|
|
|
|
public AuthController(AppCredentials credentials)
|
|
=> _credentials = credentials;
|
|
|
|
[HttpPost("login")]
|
|
public IActionResult Login([FromBody] LoginRequest request)
|
|
{
|
|
if (request.Username != _credentials.Username
|
|
|| !BCrypt.Net.BCrypt.Verify(request.Password, _credentials.PasswordHash))
|
|
return Unauthorized();
|
|
|
|
var expiry = DateTime.UtcNow.AddDays(30);
|
|
var token = GenerateToken(expiry);
|
|
|
|
return Ok(new LoginResponse(token, expiry.ToString("O")));
|
|
}
|
|
|
|
private string GenerateToken(DateTime expiry)
|
|
{
|
|
var key = new SymmetricSecurityKey(_credentials.JwtKeyBytes);
|
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
var jwt = new JwtSecurityToken(
|
|
claims: [new Claim(ClaimTypes.Name, _credentials.Username)],
|
|
expires: expiry,
|
|
signingCredentials: creds);
|
|
return new JwtSecurityTokenHandler().WriteToken(jwt);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build and verify**
|
|
|
|
```bash
|
|
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj
|
|
```
|
|
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/Controllers/AuthController.cs
|
|
git commit -m "feat: add JWT auth endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: CSV Import Service (TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Api/Services/CsvImportService.cs`
|
|
- Create: `src/AccountTracking.Api.Tests/CsvImportServiceTests.cs`
|
|
|
|
The service parses ČSOB semicolon-delimited UTF-8 CSV files. Header row is found by scanning for a line starting with "číslo účtu". Czech-locale amounts ("1 740,80") are normalised before parsing.
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
`src/AccountTracking.Api.Tests/CsvImportServiceTests.cs`:
|
|
```csharp
|
|
using AccountTracking.Api.Data;
|
|
using AccountTracking.Api.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace AccountTracking.Api.Tests;
|
|
|
|
public class CsvImportServiceTests
|
|
{
|
|
private static AppDbContext CreateDb()
|
|
{
|
|
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
return new AppDbContext(opts);
|
|
}
|
|
|
|
private static CsvImportService CreateService(AppDbContext db)
|
|
=> new(db);
|
|
|
|
// Minimal valid CSV with two data rows
|
|
private const string ValidCsv = """
|
|
Pohyby na účtu 216868554/0300 dne 19.03.2026
|
|
|
|
číslo účtu;datum zaúčtování;částka;měna;zůstatek;číslo protiúčtu;kód banky protiúčtu;jméno protistrany;adresa protistrany;konstantní symbol;variabilní symbol;specifický symbol;označení operace;název trálého příkazu;vlastní poznámka;zpráva;kategorie;původní transakce;kurz;E2E identifikace;reference plátce;původní plátce;konečný příjemce;ID transakce
|
|
216868554/0300;15.03.2026;-1 740,80;CZK;93 346,57;;;;;;205000001;;Transakce platební kartou;;;Zpráva 1;Potraviny;;;;;tx-001
|
|
216868554/0300;14.03.2026;-800,00;CZK;95 087,37;;;;;;205000002;;Transakce platební kartou;;;Zpráva 2;Restaurace;;;;;tx-002
|
|
""";
|
|
|
|
private const string CsvMissingHeader = """
|
|
Pohyby na účtu
|
|
Nějaky obsah bez správné hlavičky
|
|
datum;částka
|
|
""";
|
|
|
|
private const string CsvMissingRequiredColumn = """
|
|
číslo účtu;datum zaúčtování;měna;zůstatek;ID transakce
|
|
216868554/0300;15.03.2026;CZK;93346,57;tx-001
|
|
""";
|
|
|
|
[Fact]
|
|
public async Task Import_ParsesRowsCorrectly()
|
|
{
|
|
using var db = CreateDb();
|
|
var svc = CreateService(db);
|
|
|
|
var result = await svc.ImportAsync(ValidCsv, "test.csv");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(2, result.Value!.RecordsImported);
|
|
Assert.Equal(0, result.Value.RecordsSkipped);
|
|
|
|
var transactions = await db.Transactions.ToListAsync();
|
|
Assert.Equal(2, transactions.Count);
|
|
|
|
var tx = transactions.First(t => t.TransactionId == "tx-001");
|
|
Assert.Equal(-1740.80m, tx.Amount);
|
|
Assert.Equal(93346.57m, tx.Balance);
|
|
Assert.Equal("Potraviny", tx.Category);
|
|
Assert.Equal(new DateOnly(2026, 3, 15), tx.BookingDate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_SkipsDuplicateTransactionIds()
|
|
{
|
|
using var db = CreateDb();
|
|
var svc = CreateService(db);
|
|
|
|
await svc.ImportAsync(ValidCsv, "first.csv");
|
|
var result = await svc.ImportAsync(ValidCsv, "second.csv");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(0, result.Value!.RecordsImported);
|
|
Assert.Equal(2, result.Value.RecordsSkipped);
|
|
Assert.Equal(2, await db.Transactions.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_FindsHeaderByScanning_NotByLineNumber()
|
|
{
|
|
// Header is on line 3 in ValidCsv — but we scan, not hardcode
|
|
using var db = CreateDb();
|
|
var svc = CreateService(db);
|
|
|
|
var result = await svc.ImportAsync(ValidCsv, "test.csv");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_ReturnsError_WhenHeaderNotFound()
|
|
{
|
|
using var db = CreateDb();
|
|
var svc = CreateService(db);
|
|
|
|
var result = await svc.ImportAsync(CsvMissingHeader, "bad.csv");
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.Contains("hlavičku", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Import_ReturnsError_WhenRequiredColumnMissing()
|
|
{
|
|
using var db = CreateDb();
|
|
var svc = CreateService(db);
|
|
|
|
var result = await svc.ImportAsync(CsvMissingRequiredColumn, "bad.csv");
|
|
|
|
Assert.False(result.IsSuccess);
|
|
Assert.Contains("částka", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("1 740,80", 1740.80)]
|
|
[InlineData("-4 263,15", -4263.15)]
|
|
[InlineData("-800,00", -800.00)]
|
|
[InlineData("93 346,57", 93346.57)]
|
|
public void ParseAmount_HandlesChechLocaleFormat(string input, decimal expected)
|
|
{
|
|
var result = CsvImportService.ParseCzechDecimal(input);
|
|
Assert.Equal(expected, result);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests — verify they all fail**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
dotnet test src/AccountTracking.Api.Tests/ --no-build 2>&1 | head -30
|
|
```
|
|
|
|
Expected: build errors because `CsvImportService` does not exist yet.
|
|
|
|
- [ ] **Step 3: Implement CsvImportService**
|
|
|
|
`src/AccountTracking.Api/Services/CsvImportService.cs`:
|
|
```csharp
|
|
using AccountTracking.Api.Data;
|
|
using AccountTracking.Api.Models;
|
|
using AccountTracking.Api.Models.Dtos;
|
|
|
|
namespace AccountTracking.Api.Services;
|
|
|
|
public class CsvImportService(AppDbContext db)
|
|
{
|
|
private static readonly string[] RequiredColumns = ["částka", "ID transakce", "datum zaúčtování"];
|
|
|
|
public async Task<ImportServiceResult> ImportAsync(string csvContent, string filename)
|
|
{
|
|
var lines = csvContent
|
|
.Split('\n', StringSplitOptions.None)
|
|
.Select(l => l.TrimEnd('\r'))
|
|
.ToArray();
|
|
|
|
// Find header row by scanning for line starting with "číslo účtu"
|
|
int headerIndex = -1;
|
|
for (int i = 0; i < lines.Length; i++)
|
|
{
|
|
if (lines[i].StartsWith("číslo účtu", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
headerIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (headerIndex < 0)
|
|
return ImportServiceResult.Failure("Soubor neobsahuje správnou hlavičku (řádek začínající 'číslo účtu').");
|
|
|
|
var headers = lines[headerIndex].Split(';');
|
|
var columnIndex = BuildColumnIndex(headers);
|
|
|
|
// Validate required columns
|
|
foreach (var col in RequiredColumns)
|
|
{
|
|
if (!columnIndex.ContainsKey(col))
|
|
return ImportServiceResult.Failure($"Souboru chybí povinný sloupec: '{col}'.");
|
|
}
|
|
|
|
// Load existing transaction IDs to detect duplicates
|
|
var existingIds = db.Transactions
|
|
.Select(t => t.TransactionId)
|
|
.ToHashSet();
|
|
|
|
int imported = 0, skipped = 0;
|
|
var toInsert = new List<Transaction>();
|
|
|
|
for (int i = headerIndex + 1; i < lines.Length; i++)
|
|
{
|
|
var line = lines[i];
|
|
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
|
|
var fields = line.Split(';');
|
|
if (fields.Length < headers.Length) continue;
|
|
|
|
var txId = GetField(fields, columnIndex, "ID transakce");
|
|
if (string.IsNullOrEmpty(txId)) continue;
|
|
|
|
if (existingIds.Contains(txId))
|
|
{
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
existingIds.Add(txId); // prevent duplicates within the same file
|
|
|
|
toInsert.Add(new Transaction
|
|
{
|
|
AccountNumber = GetField(fields, columnIndex, "číslo účtu"),
|
|
BookingDate = DateOnly.ParseExact(GetField(fields, columnIndex, "datum zaúčtování"), "d.M.yyyy"),
|
|
Amount = ParseCzechDecimal(GetField(fields, columnIndex, "částka")),
|
|
Currency = GetField(fields, columnIndex, "měna"),
|
|
Balance = ParseCzechDecimal(GetField(fields, columnIndex, "zůstatek")),
|
|
CounterPartyName = GetField(fields, columnIndex, "jméno protistrany"),
|
|
OperationDescription = GetField(fields, columnIndex, "označení operace"),
|
|
Message = GetField(fields, columnIndex, "zpráva"),
|
|
Category = GetField(fields, columnIndex, "kategorie"),
|
|
VariableSymbol = GetField(fields, columnIndex, "variabilní symbol"),
|
|
BankNote = GetField(fields, columnIndex, "vlastní poznámka"),
|
|
TransactionId = txId,
|
|
CounterBankCode = GetField(fields, columnIndex, "kód banky protiúčtu"),
|
|
ConstantSymbol = GetField(fields, columnIndex, "konstantní symbol"),
|
|
SpecificSymbol = GetField(fields, columnIndex, "specifický symbol"),
|
|
OrderName = GetField(fields, columnIndex, "název trvalého příkazu"),
|
|
ExchangeRate = GetField(fields, columnIndex, "kurz"),
|
|
E2EId = GetField(fields, columnIndex, "E2E identifikace"),
|
|
PayerReference = GetField(fields, columnIndex, "reference plátce"),
|
|
OriginalPayer = GetField(fields, columnIndex, "původní plátce"),
|
|
FinalRecipient = GetField(fields, columnIndex, "konečný příjemce"),
|
|
OriginalTransaction = GetField(fields, columnIndex, "původní transakce"),
|
|
});
|
|
imported++;
|
|
}
|
|
|
|
if (toInsert.Count > 0)
|
|
{
|
|
db.Transactions.AddRange(toInsert);
|
|
db.ImportLogs.Add(new ImportLog
|
|
{
|
|
Filename = filename,
|
|
RecordsImported = imported,
|
|
RecordsSkipped = skipped,
|
|
ImportedAt = DateTime.UtcNow
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
return ImportServiceResult.Success(new ImportResult(imported, skipped));
|
|
}
|
|
|
|
public static decimal ParseCzechDecimal(string value)
|
|
{
|
|
// Czech format: "1 740,80" → remove spaces, replace comma with dot
|
|
var normalised = value.Replace(" ", "").Replace(",", ".");
|
|
return decimal.Parse(normalised, System.Globalization.CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static Dictionary<string, int> BuildColumnIndex(string[] headers)
|
|
=> headers
|
|
.Select((h, i) => (Header: h.Trim(), Index: i))
|
|
.Where(x => !string.IsNullOrEmpty(x.Header))
|
|
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
|
|
|
|
private static string GetField(string[] fields, Dictionary<string, int> index, string column)
|
|
{
|
|
if (!index.TryGetValue(column, out int i)) return "";
|
|
return i < fields.Length ? fields[i].Trim() : "";
|
|
}
|
|
}
|
|
|
|
public class ImportServiceResult
|
|
{
|
|
public bool IsSuccess { get; private init; }
|
|
public ImportResult? Value { get; private init; }
|
|
public string? Error { get; private init; }
|
|
|
|
public static ImportServiceResult Success(ImportResult value)
|
|
=> new() { IsSuccess = true, Value = value };
|
|
|
|
public static ImportServiceResult Failure(string error)
|
|
=> new() { IsSuccess = false, Error = error };
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests — verify they pass**
|
|
|
|
```bash
|
|
dotnet test src/AccountTracking.Api.Tests/ -v normal
|
|
```
|
|
|
|
Expected: all 8 tests pass.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/Services/CsvImportService.cs \
|
|
src/AccountTracking.Api.Tests/CsvImportServiceTests.cs
|
|
git commit -m "feat: implement CSV import service with tests (TDD)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Transactions API Controller
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Api/Controllers/TransactionsController.cs`
|
|
|
|
- [ ] **Step 1: Create TransactionsController**
|
|
|
|
`src/AccountTracking.Api/Controllers/TransactionsController.cs`:
|
|
```csharp
|
|
using AccountTracking.Api.Data;
|
|
using AccountTracking.Api.Models.Dtos;
|
|
using AccountTracking.Api.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace AccountTracking.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/transactions")]
|
|
[Authorize]
|
|
public class TransactionsController : ControllerBase
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly CsvImportService _importService;
|
|
|
|
public TransactionsController(AppDbContext db, CsvImportService importService)
|
|
{
|
|
_db = db;
|
|
_importService = importService;
|
|
}
|
|
|
|
[HttpPost("import")]
|
|
public async Task<IActionResult> Import(IFormFile file)
|
|
{
|
|
if (file == null || file.Length == 0)
|
|
return BadRequest(new ErrorResult("Nebyl vybrán žádný soubor."));
|
|
|
|
string content;
|
|
using (var reader = new StreamReader(file.OpenReadStream(), System.Text.Encoding.UTF8))
|
|
content = await reader.ReadToEndAsync();
|
|
|
|
var result = await _importService.ImportAsync(content, file.FileName);
|
|
|
|
if (!result.IsSuccess)
|
|
return BadRequest(new ErrorResult(result.Error!));
|
|
|
|
return Ok(result.Value);
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> List(
|
|
[FromQuery] int year,
|
|
[FromQuery] int? month,
|
|
[FromQuery] string? category,
|
|
[FromQuery] string? search,
|
|
[FromQuery] int page = 1,
|
|
[FromQuery] int pageSize = 50)
|
|
{
|
|
pageSize = Math.Clamp(pageSize, 1, 200);
|
|
page = Math.Max(1, page);
|
|
|
|
var query = _db.Transactions.AsQueryable();
|
|
|
|
query = query.Where(t => t.BookingDate.Year == year);
|
|
if (month.HasValue)
|
|
query = query.Where(t => t.BookingDate.Month == month.Value);
|
|
if (!string.IsNullOrWhiteSpace(category))
|
|
query = query.Where(t => t.Category == category);
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
query = query.Where(t =>
|
|
(t.CounterPartyName != null && EF.Functions.Like(t.CounterPartyName, $"%{search}%")) ||
|
|
(t.Message != null && EF.Functions.Like(t.Message, $"%{search}%")));
|
|
|
|
var total = await query.CountAsync();
|
|
var items = await query
|
|
.OrderByDescending(t => t.BookingDate)
|
|
.ThenByDescending(t => t.Id)
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(t => new TransactionDto(
|
|
t.Id, t.BookingDate, t.CounterPartyName,
|
|
t.Category, t.Amount, t.Balance, t.Message))
|
|
.ToListAsync();
|
|
|
|
return Ok(new TransactionListResponse(items, total, page, pageSize));
|
|
}
|
|
|
|
[HttpGet("categories")]
|
|
public async Task<IActionResult> Categories()
|
|
{
|
|
var cats = await _db.Transactions
|
|
.Where(t => t.Category != null)
|
|
.Select(t => t.Category!)
|
|
.Distinct()
|
|
.OrderBy(c => c)
|
|
.ToListAsync();
|
|
|
|
return Ok(cats);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build and verify**
|
|
|
|
```bash
|
|
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj
|
|
```
|
|
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/Controllers/TransactionsController.cs
|
|
git commit -m "feat: add transactions import, list and categories endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Dashboard Service & Controller (TDD)
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Api/Services/DashboardService.cs`
|
|
- Create: `src/AccountTracking.Api/Controllers/DashboardController.cs`
|
|
- Create: `src/AccountTracking.Api.Tests/DashboardServiceTests.cs`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
`src/AccountTracking.Api.Tests/DashboardServiceTests.cs`:
|
|
```csharp
|
|
using AccountTracking.Api.Data;
|
|
using AccountTracking.Api.Models;
|
|
using AccountTracking.Api.Services;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace AccountTracking.Api.Tests;
|
|
|
|
public class DashboardServiceTests
|
|
{
|
|
private static AppDbContext CreateDb(params Transaction[] seed)
|
|
{
|
|
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
.Options;
|
|
var db = new AppDbContext(opts);
|
|
db.Transactions.AddRange(seed);
|
|
db.SaveChanges();
|
|
return db;
|
|
}
|
|
|
|
private static Transaction Tx(string date, decimal amount, decimal balance,
|
|
string category = "Test", string txId = "") =>
|
|
new()
|
|
{
|
|
BookingDate = DateOnly.Parse(date),
|
|
Amount = amount,
|
|
Balance = balance,
|
|
Category = category,
|
|
TransactionId = txId == "" ? Guid.NewGuid().ToString() : txId,
|
|
Currency = "CZK",
|
|
AccountNumber = "test"
|
|
};
|
|
|
|
// ── Summary ──────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Summary_YearOnly_ReturnsTotalsForWholeYear()
|
|
{
|
|
using var db = CreateDb(
|
|
Tx("2025-01-10", -1000, 9000),
|
|
Tx("2025-06-15", -2000, 7000),
|
|
Tx("2025-03-01", 5000, 12000),
|
|
Tx("2024-12-31", -500, 9500) // different year — excluded
|
|
);
|
|
var svc = new DashboardService(db);
|
|
|
|
var result = await svc.GetSummaryAsync(2025, null);
|
|
|
|
Assert.Equal(3000m, result.TotalSpent); // 1000 + 2000
|
|
Assert.Equal(5000m, result.TotalIncome);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Summary_YearAndMonth_ReturnsTotalsForMonth()
|
|
{
|
|
using var db = CreateDb(
|
|
Tx("2025-03-10", -1000, 9000),
|
|
Tx("2025-03-20", -500, 8500),
|
|
Tx("2025-04-01", -800, 7700) // different month — excluded
|
|
);
|
|
var svc = new DashboardService(db);
|
|
|
|
var result = await svc.GetSummaryAsync(2025, 3);
|
|
|
|
Assert.Equal(1500m, result.TotalSpent);
|
|
Assert.Equal(0m, result.TotalIncome);
|
|
}
|
|
|
|
// ── SpendingByCategory ───────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task SpendingByCategory_ReturnsAbsoluteValuesSortedDescending()
|
|
{
|
|
using var db = CreateDb(
|
|
Tx("2025-03-01", -100, 900, "A"),
|
|
Tx("2025-03-02", -300, 600, "B"),
|
|
Tx("2025-03-03", -200, 400, "A")
|
|
);
|
|
var svc = new DashboardService(db);
|
|
|
|
var result = await svc.GetSpendingByCategoryAsync(2025, 3);
|
|
|
|
Assert.Equal(2, result.Count);
|
|
Assert.Equal("A", result[0].Category);
|
|
Assert.Equal(300m, result[0].Total); // 100 + 200
|
|
Assert.Equal("B", result[1].Category);
|
|
Assert.Equal(300m, result[1].Total);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SpendingByCategory_ExcludesPositiveAmounts()
|
|
{
|
|
using var db = CreateDb(
|
|
Tx("2025-03-01", -500, 9500, "Potraviny"),
|
|
Tx("2025-03-02", 200, 9700, "Potraviny") // refund — excluded
|
|
);
|
|
var svc = new DashboardService(db);
|
|
|
|
var result = await svc.GetSpendingByCategoryAsync(2025, 3);
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal(500m, result[0].Total);
|
|
}
|
|
|
|
// ── MonthlyBalances ──────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task MonthlyBalances_ReturnsLastBalancePerMonth()
|
|
{
|
|
using var db = CreateDb(
|
|
Tx("2025-01-10", -100, 900),
|
|
Tx("2025-01-20", -200, 700), // last in Jan — use this balance
|
|
Tx("2025-03-05", -150, 550) // March — Feb omitted (no transactions)
|
|
);
|
|
var svc = new DashboardService(db);
|
|
|
|
var result = await svc.GetMonthlyBalancesAsync(2025);
|
|
|
|
Assert.Equal(2, result.Count);
|
|
Assert.Equal(1, result[0].Month);
|
|
Assert.Equal(700m, result[0].ClosingBalance);
|
|
Assert.Equal(3, result[1].Month);
|
|
Assert.Equal(550m, result[1].ClosingBalance);
|
|
}
|
|
|
|
// ── CumulativeSpending ───────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task CumulativeSpending_FillsGapsAndAccumulates()
|
|
{
|
|
using var db = CreateDb(
|
|
Tx("2025-03-01", -100, 900),
|
|
Tx("2025-03-03", -200, 700), // gap on day 2
|
|
Tx("2025-03-03", -50, 650) // two transactions on same day
|
|
);
|
|
var svc = new DashboardService(db);
|
|
|
|
var result = await svc.GetCumulativeSpendingAsync(2025, 3);
|
|
|
|
// Day 1: 100, Day 2: filled = 100, Day 3: 100 + 250 = 350
|
|
Assert.Equal(3, result.Count);
|
|
Assert.Equal(1, result[0].Day); Assert.Equal(100m, result[0].CumulativeSpent);
|
|
Assert.Equal(2, result[1].Day); Assert.Equal(100m, result[1].CumulativeSpent);
|
|
Assert.Equal(3, result[2].Day); Assert.Equal(350m, result[2].CumulativeSpent);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CumulativeSpending_ExcludesPositiveAmounts()
|
|
{
|
|
using var db = CreateDb(
|
|
Tx("2025-03-01", -100, 900),
|
|
Tx("2025-03-01", 500, 1400) // income — not counted
|
|
);
|
|
var svc = new DashboardService(db);
|
|
|
|
var result = await svc.GetCumulativeSpendingAsync(2025, 3);
|
|
|
|
Assert.Single(result);
|
|
Assert.Equal(100m, result[0].CumulativeSpent);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests — verify they fail**
|
|
|
|
```bash
|
|
dotnet test src/AccountTracking.Api.Tests/ 2>&1 | head -20
|
|
```
|
|
|
|
Expected: build error — `DashboardService` not found.
|
|
|
|
- [ ] **Step 3: Implement DashboardService**
|
|
|
|
`src/AccountTracking.Api/Services/DashboardService.cs`:
|
|
```csharp
|
|
using AccountTracking.Api.Data;
|
|
using AccountTracking.Api.Models.Dtos;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace AccountTracking.Api.Services;
|
|
|
|
public class DashboardService(AppDbContext db)
|
|
{
|
|
public async Task<SummaryDto> GetSummaryAsync(int year, int? month)
|
|
{
|
|
var query = db.Transactions.Where(t => t.BookingDate.Year == year);
|
|
if (month.HasValue)
|
|
query = query.Where(t => t.BookingDate.Month == month.Value);
|
|
|
|
var spent = await query
|
|
.Where(t => t.Amount < 0)
|
|
.SumAsync(t => (decimal?)t.Amount) ?? 0;
|
|
var income = await query
|
|
.Where(t => t.Amount > 0)
|
|
.SumAsync(t => (decimal?)t.Amount) ?? 0;
|
|
|
|
return new SummaryDto(Math.Abs(spent), income);
|
|
}
|
|
|
|
public async Task<List<CategorySpendingDto>> GetSpendingByCategoryAsync(int year, int? month)
|
|
{
|
|
var query = db.Transactions
|
|
.Where(t => t.BookingDate.Year == year && t.Amount < 0);
|
|
|
|
if (month.HasValue)
|
|
query = query.Where(t => t.BookingDate.Month == month.Value);
|
|
|
|
return await query
|
|
.GroupBy(t => t.Category ?? "Nezatříděno")
|
|
.Select(g => new CategorySpendingDto(g.Key, Math.Abs(g.Sum(t => t.Amount))))
|
|
.OrderByDescending(c => c.Total)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<List<MonthlyBalanceDto>> GetMonthlyBalancesAsync(int year)
|
|
{
|
|
// For each month: get the balance of the transaction with the latest booking_date
|
|
// (highest id as tiebreaker for same-date rows)
|
|
var rows = await db.Transactions
|
|
.Where(t => t.BookingDate.Year == year)
|
|
.GroupBy(t => t.BookingDate.Month)
|
|
.Select(g => new MonthlyBalanceDto(
|
|
g.Key,
|
|
g.OrderByDescending(t => t.BookingDate)
|
|
.ThenByDescending(t => t.Id)
|
|
.First().Balance))
|
|
.OrderBy(r => r.Month)
|
|
.ToListAsync();
|
|
|
|
return rows;
|
|
}
|
|
|
|
public async Task<List<CumulativeSpendingDto>> GetCumulativeSpendingAsync(int year, int month)
|
|
{
|
|
// Get daily spending totals (absolute values, negative amounts only)
|
|
var dailySpending = await db.Transactions
|
|
.Where(t => t.BookingDate.Year == year
|
|
&& t.BookingDate.Month == month
|
|
&& t.Amount < 0)
|
|
.GroupBy(t => t.BookingDate.Day)
|
|
.Select(g => new { Day = g.Key, Spent = Math.Abs(g.Sum(t => t.Amount)) })
|
|
.OrderBy(g => g.Day)
|
|
.ToListAsync();
|
|
|
|
if (dailySpending.Count == 0)
|
|
return [];
|
|
|
|
// Fill gaps: for each day from day 1 to last day with spending,
|
|
// carry forward the previous cumulative value
|
|
var result = new List<CumulativeSpendingDto>();
|
|
decimal running = 0;
|
|
int lastDay = dailySpending.Max(d => d.Day);
|
|
var spendingByDay = dailySpending.ToDictionary(d => d.Day, d => d.Spent);
|
|
|
|
for (int day = 1; day <= lastDay; day++)
|
|
{
|
|
if (spendingByDay.TryGetValue(day, out var spent))
|
|
running += spent;
|
|
|
|
// Only emit days up to and including last spending day
|
|
// but fill gaps so chart is continuous
|
|
if (day <= lastDay)
|
|
result.Add(new CumulativeSpendingDto(day, running));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests — verify they pass**
|
|
|
|
```bash
|
|
dotnet test src/AccountTracking.Api.Tests/ -v normal
|
|
```
|
|
|
|
Expected: all tests pass (both CsvImportService and DashboardService suites).
|
|
|
|
- [ ] **Step 5: Create DashboardController**
|
|
|
|
`src/AccountTracking.Api/Controllers/DashboardController.cs`:
|
|
```csharp
|
|
using AccountTracking.Api.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace AccountTracking.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/dashboard")]
|
|
[Authorize]
|
|
public class DashboardController : ControllerBase
|
|
{
|
|
private readonly DashboardService _svc;
|
|
public DashboardController(DashboardService svc) => _svc = svc;
|
|
|
|
[HttpGet("summary")]
|
|
public async Task<IActionResult> Summary([FromQuery] int year, [FromQuery] int? month)
|
|
=> Ok(await _svc.GetSummaryAsync(year, month));
|
|
|
|
[HttpGet("spending-by-category")]
|
|
public async Task<IActionResult> SpendingByCategory([FromQuery] int year, [FromQuery] int? month)
|
|
=> Ok(await _svc.GetSpendingByCategoryAsync(year, month));
|
|
|
|
[HttpGet("monthly-balances")]
|
|
public async Task<IActionResult> MonthlyBalances([FromQuery] int year)
|
|
=> Ok(await _svc.GetMonthlyBalancesAsync(year));
|
|
|
|
[HttpGet("cumulative-spending")]
|
|
public async Task<IActionResult> CumulativeSpending([FromQuery] int year, [FromQuery] int month)
|
|
=> Ok(await _svc.GetCumulativeSpendingAsync(year, month));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Build and verify**
|
|
|
|
```bash
|
|
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj
|
|
```
|
|
|
|
Expected: `Build succeeded.`
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/Services/DashboardService.cs \
|
|
src/AccountTracking.Api/Controllers/DashboardController.cs \
|
|
src/AccountTracking.Api.Tests/DashboardServiceTests.cs
|
|
git commit -m "feat: implement dashboard service and controller with tests (TDD)"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: EF Core Migrations
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Api/Data/Migrations/` (generated)
|
|
|
|
Migrations require a real MySQL connection. Use a local dev DB for this step.
|
|
|
|
- [ ] **Step 1: Set up a local MySQL instance (if not running)**
|
|
|
|
```bash
|
|
docker run -d --name finance-dev-db \
|
|
-e MYSQL_ROOT_PASSWORD=devpass \
|
|
-e MYSQL_DATABASE=accounttracking \
|
|
-p 3306:3306 \
|
|
mysql:8
|
|
```
|
|
|
|
Wait ~15 seconds for MySQL to start.
|
|
|
|
- [ ] **Step 2: Create the initial migration**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
DB_CONNECTION="Server=localhost;Database=accounttracking;User=root;Password=devpass;" \
|
|
dotnet ef migrations add InitialCreate \
|
|
--project src/AccountTracking.Api \
|
|
--output-dir Data/Migrations
|
|
```
|
|
|
|
Expected: `Done. To undo this action, use 'ef migrations remove'`
|
|
|
|
- [ ] **Step 3: Verify the migration applies**
|
|
|
|
```bash
|
|
DB_CONNECTION="Server=localhost;Database=accounttracking;User=root;Password=devpass;" \
|
|
dotnet ef database update \
|
|
--project src/AccountTracking.Api
|
|
```
|
|
|
|
Expected: `Done.`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/Data/Migrations/
|
|
git commit -m "feat: add initial EF Core database migration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: NSwag OpenAPI Spec Generation
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Api/nswag.json`
|
|
- Create (generated): `src/AccountTracking.Api/openapi.json`
|
|
|
|
- [ ] **Step 1: Create nswag.json in the API project**
|
|
|
|
`src/AccountTracking.Api/nswag.json`:
|
|
```json
|
|
{
|
|
"runtime": "Net80",
|
|
"documentGenerator": {
|
|
"aspNetCoreToOpenApi": {
|
|
"project": "AccountTracking.Api.csproj",
|
|
"msBuildProjectExtensionsPath": null,
|
|
"configuration": "Debug",
|
|
"noBuild": true,
|
|
"verbose": false,
|
|
"outputType": "OpenApi3",
|
|
"output": "openapi.json"
|
|
}
|
|
},
|
|
"codeGenerators": {}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build and generate the spec**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj \
|
|
/p:GenerateOpenApiSpec=true
|
|
```
|
|
|
|
If the NSwag target fails due to DB (no DB connection in build env), run manually:
|
|
|
|
```bash
|
|
cd src/AccountTracking.Api
|
|
dotnet build -c Debug
|
|
$(dotnet tool list --global | grep nswag | awk '{print $1}') run nswag.json
|
|
```
|
|
|
|
Or use the simpler approach — run the API and curl the swagger endpoint:
|
|
|
|
```bash
|
|
# Terminal 1: start the API with InMemory DB (no DB_CONNECTION set)
|
|
cd src/AccountTracking.Api
|
|
dotnet run &
|
|
API_PID=$!
|
|
sleep 3
|
|
|
|
# Fetch and save the OpenAPI spec
|
|
curl -s http://localhost:5000/swagger/v1/swagger.json -o openapi.json
|
|
|
|
kill $API_PID
|
|
```
|
|
|
|
- [ ] **Step 3: Verify openapi.json contains expected routes**
|
|
|
|
```bash
|
|
cat src/AccountTracking.Api/openapi.json | grep -E '"\/api\/(auth|transactions|dashboard)'
|
|
```
|
|
|
|
Expected: lines containing `/api/auth/login`, `/api/transactions`, `/api/dashboard/summary` etc.
|
|
|
|
- [ ] **Step 4: Add openapi.json to .gitignore or commit it**
|
|
|
|
The spec should be committed so the frontend Dockerfile can use it without running the API:
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/openapi.json src/AccountTracking.Api/nswag.json
|
|
git commit -m "feat: add NSwag config and commit generated openapi.json"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Frontend Scaffold
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Web/package.json`
|
|
- Create: `src/AccountTracking.Web/vite.config.ts`
|
|
- Create: `src/AccountTracking.Web/tsconfig.json`
|
|
- Create: `src/AccountTracking.Web/index.html`
|
|
- Create: `src/AccountTracking.Web/src/main.ts`
|
|
- Create: `src/AccountTracking.Web/src/App.vue`
|
|
|
|
- [ ] **Step 1: Scaffold the Vue project**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking/src
|
|
npm create vue@latest AccountTracking.Web -- \
|
|
--typescript --router --pinia --no-jsx --no-vitest --no-playwright --no-eslint
|
|
cd AccountTracking.Web
|
|
npm install
|
|
```
|
|
|
|
- [ ] **Step 2: Add Vuetify, ApexCharts and NSwag dependencies**
|
|
|
|
```bash
|
|
npm install vuetify@^3.6.0 @mdi/font apexcharts vue3-apexcharts
|
|
npm install --save-dev nswag vite-plugin-vuetify
|
|
```
|
|
|
|
- [ ] **Step 3: Configure Vite with Vuetify plugin and API proxy**
|
|
|
|
Replace `src/AccountTracking.Web/vite.config.ts`:
|
|
```ts
|
|
import { defineConfig } from 'vite'
|
|
import vue from '@vitejs/plugin-vue'
|
|
import vuetify from 'vite-plugin-vuetify'
|
|
|
|
export default defineConfig({
|
|
plugins: [
|
|
vue(),
|
|
vuetify({ autoImport: true }),
|
|
],
|
|
server: {
|
|
proxy: {
|
|
'/api': {
|
|
target: 'http://localhost:5000',
|
|
changeOrigin: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 4: Configure main.ts with Vuetify and Pinia**
|
|
|
|
`src/AccountTracking.Web/src/main.ts`:
|
|
```ts
|
|
import { createApp } from 'vue'
|
|
import { createPinia } from 'pinia'
|
|
import { createVuetify } from 'vuetify'
|
|
import { aliases, mdi } from 'vuetify/iconsets/mdi'
|
|
import * as components from 'vuetify/components'
|
|
import * as directives from 'vuetify/directives'
|
|
import VueApexCharts from 'vue3-apexcharts'
|
|
import router from './router'
|
|
import App from './App.vue'
|
|
|
|
import 'vuetify/styles'
|
|
import '@mdi/font/css/materialdesignicons.css'
|
|
|
|
const vuetify = createVuetify({
|
|
components,
|
|
directives,
|
|
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
|
|
theme: {
|
|
defaultTheme: 'dark',
|
|
},
|
|
})
|
|
|
|
createApp(App)
|
|
.use(createPinia())
|
|
.use(router)
|
|
.use(vuetify)
|
|
.use(VueApexCharts)
|
|
.mount('#app')
|
|
```
|
|
|
|
- [ ] **Step 5: Create a minimal App.vue**
|
|
|
|
`src/AccountTracking.Web/src/App.vue`:
|
|
```vue
|
|
<template>
|
|
<v-app>
|
|
<router-view />
|
|
</v-app>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 6: Verify the dev server starts**
|
|
|
|
```bash
|
|
cd src/AccountTracking.Web
|
|
npm run dev
|
|
```
|
|
|
|
Expected: `VITE v5.x ready` with a local URL. Open it — should show a blank dark page. Stop with Ctrl+C.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
git add src/AccountTracking.Web/
|
|
git commit -m "feat: scaffold Vue 3 + Vuetify + Pinia frontend"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: NSwag TypeScript Client
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Web/nswag.json`
|
|
- Create (generated): `src/AccountTracking.Web/src/api/apiClient.ts`
|
|
- Create: `src/AccountTracking.Web/src/api/index.ts`
|
|
|
|
- [ ] **Step 1: Copy openapi.json to the web project**
|
|
|
|
```bash
|
|
cp src/AccountTracking.Api/openapi.json src/AccountTracking.Web/openapi.json
|
|
```
|
|
|
|
- [ ] **Step 2: Create nswag.json for TypeScript client generation**
|
|
|
|
`src/AccountTracking.Web/nswag.json`:
|
|
```json
|
|
{
|
|
"runtime": "Default",
|
|
"documentGenerator": {
|
|
"fromDocument": {
|
|
"url": "openapi.json"
|
|
}
|
|
},
|
|
"codeGenerators": {
|
|
"openApiToTypeScriptClient": {
|
|
"className": "{controller}Client",
|
|
"template": "Fetch",
|
|
"typeScriptVersion": 5.0,
|
|
"dateTimeType": "String",
|
|
"nullValue": "Undefined",
|
|
"generateClientClasses": true,
|
|
"generateDtoTypes": true,
|
|
"operationGenerationMode": "MultipleClientsFromOperationId",
|
|
"markOptionalProperties": true,
|
|
"typeStyle": "Interface",
|
|
"enumStyle": "Enum",
|
|
"output": "src/api/apiClient.ts"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Generate the TypeScript client**
|
|
|
|
```bash
|
|
cd src/AccountTracking.Web
|
|
npx nswag run nswag.json
|
|
```
|
|
|
|
Expected: `src/api/apiClient.ts` created.
|
|
|
|
- [ ] **Step 4: Create the authenticated API wrapper**
|
|
|
|
`src/AccountTracking.Web/src/api/index.ts`:
|
|
```ts
|
|
import { useRouter } from 'vue-router'
|
|
import { AuthClient, TransactionsClient, DashboardClient } from './apiClient'
|
|
|
|
// Custom fetch that injects the JWT and handles 401
|
|
class AuthFetch {
|
|
fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
|
const token = localStorage.getItem('token')
|
|
const headers = new Headers(init?.headers)
|
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
|
|
|
return fetch(url, { ...init, headers }).then((res) => {
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('token')
|
|
localStorage.removeItem('expiresAt')
|
|
// Navigate to login — import router lazily to avoid circular dep
|
|
import('../router').then(({ default: router }) => router.push('/login'))
|
|
}
|
|
return res
|
|
})
|
|
}
|
|
}
|
|
|
|
const httpClient = new AuthFetch()
|
|
|
|
// Base URL is empty: NSwag generates full paths like /api/auth/login from the OpenAPI spec.
|
|
// Verify after generation — if routes come out as /auth/login, change '' to '/api'.
|
|
export const authApi = new AuthClient('', httpClient)
|
|
export const transactionsApi = new TransactionsClient('', httpClient)
|
|
export const dashboardApi = new DashboardClient('', httpClient)
|
|
```
|
|
|
|
- [ ] **Step 5: Add generated file to .gitignore or commit**
|
|
|
|
The generated `apiClient.ts` should be committed (it's the contract between front and back). Add `openapi.json` to `.gitignore` in the web dir (it's a copy):
|
|
|
|
```bash
|
|
echo "openapi.json" >> src/AccountTracking.Web/.gitignore
|
|
```
|
|
|
|
- [ ] **Step 6: Verify TypeScript compiles**
|
|
|
|
```bash
|
|
cd src/AccountTracking.Web
|
|
npm run build
|
|
```
|
|
|
|
Expected: build succeeds (or type errors from placeholder views — fix those in the next tasks).
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
git add src/AccountTracking.Web/nswag.json \
|
|
src/AccountTracking.Web/src/api/ \
|
|
src/AccountTracking.Web/.gitignore
|
|
git commit -m "feat: generate NSwag TypeScript client and authenticated API wrapper"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Auth Store, Router Guard & Login Page
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Web/src/stores/auth.ts`
|
|
- Modify: `src/AccountTracking.Web/src/router/index.ts`
|
|
- Create: `src/AccountTracking.Web/src/views/LoginView.vue`
|
|
|
|
- [ ] **Step 1: Create auth Pinia store**
|
|
|
|
`src/AccountTracking.Web/src/stores/auth.ts`:
|
|
```ts
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import { authApi } from '../api'
|
|
|
|
export const useAuthStore = defineStore('auth', () => {
|
|
const token = ref<string | null>(localStorage.getItem('token'))
|
|
const expiresAt = ref<string | null>(localStorage.getItem('expiresAt'))
|
|
|
|
const isAuthenticated = computed(() => {
|
|
if (!token.value || !expiresAt.value) return false
|
|
return Date.now() < Date.parse(expiresAt.value)
|
|
})
|
|
|
|
async function login(username: string, password: string): Promise<void> {
|
|
const response = await authApi.login({ username, password })
|
|
token.value = response.token ?? null
|
|
expiresAt.value = response.expiresAt ?? null
|
|
if (token.value) localStorage.setItem('token', token.value)
|
|
if (expiresAt.value) localStorage.setItem('expiresAt', expiresAt.value)
|
|
}
|
|
|
|
function logout() {
|
|
token.value = null
|
|
expiresAt.value = null
|
|
localStorage.removeItem('token')
|
|
localStorage.removeItem('expiresAt')
|
|
}
|
|
|
|
return { token, isAuthenticated, login, logout }
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Update router with navigation guard**
|
|
|
|
`src/AccountTracking.Web/src/router/index.ts`:
|
|
```ts
|
|
import { createRouter, createWebHistory } from 'vue-router'
|
|
import { useAuthStore } from '../stores/auth'
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(),
|
|
routes: [
|
|
{ path: '/login', component: () => import('../views/LoginView.vue'), meta: { public: true } },
|
|
{ path: '/', component: () => import('../views/DashboardView.vue') },
|
|
{ path: '/transactions', component: () => import('../views/TransactionsView.vue') },
|
|
],
|
|
})
|
|
|
|
router.beforeEach((to) => {
|
|
if (to.meta.public) return true
|
|
const auth = useAuthStore()
|
|
if (!auth.isAuthenticated) return '/login'
|
|
return true
|
|
})
|
|
|
|
export default router
|
|
```
|
|
|
|
- [ ] **Step 3: Create LoginView**
|
|
|
|
`src/AccountTracking.Web/src/views/LoginView.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useAuthStore } from '../stores/auth'
|
|
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
|
|
const username = ref('')
|
|
const password = ref('')
|
|
const error = ref('')
|
|
const loading = ref(false)
|
|
|
|
async function submit() {
|
|
error.value = ''
|
|
loading.value = true
|
|
try {
|
|
await auth.login(username.value, password.value)
|
|
router.push('/')
|
|
} catch {
|
|
error.value = 'Nesprávné přihlašovací údaje.'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<v-container class="fill-height" fluid>
|
|
<v-row align="center" justify="center">
|
|
<v-col cols="12" sm="8" md="4">
|
|
<v-card>
|
|
<v-card-title class="text-h5 pa-6">Finance Tracker</v-card-title>
|
|
<v-card-text>
|
|
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
|
|
<v-text-field
|
|
v-model="username"
|
|
label="Uživatelské jméno"
|
|
prepend-inner-icon="mdi-account"
|
|
@keyup.enter="submit"
|
|
/>
|
|
<v-text-field
|
|
v-model="password"
|
|
label="Heslo"
|
|
type="password"
|
|
prepend-inner-icon="mdi-lock"
|
|
@keyup.enter="submit"
|
|
/>
|
|
</v-card-text>
|
|
<v-card-actions class="pa-6 pt-0">
|
|
<v-btn
|
|
block
|
|
color="primary"
|
|
size="large"
|
|
:loading="loading"
|
|
@click="submit"
|
|
>
|
|
Přihlásit se
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 4: Create placeholder views so the app compiles**
|
|
|
|
`src/AccountTracking.Web/src/views/DashboardView.vue`:
|
|
```vue
|
|
<template><div>Dashboard</div></template>
|
|
```
|
|
|
|
`src/AccountTracking.Web/src/views/TransactionsView.vue`:
|
|
```vue
|
|
<template><div>Transactions</div></template>
|
|
```
|
|
|
|
- [ ] **Step 5: Build and verify**
|
|
|
|
```bash
|
|
cd src/AccountTracking.Web && npm run build
|
|
```
|
|
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
git add src/AccountTracking.Web/src/stores/auth.ts \
|
|
src/AccountTracking.Web/src/router/index.ts \
|
|
src/AccountTracking.Web/src/views/
|
|
git commit -m "feat: auth store, router guard and login page"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Dashboard Page
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Web/src/stores/dashPeriod.ts`
|
|
- Replace: `src/AccountTracking.Web/src/views/DashboardView.vue`
|
|
- Create: `src/AccountTracking.Web/src/components/YearSummary.vue`
|
|
- Create: `src/AccountTracking.Web/src/components/MonthSummary.vue`
|
|
- Create: `src/AccountTracking.Web/src/components/DonutChart.vue`
|
|
- Create: `src/AccountTracking.Web/src/components/MonthlyBalancesChart.vue`
|
|
- Create: `src/AccountTracking.Web/src/components/CumulativeSpendingChart.vue`
|
|
- Create: `src/AccountTracking.Web/src/components/UploadCsvButton.vue`
|
|
|
|
- [ ] **Step 1: Create dashPeriod store**
|
|
|
|
`src/AccountTracking.Web/src/stores/dashPeriod.ts`:
|
|
```ts
|
|
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
|
|
export const useDashPeriodStore = defineStore('dashPeriod', () => {
|
|
const now = new Date()
|
|
const year = ref(now.getFullYear())
|
|
const month = ref(now.getMonth() + 1) // 1-based
|
|
|
|
const monthLabel = computed(() => {
|
|
const d = new Date(year.value, month.value - 1, 1)
|
|
return d.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
|
|
})
|
|
|
|
function prevMonth() {
|
|
if (month.value === 1) { month.value = 12; year.value-- }
|
|
else month.value--
|
|
}
|
|
|
|
function nextMonth() {
|
|
if (month.value === 12) { month.value = 1; year.value++ }
|
|
else month.value++
|
|
}
|
|
|
|
function prevYear() { year.value-- }
|
|
function nextYear() { year.value++ }
|
|
|
|
return { year, month, monthLabel, prevMonth, nextMonth, prevYear, nextYear }
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Create UploadCsvButton component**
|
|
|
|
`src/AccountTracking.Web/src/components/UploadCsvButton.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import { transactionsApi } from '../api'
|
|
|
|
const fileInput = ref<HTMLInputElement | null>(null)
|
|
const snackbar = ref(false)
|
|
const snackbarText = ref('')
|
|
const snackbarColor = ref('success')
|
|
const loading = ref(false)
|
|
|
|
function openPicker() {
|
|
fileInput.value?.click()
|
|
}
|
|
|
|
async function onFileSelected(event: Event) {
|
|
const file = (event.target as HTMLInputElement).files?.[0]
|
|
if (!file) return
|
|
|
|
loading.value = true
|
|
try {
|
|
const result = await transactionsApi.import({ fileName: file.name, data: file })
|
|
snackbarText.value = `Importováno ${result.recordsImported}, přeskočeno ${result.recordsSkipped}.`
|
|
snackbarColor.value = 'success'
|
|
} catch (e: any) {
|
|
const body = await e?.response?.json().catch(() => null)
|
|
snackbarText.value = body?.error ?? 'Chyba při importu.'
|
|
snackbarColor.value = 'error'
|
|
} finally {
|
|
loading.value = false
|
|
snackbar.value = true
|
|
if (fileInput.value) fileInput.value.value = ''
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
accept=".csv"
|
|
style="display:none"
|
|
@change="onFileSelected"
|
|
/>
|
|
<v-btn
|
|
prepend-icon="mdi-upload"
|
|
color="primary"
|
|
variant="outlined"
|
|
:loading="loading"
|
|
@click="openPicker"
|
|
>
|
|
Nahrát CSV
|
|
</v-btn>
|
|
<v-snackbar v-model="snackbar" :color="snackbarColor" timeout="4000">
|
|
{{ snackbarText }}
|
|
</v-snackbar>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 3: Create DonutChart component**
|
|
|
|
`src/AccountTracking.Web/src/components/DonutChart.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { CategorySpendingDto } from '../api/apiClient'
|
|
|
|
const props = defineProps<{
|
|
data: CategorySpendingDto[]
|
|
loading: boolean
|
|
}>()
|
|
|
|
const chartOptions = computed(() => ({
|
|
chart: { type: 'donut', background: 'transparent' },
|
|
labels: props.data.map(d => d.category ?? 'Nezatříděno'),
|
|
theme: { mode: 'dark' },
|
|
legend: { show: false },
|
|
dataLabels: { enabled: false },
|
|
tooltip: {
|
|
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
|
}
|
|
}))
|
|
|
|
const series = computed(() => props.data.map(d => d.total ?? 0))
|
|
|
|
const legendItems = computed(() =>
|
|
props.data.map((d, i) => ({
|
|
label: d.category ?? 'Nezatříděno',
|
|
total: d.total ?? 0,
|
|
color: getColor(i),
|
|
}))
|
|
)
|
|
|
|
function getColor(i: number) {
|
|
const colors = ['#ef5350','#42a5f5','#66bb6a','#ffa726','#ab47bc',
|
|
'#26c6da','#d4e157','#ff7043','#8d6e63','#78909c']
|
|
return colors[i % colors.length]
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="loading">
|
|
<v-skeleton-loader type="image" />
|
|
</div>
|
|
<div v-else-if="data.length === 0" class="text-center text-medium-emphasis py-4">
|
|
Žádné výdaje
|
|
</div>
|
|
<div v-else>
|
|
<apexchart type="donut" :options="chartOptions" :series="series" height="200" />
|
|
<div class="mt-2">
|
|
<div
|
|
v-for="item in legendItems"
|
|
:key="item.label"
|
|
class="d-flex align-center gap-2 mb-1"
|
|
>
|
|
<div :style="{ width: '10px', height: '10px', borderRadius: '50%', background: item.color, flexShrink: 0 }" />
|
|
<span class="text-body-2 flex-grow-1">{{ item.label }}</span>
|
|
<span class="text-body-2 text-medium-emphasis">{{ item.total.toLocaleString('cs-CZ') }} Kč</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 4: Create MonthlyBalancesChart component**
|
|
|
|
`src/AccountTracking.Web/src/components/MonthlyBalancesChart.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { MonthlyBalanceDto } from '../api/apiClient'
|
|
|
|
const props = defineProps<{
|
|
data: MonthlyBalanceDto[]
|
|
loading: boolean
|
|
}>()
|
|
|
|
const MONTH_NAMES = ['Led','Úno','Bře','Dub','Kvě','Čvn','Čvc','Srp','Zář','Říj','Lis','Pro']
|
|
|
|
const chartOptions = computed(() => ({
|
|
chart: { type: 'bar', background: 'transparent', toolbar: { show: false } },
|
|
xaxis: { categories: props.data.map(d => MONTH_NAMES[(d.month ?? 1) - 1]) },
|
|
theme: { mode: 'dark' },
|
|
dataLabels: { enabled: false },
|
|
tooltip: {
|
|
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
|
},
|
|
colors: ['#42a5f5'],
|
|
}))
|
|
|
|
const series = computed(() => [{
|
|
name: 'Zůstatek',
|
|
data: props.data.map(d => d.closingBalance ?? 0)
|
|
}])
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="loading"><v-skeleton-loader type="image" /></div>
|
|
<apexchart v-else type="bar" :options="chartOptions" :series="series" height="220" />
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 5: Create CumulativeSpendingChart component**
|
|
|
|
`src/AccountTracking.Web/src/components/CumulativeSpendingChart.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { CumulativeSpendingDto } from '../api/apiClient'
|
|
|
|
const props = defineProps<{
|
|
data: CumulativeSpendingDto[]
|
|
loading: boolean
|
|
}>()
|
|
|
|
const chartOptions = computed(() => ({
|
|
chart: { type: 'line', background: 'transparent', toolbar: { show: false } },
|
|
xaxis: { categories: props.data.map(d => `${d.day}.`) },
|
|
theme: { mode: 'dark' },
|
|
stroke: { curve: 'smooth', width: 2 },
|
|
dataLabels: { enabled: false },
|
|
tooltip: {
|
|
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
|
},
|
|
colors: ['#ef5350'],
|
|
}))
|
|
|
|
const series = computed(() => [{
|
|
name: 'Kumulativní výdaje',
|
|
data: props.data.map(d => d.cumulativeSpent ?? 0)
|
|
}])
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="loading"><v-skeleton-loader type="image" /></div>
|
|
<div v-else-if="data.length === 0" class="text-center text-medium-emphasis py-4">Žádné výdaje</div>
|
|
<apexchart v-else type="line" :options="chartOptions" :series="series" height="220" />
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 6: Create YearSummary component**
|
|
|
|
`src/AccountTracking.Web/src/components/YearSummary.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { ref, watch } from 'vue'
|
|
import { useDashPeriodStore } from '../stores/dashPeriod'
|
|
import { dashboardApi } from '../api'
|
|
import type { CategorySpendingDto, MonthlyBalanceDto, SummaryDto } from '../api/apiClient'
|
|
import DonutChart from './DonutChart.vue'
|
|
import MonthlyBalancesChart from './MonthlyBalancesChart.vue'
|
|
|
|
const period = useDashPeriodStore()
|
|
|
|
const summary = ref<SummaryDto | null>(null)
|
|
const categories = ref<CategorySpendingDto[]>([])
|
|
const balances = ref<MonthlyBalanceDto[]>([])
|
|
const loadingSummary = ref(false)
|
|
const loadingCategories = ref(false)
|
|
const loadingBalances = ref(false)
|
|
|
|
async function load() {
|
|
loadingSummary.value = true
|
|
loadingCategories.value = true
|
|
loadingBalances.value = true
|
|
try {
|
|
const [s, c, b] = await Promise.all([
|
|
dashboardApi.summary(period.year, undefined),
|
|
dashboardApi.spendingByCategory(period.year, undefined),
|
|
dashboardApi.monthlyBalances(period.year),
|
|
])
|
|
summary.value = s
|
|
categories.value = c
|
|
balances.value = b
|
|
} finally {
|
|
loadingSummary.value = false
|
|
loadingCategories.value = false
|
|
loadingBalances.value = false
|
|
}
|
|
}
|
|
|
|
watch(() => period.year, load, { immediate: true })
|
|
</script>
|
|
|
|
<template>
|
|
<v-card height="100%">
|
|
<v-card-title class="d-flex align-center pa-3">
|
|
<v-btn icon="mdi-chevron-left" variant="text" @click="period.prevYear" />
|
|
<span class="flex-grow-1 text-center text-h6">{{ period.year }}</span>
|
|
<v-btn icon="mdi-chevron-right" variant="text" @click="period.nextYear" />
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<v-skeleton-loader v-if="loadingSummary" type="text,text" />
|
|
<v-row v-else class="mb-4">
|
|
<v-col cols="6" class="text-center">
|
|
<div class="text-caption text-medium-emphasis">VÝDAJE</div>
|
|
<div class="text-h6 text-error">{{ summary?.totalSpent.toLocaleString('cs-CZ') }} Kč</div>
|
|
</v-col>
|
|
<v-col cols="6" class="text-center">
|
|
<div class="text-caption text-medium-emphasis">PŘÍJMY</div>
|
|
<div class="text-h6 text-success">{{ summary?.totalIncome.toLocaleString('cs-CZ') }} Kč</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-row>
|
|
<v-col cols="12" md="5">
|
|
<div class="text-caption text-medium-emphasis mb-1">VÝDAJE DLE KATEGORIÍ</div>
|
|
<DonutChart :data="categories" :loading="loadingCategories" />
|
|
</v-col>
|
|
<v-col cols="12" md="7">
|
|
<div class="text-caption text-medium-emphasis mb-1">MĚSÍČNÍ ZŮSTATKY</div>
|
|
<MonthlyBalancesChart :data="balances" :loading="loadingBalances" />
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 7: Create MonthSummary component**
|
|
|
|
`src/AccountTracking.Web/src/components/MonthSummary.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { ref, watch } from 'vue'
|
|
import { useDashPeriodStore } from '../stores/dashPeriod'
|
|
import { dashboardApi } from '../api'
|
|
import type { CategorySpendingDto, CumulativeSpendingDto, SummaryDto } from '../api/apiClient'
|
|
import DonutChart from './DonutChart.vue'
|
|
import CumulativeSpendingChart from './CumulativeSpendingChart.vue'
|
|
|
|
const period = useDashPeriodStore()
|
|
|
|
const summary = ref<SummaryDto | null>(null)
|
|
const categories = ref<CategorySpendingDto[]>([])
|
|
const cumulative = ref<CumulativeSpendingDto[]>([])
|
|
const loadingSummary = ref(false)
|
|
const loadingCategories = ref(false)
|
|
const loadingCumulative = ref(false)
|
|
|
|
async function load() {
|
|
loadingSummary.value = true
|
|
loadingCategories.value = true
|
|
loadingCumulative.value = true
|
|
try {
|
|
const [s, c, cu] = await Promise.all([
|
|
dashboardApi.summary(period.year, period.month),
|
|
dashboardApi.spendingByCategory(period.year, period.month),
|
|
dashboardApi.cumulativeSpending(period.year, period.month),
|
|
])
|
|
summary.value = s
|
|
categories.value = c
|
|
cumulative.value = cu
|
|
} finally {
|
|
loadingSummary.value = false
|
|
loadingCategories.value = false
|
|
loadingCumulative.value = false
|
|
}
|
|
}
|
|
|
|
watch([() => period.year, () => period.month], load, { immediate: true })
|
|
</script>
|
|
|
|
<template>
|
|
<v-card height="100%">
|
|
<v-card-title class="d-flex align-center pa-3">
|
|
<v-btn icon="mdi-chevron-left" variant="text" @click="period.prevMonth" />
|
|
<span class="flex-grow-1 text-center text-h6">{{ period.monthLabel }}</span>
|
|
<v-btn icon="mdi-chevron-right" variant="text" @click="period.nextMonth" />
|
|
</v-card-title>
|
|
|
|
<v-card-text>
|
|
<v-skeleton-loader v-if="loadingSummary" type="text,text" />
|
|
<v-row v-else class="mb-4">
|
|
<v-col cols="6" class="text-center">
|
|
<div class="text-caption text-medium-emphasis">VÝDAJE</div>
|
|
<div class="text-h6 text-error">{{ summary?.totalSpent.toLocaleString('cs-CZ') }} Kč</div>
|
|
</v-col>
|
|
<v-col cols="6" class="text-center">
|
|
<div class="text-caption text-medium-emphasis">PŘÍJMY</div>
|
|
<div class="text-h6 text-success">{{ summary?.totalIncome.toLocaleString('cs-CZ') }} Kč</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-row>
|
|
<v-col cols="12" md="5">
|
|
<div class="text-caption text-medium-emphasis mb-1">VÝDAJE DLE KATEGORIÍ</div>
|
|
<DonutChart :data="categories" :loading="loadingCategories" />
|
|
</v-col>
|
|
<v-col cols="12" md="7">
|
|
<div class="text-caption text-medium-emphasis mb-1">KUMULATIVNÍ VÝDAJE</div>
|
|
<CumulativeSpendingChart :data="cumulative" :loading="loadingCumulative" />
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 8: Create DashboardView**
|
|
|
|
`src/AccountTracking.Web/src/views/DashboardView.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { useRouter } from 'vue-router'
|
|
import YearSummary from '../components/YearSummary.vue'
|
|
import MonthSummary from '../components/MonthSummary.vue'
|
|
import UploadCsvButton from '../components/UploadCsvButton.vue'
|
|
|
|
const auth = useAuthStore()
|
|
const router = useRouter()
|
|
|
|
function logout() {
|
|
auth.logout()
|
|
router.push('/login')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<v-app-bar>
|
|
<v-app-bar-title>Finance Tracker</v-app-bar-title>
|
|
<template #append>
|
|
<UploadCsvButton class="mr-2" />
|
|
<v-btn icon="mdi-logout" variant="text" @click="logout" />
|
|
</template>
|
|
</v-app-bar>
|
|
|
|
<v-main>
|
|
<v-container fluid class="pa-4">
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<YearSummary />
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<MonthSummary />
|
|
</v-col>
|
|
</v-row>
|
|
</v-container>
|
|
</v-main>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 9: Build and verify**
|
|
|
|
```bash
|
|
cd src/AccountTracking.Web && npm run build
|
|
```
|
|
|
|
Expected: build succeeds with no type errors.
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
git add src/AccountTracking.Web/src/
|
|
git commit -m "feat: dashboard page with year/month summary, charts and CSV upload"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Transactions Page
|
|
|
|
**Files:**
|
|
- Replace: `src/AccountTracking.Web/src/views/TransactionsView.vue`
|
|
- Create: `src/AccountTracking.Web/src/stores/txPeriod.ts`
|
|
|
|
- [ ] **Step 1: Create txPeriod store**
|
|
|
|
`src/AccountTracking.Web/src/stores/txPeriod.ts`:
|
|
```ts
|
|
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
|
|
export const useTxPeriodStore = defineStore('txPeriod', () => {
|
|
const now = new Date()
|
|
const year = ref(now.getFullYear())
|
|
const month = ref<number | null>(null)
|
|
|
|
return { year, month }
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Create TransactionsView**
|
|
|
|
`src/AccountTracking.Web/src/views/TransactionsView.vue`:
|
|
```vue
|
|
<script setup lang="ts">
|
|
import { ref, watch, computed } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { transactionsApi } from '../api'
|
|
import { useAuthStore } from '../stores/auth'
|
|
import { useTxPeriodStore } from '../stores/txPeriod'
|
|
import type { TransactionDto } from '../api/apiClient'
|
|
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
const period = useTxPeriodStore()
|
|
|
|
const items = ref<TransactionDto[]>([])
|
|
const totalCount = ref(0)
|
|
const page = ref(1)
|
|
const loading = ref(false)
|
|
const categories = ref<string[]>([])
|
|
const selectedCategory = ref<string | null>(null)
|
|
const search = ref('')
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const MONTHS = [
|
|
{ title: 'Vše', value: null },
|
|
...Array.from({ length: 12 }, (_, i) => ({
|
|
title: new Date(2000, i, 1).toLocaleDateString('cs-CZ', { month: 'long' }),
|
|
value: i + 1
|
|
}))
|
|
]
|
|
|
|
const YEARS = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
|
|
|
|
const headers = [
|
|
{ title: 'Datum', key: 'bookingDate', sortable: false },
|
|
{ title: 'Protiúčet / Zpráva', key: 'counterPartyName', sortable: false },
|
|
{ title: 'Kategorie', key: 'category', sortable: false },
|
|
{ title: 'Částka', key: 'amount', sortable: false, align: 'end' as const },
|
|
{ title: 'Zůstatek', key: 'balance', sortable: false, align: 'end' as const },
|
|
]
|
|
|
|
async function loadCategories() {
|
|
categories.value = await transactionsApi.categories()
|
|
}
|
|
|
|
async function loadTransactions() {
|
|
loading.value = true
|
|
try {
|
|
const result = await transactionsApi.list(
|
|
period.year,
|
|
period.month ?? undefined,
|
|
selectedCategory.value ?? undefined,
|
|
search.value || undefined,
|
|
page.value,
|
|
50
|
|
)
|
|
items.value = result.items ?? []
|
|
totalCount.value = result.totalCount ?? 0
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function onSearchInput() {
|
|
if (searchTimeout) clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => { page.value = 1; loadTransactions() }, 300)
|
|
}
|
|
|
|
watch([() => period.year, () => period.month, selectedCategory, page], loadTransactions)
|
|
|
|
loadCategories()
|
|
loadTransactions()
|
|
|
|
function logout() {
|
|
auth.logout()
|
|
router.push('/login')
|
|
}
|
|
|
|
function formatAmount(val: number) {
|
|
return `${val.toLocaleString('cs-CZ')} Kč`
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<v-app-bar>
|
|
<v-app-bar-title>Finance Tracker — Transakce</v-app-bar-title>
|
|
<template #append>
|
|
<v-btn to="/" variant="text" class="mr-2">Dashboard</v-btn>
|
|
<v-btn icon="mdi-logout" variant="text" @click="logout" />
|
|
</template>
|
|
</v-app-bar>
|
|
|
|
<v-main>
|
|
<v-container fluid class="pa-4">
|
|
<v-row class="mb-2">
|
|
<v-col cols="6" md="2">
|
|
<v-select
|
|
v-model="period.year"
|
|
:items="YEARS"
|
|
label="Rok"
|
|
density="compact"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="6" md="2">
|
|
<v-select
|
|
v-model="period.month"
|
|
:items="MONTHS"
|
|
item-title="title"
|
|
item-value="value"
|
|
label="Měsíc"
|
|
density="compact"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="selectedCategory"
|
|
:items="[{ title: 'Vše', value: null }, ...categories.map(c => ({ title: c, value: c }))]"
|
|
item-title="title"
|
|
item-value="value"
|
|
label="Kategorie"
|
|
density="compact"
|
|
clearable
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="5">
|
|
<v-text-field
|
|
v-model="search"
|
|
label="Hledat"
|
|
prepend-inner-icon="mdi-magnify"
|
|
density="compact"
|
|
clearable
|
|
@input="onSearchInput"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-data-table-server
|
|
:headers="headers"
|
|
:items="items"
|
|
:items-length="totalCount"
|
|
:loading="loading"
|
|
:items-per-page="50"
|
|
v-model:page="page"
|
|
@update:options="loadTransactions"
|
|
>
|
|
<template #item.bookingDate="{ item }">
|
|
{{ item.bookingDate }}
|
|
</template>
|
|
<template #item.counterPartyName="{ item }">
|
|
<div>{{ item.counterPartyName }}</div>
|
|
<div class="text-caption text-medium-emphasis">{{ item.message }}</div>
|
|
</template>
|
|
<template #item.amount="{ item }">
|
|
<span :class="item.amount < 0 ? 'text-error' : 'text-success'">
|
|
{{ formatAmount(item.amount) }}
|
|
</span>
|
|
</template>
|
|
<template #item.balance="{ item }">
|
|
{{ formatAmount(item.balance) }}
|
|
</template>
|
|
</v-data-table-server>
|
|
</v-container>
|
|
</v-main>
|
|
</template>
|
|
```
|
|
|
|
- [ ] **Step 3: Add nav link from Dashboard to Transactions**
|
|
|
|
In `DashboardView.vue`, add a navigation button next to logout:
|
|
```vue
|
|
<!-- in the <template #append> of v-app-bar -->
|
|
<v-btn to="/transactions" variant="text" class="mr-2">Transakce</v-btn>
|
|
```
|
|
|
|
- [ ] **Step 4: Build and verify**
|
|
|
|
```bash
|
|
cd src/AccountTracking.Web && npm run build
|
|
```
|
|
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /mnt/c/Repos/Personal/account-tracking
|
|
git add src/AccountTracking.Web/src/views/TransactionsView.vue \
|
|
src/AccountTracking.Web/src/stores/txPeriod.ts \
|
|
src/AccountTracking.Web/src/views/DashboardView.vue
|
|
git commit -m "feat: transactions page with server-side filtering and pagination"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Docker & Deployment
|
|
|
|
**Files:**
|
|
- Create: `src/AccountTracking.Api/Dockerfile`
|
|
- Create: `src/AccountTracking.Web/Dockerfile`
|
|
- Create: `src/AccountTracking.Web/nginx.conf`
|
|
- Create: `docker-compose.yml`
|
|
- Create: `.env.example`
|
|
|
|
- [ ] **Step 1: Create API Dockerfile**
|
|
|
|
`src/AccountTracking.Api/Dockerfile`:
|
|
```dockerfile
|
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
|
WORKDIR /src
|
|
COPY AccountTracking.Api.csproj .
|
|
RUN dotnet restore
|
|
COPY . .
|
|
RUN dotnet publish -c Release -o /app/publish
|
|
|
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
|
|
WORKDIR /app
|
|
COPY --from=build /app/publish .
|
|
EXPOSE 5000
|
|
ENV ASPNETCORE_URLS=http://+:5000
|
|
ENTRYPOINT ["dotnet", "AccountTracking.Api.dll"]
|
|
```
|
|
|
|
- [ ] **Step 2: Create web Nginx config**
|
|
|
|
`src/AccountTracking.Web/nginx.conf`:
|
|
```nginx
|
|
server {
|
|
listen 3000;
|
|
root /usr/share/nginx/html;
|
|
index index.html;
|
|
|
|
location / {
|
|
try_files $uri $uri/ /index.html;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Create web Dockerfile**
|
|
|
|
`src/AccountTracking.Web/Dockerfile`:
|
|
```dockerfile
|
|
# Stage 1: build .NET API to get openapi.json
|
|
# Build context is repo root (set in docker-compose.yml), so paths are relative to repo root
|
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS api-build
|
|
WORKDIR /src
|
|
COPY src/AccountTracking.Api/AccountTracking.Api.csproj ./AccountTracking.Api/
|
|
RUN dotnet restore AccountTracking.Api/AccountTracking.Api.csproj
|
|
COPY src/AccountTracking.Api/ ./AccountTracking.Api/
|
|
RUN dotnet build AccountTracking.Api/AccountTracking.Api.csproj -c Release
|
|
|
|
# Stage 2: build Vue frontend with NSwag client
|
|
FROM node:20-alpine AS web-build
|
|
WORKDIR /app
|
|
COPY package*.json ./
|
|
RUN npm ci
|
|
COPY --from=api-build /src/AccountTracking.Api/openapi.json ./openapi.json
|
|
COPY . .
|
|
RUN npx nswag run nswag.json
|
|
RUN npm run build
|
|
|
|
# Stage 3: serve with Nginx
|
|
FROM nginx:alpine
|
|
COPY --from=web-build /app/dist /usr/share/nginx/html
|
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
```
|
|
|
|
Note: the web Dockerfile is built from the repo root (so it can COPY from the sibling Api project). The `docker-compose.yml` sets `context` accordingly.
|
|
|
|
- [ ] **Step 4: Create docker-compose.yml**
|
|
|
|
`docker-compose.yml`:
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
services:
|
|
db:
|
|
image: mysql:8
|
|
environment:
|
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
|
MYSQL_DATABASE: accounttracking
|
|
volumes:
|
|
- db_data:/var/lib/mysql
|
|
healthcheck:
|
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 30s
|
|
restart: unless-stopped
|
|
|
|
api:
|
|
build:
|
|
context: src/AccountTracking.Api
|
|
dockerfile: Dockerfile
|
|
environment:
|
|
DB_CONNECTION: "Server=db;Database=accounttracking;User=root;Password=${DB_ROOT_PASSWORD};"
|
|
JWT_SECRET: ${JWT_SECRET}
|
|
APP_USERNAME: ${APP_USERNAME}
|
|
APP_PASSWORD: ${APP_PASSWORD}
|
|
ALLOWED_ORIGIN: ${ALLOWED_ORIGIN}
|
|
ports:
|
|
- "5000:5000"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
restart: unless-stopped
|
|
|
|
web:
|
|
build:
|
|
context: .
|
|
dockerfile: src/AccountTracking.Web/Dockerfile
|
|
ports:
|
|
- "3000:3000"
|
|
restart: unless-stopped
|
|
|
|
volumes:
|
|
db_data:
|
|
```
|
|
|
|
- [ ] **Step 5: Create .env.example**
|
|
|
|
`.env.example`:
|
|
```
|
|
# MySQL root password (used by both db and api services)
|
|
DB_ROOT_PASSWORD=changeme
|
|
|
|
# JWT signing secret: generate with: openssl rand -base64 32
|
|
JWT_SECRET=CHANGE_ME_base64_encoded_32_bytes
|
|
|
|
# Single-user login credentials
|
|
APP_USERNAME=admin
|
|
# bcrypt hash of your password — generate with: htpasswd -bnBC 10 "" yourpassword | tr -d ':'
|
|
APP_PASSWORD=$2b$10$CHANGE_ME_bcrypt_hash
|
|
|
|
# Frontend origin for CORS (your home server URL)
|
|
ALLOWED_ORIGIN=http://finance.home
|
|
```
|
|
|
|
- [ ] **Step 6: Create actual .env from the example**
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
# Edit .env with your real values:
|
|
# 1. Generate JWT_SECRET: openssl rand -base64 32
|
|
# 2. Generate APP_PASSWORD bcrypt hash (e.g. with a small Node script or Python):
|
|
# node -e "const b=require('bcryptjs');console.log(b.hashSync('yourpassword',10))"
|
|
```
|
|
|
|
- [ ] **Step 7: Build and start all services**
|
|
|
|
```bash
|
|
docker compose build
|
|
docker compose up -d
|
|
docker compose logs -f api
|
|
```
|
|
|
|
Expected: API logs show "Now listening on: http://[::]:5000" after migrations run.
|
|
|
|
- [ ] **Step 8: Verify the app works end-to-end**
|
|
|
|
```bash
|
|
# Test login
|
|
curl -s -X POST http://localhost:5000/api/auth/login \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"username":"admin","password":"yourpassword"}' | jq .
|
|
```
|
|
|
|
Expected: `{"token":"eyJ...","expiresAt":"..."}`.
|
|
|
|
Open `http://localhost:3000` in a browser — should show the login page.
|
|
|
|
- [ ] **Step 9: Add .env to .gitignore (if not already)**
|
|
|
|
```bash
|
|
grep -q '^\.env$' .gitignore || echo '.env' >> .gitignore
|
|
```
|
|
|
|
- [ ] **Step 10: Commit**
|
|
|
|
```bash
|
|
git add src/AccountTracking.Api/Dockerfile \
|
|
src/AccountTracking.Web/Dockerfile \
|
|
src/AccountTracking.Web/nginx.conf \
|
|
docker-compose.yml \
|
|
.env.example \
|
|
.gitignore
|
|
git commit -m "feat: Docker build and docker-compose deployment config"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Task | Deliverable |
|
|
|------|-------------|
|
|
| 1 | .NET solution, projects, NuGet packages |
|
|
| 2 | EF Core entities and DTOs |
|
|
| 3 | Program.cs with auth, CORS, DB, Polly |
|
|
| 4 | JWT auth endpoint |
|
|
| 5 | CSV import service (TDD — 8 tests) |
|
|
| 6 | Transactions REST API |
|
|
| 7 | Dashboard service + controller (TDD — 9 tests) |
|
|
| 8 | EF Core migrations |
|
|
| 9 | NSwag OpenAPI spec generation |
|
|
| 10 | Vue 3 + Vuetify frontend scaffold |
|
|
| 11 | NSwag TypeScript client + authenticated wrapper |
|
|
| 12 | Auth store, router guard, login page |
|
|
| 13 | Dashboard page (period store, year/month columns, charts, CSV upload) |
|
|
| 14 | Transactions page |
|
|
| 15 | Dockerfiles, docker-compose, .env |
|