Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
e14e552388
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
.vs/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# ASP.NET
|
||||||
|
appsettings.Development.json
|
||||||
|
|
||||||
|
# Published output
|
||||||
|
publish/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# NuGet
|
||||||
|
*.nupkg
|
||||||
|
*.snupkg
|
||||||
|
packages/
|
||||||
|
!**/packages/build/
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
|
||||||
|
# Rider/JetBrains
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
# Node / Vue frontend
|
||||||
|
BudgetApp.Web/node_modules/
|
||||||
|
BudgetApp.Web/dist/
|
||||||
|
BudgetApp.Web/dist-ssr/
|
||||||
|
BudgetApp.Web/coverage/
|
||||||
|
BudgetApp.Web/*.local
|
||||||
|
BudgetApp.Web/.eslintcache
|
||||||
|
BudgetApp.Web/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment secrets
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
.env.local
|
||||||
33
BudgetApp.Api/BudgetApp.Api.csproj
Normal file
33
BudgetApp.Api/BudgetApp.Api.csproj
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" 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.Tools" Version="8.0.10">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\BudgetApp.Services\BudgetApp.Services.csproj" />
|
||||||
|
<ProjectReference Include="..\BudgetApp.Storage\BudgetApp.Storage.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
6
BudgetApp.Api/BudgetApp.Api.http
Normal file
6
BudgetApp.Api/BudgetApp.Api.http
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@BudgetApp.Api_HostAddress = http://localhost:5240
|
||||||
|
|
||||||
|
GET {{BudgetApp.Api_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
||||||
24
BudgetApp.Api/BudgetApp.Api.sln
Normal file
24
BudgetApp.Api/BudgetApp.Api.sln
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.5.2.0
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BudgetApp.Api", "BudgetApp.Api.csproj", "{8E0F1440-3780-2695-ACE0-D9023FCF18C4}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{8E0F1440-3780-2695-ACE0-D9023FCF18C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{8E0F1440-3780-2695-ACE0-D9023FCF18C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{8E0F1440-3780-2695-ACE0-D9023FCF18C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{8E0F1440-3780-2695-ACE0-D9023FCF18C4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {9F3ACFAB-C6FB-4759-AF60-C7F8475BC4C4}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
20
BudgetApp.Api/Controllers/TransactionsController.cs
Normal file
20
BudgetApp.Api/Controllers/TransactionsController.cs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
using BudgetApp.PublicModels;
|
||||||
|
using BudgetApp.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BudgetApp.Api.Controllers
|
||||||
|
{
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class TransactionsController(ITransactionService transactionService) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ITransactionService _transactionService = transactionService ?? throw new ArgumentNullException(nameof(transactionService));
|
||||||
|
|
||||||
|
[HttpGet("getTransactions")]
|
||||||
|
public async Task<ActionResult<ListResponse<YearSummaryDto>>> GetTransactions(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _transactionService.GetTransactions(cancellationToken).ConfigureAwait(false);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
BudgetApp.Api/Controllers/UploadController.cs
Normal file
54
BudgetApp.Api/Controllers/UploadController.cs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
using BudgetApp.PublicModels;
|
||||||
|
using BudgetApp.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BudgetApp.Api.Controllers
|
||||||
|
{
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class UploadController(IUploadService uploadService) : ControllerBase
|
||||||
|
{
|
||||||
|
|
||||||
|
private readonly IUploadService _uploadService = uploadService ?? throw new ArgumentNullException(nameof(uploadService));
|
||||||
|
|
||||||
|
// Adjust as needed (e.g., 50 MB)
|
||||||
|
private const long MaxFileSizeBytes = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Receives a CSV file via multipart/form-data.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("csv")]
|
||||||
|
[RequestSizeLimit(MaxFileSizeBytes)]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
public async Task<ActionResult<BaseResponse>> UploadCsv([FromForm] IFormFile file, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (file is null)
|
||||||
|
return BadRequest("Missing 'file' form field.");
|
||||||
|
|
||||||
|
if (file.Length == 0)
|
||||||
|
return BadRequest("The uploaded file is empty.");
|
||||||
|
|
||||||
|
if (file.Length > MaxFileSizeBytes)
|
||||||
|
return BadRequest($"File too large. Limit is {MaxFileSizeBytes} bytes.");
|
||||||
|
|
||||||
|
// Light validation – content types for CSV vary by client/OS
|
||||||
|
var allowedContentTypes = new[]
|
||||||
|
{
|
||||||
|
"text/csv",
|
||||||
|
"application/csv",
|
||||||
|
"text/plain",
|
||||||
|
"application/vnd.ms-excel"
|
||||||
|
};
|
||||||
|
var hasCsvLikeContentType = allowedContentTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase);
|
||||||
|
var hasCsvExtension = Path.GetExtension(file.FileName).Equals(".csv", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!hasCsvLikeContentType && !hasCsvExtension)
|
||||||
|
return BadRequest("Only CSV files are accepted.");
|
||||||
|
|
||||||
|
var response = await _uploadService.UploadCsv(file, ct);
|
||||||
|
|
||||||
|
// Return whatever metadata/result you need
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
BudgetApp.Api/Program.cs
Normal file
49
BudgetApp.Api/Program.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
using BudgetApp.Services;
|
||||||
|
using BudgetApp.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
var connectionString = builder.Configuration.GetSection("ConnectionStrings").GetValue<string>("MainDatabase");
|
||||||
|
builder.Services.AddDbContext<BudgetContext>(options => options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IUploadService, UploadService>();
|
||||||
|
builder.Services.AddScoped<ITransactionService, TransactionService>();
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AllowAll", policy =>
|
||||||
|
{
|
||||||
|
policy
|
||||||
|
.AllowAnyOrigin()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapGet("/ping", () => Results.Ok(new { ok = true, time = DateTime.UtcNow }));
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
41
BudgetApp.Api/Properties/launchSettings.json
Normal file
41
BudgetApp.Api/Properties/launchSettings.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"iisSettings": {
|
||||||
|
"windowsAuthentication": false,
|
||||||
|
"anonymousAuthentication": true,
|
||||||
|
"iisExpress": {
|
||||||
|
"applicationUrl": "http://localhost:17516",
|
||||||
|
"sslPort": 44307
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"applicationUrl": "http://localhost:5240",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"applicationUrl": "https://localhost:7244;http://localhost:5240",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IIS Express": {
|
||||||
|
"commandName": "IISExpress",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
BudgetApp.Api/appsettings.json
Normal file
13
BudgetApp.Api/appsettings.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"MainDatabase": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
9
BudgetApp.Enums/BudgetApp.Enums.csproj
Normal file
9
BudgetApp.Enums/BudgetApp.Enums.csproj
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
8
BudgetApp.Enums/UpdateStatusState.cs
Normal file
8
BudgetApp.Enums/UpdateStatusState.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace BudgetApp.Enums
|
||||||
|
{
|
||||||
|
public enum UpdateStatusState
|
||||||
|
{
|
||||||
|
Processing = 0,
|
||||||
|
Done = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
19
BudgetApp.PublicModels/BaseResponse.cs
Normal file
19
BudgetApp.PublicModels/BaseResponse.cs
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
namespace BudgetApp.PublicModels
|
||||||
|
{
|
||||||
|
public class BaseResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; } = true;
|
||||||
|
public string Error { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DataResponse<T> : BaseResponse
|
||||||
|
{
|
||||||
|
public T? Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ListResponse<T> : BaseResponse
|
||||||
|
{
|
||||||
|
public List<T> Data { get; set; } = [];
|
||||||
|
public int Total { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
9
BudgetApp.PublicModels/BudgetApp.PublicModels.csproj
Normal file
9
BudgetApp.PublicModels/BudgetApp.PublicModels.csproj
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
38
BudgetApp.PublicModels/YearSummaryDto.cs
Normal file
38
BudgetApp.PublicModels/YearSummaryDto.cs
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
namespace BudgetApp.PublicModels
|
||||||
|
{
|
||||||
|
public class YearSummaryDto
|
||||||
|
{
|
||||||
|
public int Year { get; set; }
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
public List<MonthSummaryDto> Months { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MonthSummaryDto
|
||||||
|
{
|
||||||
|
public int Month { get; set; }
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
public List<DaySummaryDto> Days { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DaySummaryDto
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
public List<TransactionDto> Transactions { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TransactionDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
public string OperationDescription { get; set; } = string.Empty;
|
||||||
|
public string Note { get; set; } = string.Empty;
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public string TransactionId { get; set; } = string.Empty;
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
public DateTime Updated { get; set; }
|
||||||
|
public DateTime? Deleted { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
19
BudgetApp.Services/BudgetApp.Services.csproj
Normal file
19
BudgetApp.Services/BudgetApp.Services.csproj
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\BudgetApp.PublicModels\BudgetApp.PublicModels.csproj" />
|
||||||
|
<ProjectReference Include="..\BudgetApp.Storage\BudgetApp.Storage.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
104
BudgetApp.Services/TransactionService.cs
Normal file
104
BudgetApp.Services/TransactionService.cs
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
using BudgetApp.PublicModels;
|
||||||
|
using BudgetApp.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace BudgetApp.Services
|
||||||
|
{
|
||||||
|
public class TransactionService(BudgetContext context) : ITransactionService
|
||||||
|
{
|
||||||
|
private readonly BudgetContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
|
||||||
|
public async Task<ListResponse<YearSummaryDto>> GetTransactions(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = new ListResponse<YearSummaryDto>();
|
||||||
|
|
||||||
|
var transactions = await _context.Transactions
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(t => t.Date)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var years = transactions
|
||||||
|
.GroupBy(t => t.Date.Year)
|
||||||
|
.OrderBy(g => g.Key)
|
||||||
|
.Select(yearGroup =>
|
||||||
|
{
|
||||||
|
var months = yearGroup
|
||||||
|
.GroupBy(t => t.Date.Month)
|
||||||
|
.OrderBy(monthGroup => monthGroup.Key)
|
||||||
|
.Select(monthGroup =>
|
||||||
|
{
|
||||||
|
var days = monthGroup
|
||||||
|
.GroupBy(t => t.Date.Day)
|
||||||
|
.OrderBy(dayGroup => dayGroup.Key)
|
||||||
|
.Select(dayGroup =>
|
||||||
|
{
|
||||||
|
var dayTransactions = dayGroup
|
||||||
|
.OrderBy(t => t.Date)
|
||||||
|
.ThenBy(t => t.Created)
|
||||||
|
.Select(MapTransaction)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var dayBalance = dayTransactions.Sum(t => t.Amount);
|
||||||
|
|
||||||
|
return new DaySummaryDto
|
||||||
|
{
|
||||||
|
Date = dayTransactions.First().Date,
|
||||||
|
Balance = dayBalance,
|
||||||
|
Transactions = dayTransactions
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var monthBalance = days.Sum(d => d.Balance);
|
||||||
|
|
||||||
|
return new MonthSummaryDto
|
||||||
|
{
|
||||||
|
Month = monthGroup.Key,
|
||||||
|
Balance = monthBalance,
|
||||||
|
Days = days
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var yearBalance = months.Sum(m => m.Balance);
|
||||||
|
|
||||||
|
return new YearSummaryDto
|
||||||
|
{
|
||||||
|
Year = yearGroup.Key,
|
||||||
|
Balance = yearBalance,
|
||||||
|
Months = months
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
response.Data = years;
|
||||||
|
response.Total = transactions.Count;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TransactionDto MapTransaction(Transaction source)
|
||||||
|
{
|
||||||
|
return new TransactionDto
|
||||||
|
{
|
||||||
|
Id = source.Id,
|
||||||
|
Date = source.Date,
|
||||||
|
Amount = source.Amount,
|
||||||
|
Balance = source.Balance,
|
||||||
|
OperationDescription = source.OperationDescription,
|
||||||
|
Note = source.Note,
|
||||||
|
Category = source.Category,
|
||||||
|
TransactionId = source.TransactionId,
|
||||||
|
Created = source.Created,
|
||||||
|
Updated = source.Updated,
|
||||||
|
Deleted = source.Deleted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ITransactionService
|
||||||
|
{
|
||||||
|
Task<ListResponse<YearSummaryDto>> GetTransactions(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
BudgetApp.Services/UploadService.cs
Normal file
201
BudgetApp.Services/UploadService.cs
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
using BudgetApp.Enums;
|
||||||
|
using BudgetApp.PublicModels;
|
||||||
|
using BudgetApp.Storage;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace BudgetApp.Services
|
||||||
|
{
|
||||||
|
public class UploadService(BudgetContext context) : IUploadService
|
||||||
|
{
|
||||||
|
private readonly BudgetContext _context = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
|
||||||
|
public async Task<BaseResponse> UploadCsv(IFormFile file, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await SetUpdateStatusState(UpdateStatusState.Processing);
|
||||||
|
|
||||||
|
var response = new BaseResponse();
|
||||||
|
var errors = new List<string>();
|
||||||
|
var transactionsToInsert = new List<Transaction>();
|
||||||
|
|
||||||
|
var existingIds = await _context.Transactions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(t => t.TransactionId)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var knownTransactionIds = new HashSet<string>(existingIds, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
using (var stream = file.OpenReadStream())
|
||||||
|
using (var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
|
||||||
|
{
|
||||||
|
while (!reader.EndOfStream)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var rawLine = await reader.ReadLineAsync().ConfigureAwait(false);
|
||||||
|
if (string.IsNullOrWhiteSpace(rawLine))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawLine.StartsWith("2168", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue; // Non-transaction rows (e.g., headers) are ignored.
|
||||||
|
}
|
||||||
|
|
||||||
|
var cols = rawLine.Split(';');
|
||||||
|
if (cols.Length <= 23)
|
||||||
|
{
|
||||||
|
errors.Add("Skipping row with insufficient number of columns.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transactionId = cols[23].Trim('\"', ' ');
|
||||||
|
if (string.IsNullOrWhiteSpace(transactionId))
|
||||||
|
{
|
||||||
|
errors.Add("Skipping row without transaction identifier.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!knownTransactionIds.Add(transactionId))
|
||||||
|
{
|
||||||
|
continue; // Already processed or exists in database.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DateOnly.TryParseExact(cols[1].Trim('\"', ' '), "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
|
||||||
|
{
|
||||||
|
errors.Add($"Skipping row with invalid date value: '{cols[1]}'.");
|
||||||
|
knownTransactionIds.Remove(transactionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParseDecimal(cols[2], out var amount))
|
||||||
|
{
|
||||||
|
errors.Add($"Skipping row with invalid amount value: '{cols[2]}'.");
|
||||||
|
knownTransactionIds.Remove(transactionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryParseDecimal(cols[4], out var balance))
|
||||||
|
{
|
||||||
|
errors.Add($"Skipping row with invalid balance value: '{cols[4]}'.");
|
||||||
|
knownTransactionIds.Remove(transactionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transaction = new Transaction
|
||||||
|
{
|
||||||
|
Amount = amount,
|
||||||
|
Balance = balance,
|
||||||
|
Category = cols[16].Trim('\"', ' '),
|
||||||
|
Date = date,
|
||||||
|
Note = cols[15].Trim('\"', ' '),
|
||||||
|
OperationDescription = string.Join(" | ",
|
||||||
|
SafeGetColumn(cols, 12),
|
||||||
|
SafeGetColumn(cols, 13),
|
||||||
|
SafeGetColumn(cols, 14)).Trim(' ', '|'),
|
||||||
|
TransactionId = transactionId
|
||||||
|
};
|
||||||
|
|
||||||
|
transactionsToInsert.Add(transaction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionsToInsert.Count > 0)
|
||||||
|
{
|
||||||
|
await _context.Transactions.AddRangeAsync(transactionsToInsert, cancellationToken).ConfigureAwait(false);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.Count > 0)
|
||||||
|
{
|
||||||
|
response.Success = false;
|
||||||
|
response.Error = string.Join(" ", errors.Distinct());
|
||||||
|
}
|
||||||
|
|
||||||
|
await SetUpdateStatusState(UpdateStatusState.Done);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SafeGetColumn(string[] columns, int index)
|
||||||
|
{
|
||||||
|
return index >= 0 && index < columns.Length
|
||||||
|
? columns[index].Trim('\"', ' ')
|
||||||
|
: string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseDecimal(string input, out decimal value)
|
||||||
|
{
|
||||||
|
value = 0m;
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sanitized = NormalizeDecimalValue(input);
|
||||||
|
return decimal.TryParse(sanitized, NumberStyles.Number | NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeDecimalValue(string input)
|
||||||
|
{
|
||||||
|
var normalized = input
|
||||||
|
.Replace("\u00A0", string.Empty) // non-breaking space
|
||||||
|
.Trim();
|
||||||
|
|
||||||
|
var builder = new StringBuilder(normalized.Length);
|
||||||
|
foreach (var ch in normalized)
|
||||||
|
{
|
||||||
|
if (char.IsDigit(ch) || ch is '-' or '+' || ch == ',' || ch == '.')
|
||||||
|
{
|
||||||
|
builder.Append(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleaned = builder.ToString();
|
||||||
|
var lastComma = cleaned.LastIndexOf(',');
|
||||||
|
var lastDot = cleaned.LastIndexOf('.');
|
||||||
|
|
||||||
|
if (lastComma >= 0 && lastDot >= 0)
|
||||||
|
{
|
||||||
|
if (lastComma > lastDot)
|
||||||
|
{
|
||||||
|
cleaned = cleaned.Replace(".", string.Empty);
|
||||||
|
cleaned = cleaned.Replace(',', '.');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cleaned = cleaned.Replace(",", string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (lastComma >= 0)
|
||||||
|
{
|
||||||
|
cleaned = cleaned.Replace(".", string.Empty);
|
||||||
|
cleaned = cleaned.Replace(',', '.');
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cleaned = cleaned.Replace(",", string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SetUpdateStatusState(UpdateStatusState state)
|
||||||
|
{
|
||||||
|
var updateStatus = await _context.UpdateStatuses
|
||||||
|
.SingleAsync();
|
||||||
|
|
||||||
|
updateStatus.State = state;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IUploadService
|
||||||
|
{
|
||||||
|
Task<BaseResponse> UploadCsv(IFormFile file, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
BudgetApp.Storage/BaseModel.cs
Normal file
10
BudgetApp.Storage/BaseModel.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace BudgetApp.Storage
|
||||||
|
{
|
||||||
|
public class BaseModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime Created { get; set; }
|
||||||
|
public DateTime Updated { get; set; }
|
||||||
|
public DateTime? Deleted { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
22
BudgetApp.Storage/BudgetApp.Storage.csproj
Normal file
22
BudgetApp.Storage/BudgetApp.Storage.csproj
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" 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="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\BudgetApp.Enums\BudgetApp.Enums.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
81
BudgetApp.Storage/BudgetContext.cs
Normal file
81
BudgetApp.Storage/BudgetContext.cs
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
using BudgetApp.Enums;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage
|
||||||
|
{
|
||||||
|
public class BudgetContext : DbContext
|
||||||
|
{
|
||||||
|
public BudgetContext() { }
|
||||||
|
public BudgetContext(DbContextOptions options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<Transaction> Transactions { get; set; }
|
||||||
|
public DbSet<UpdateStatus> UpdateStatuses { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<UpdateStatus>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Id).ValueGeneratedNever();
|
||||||
|
entity.ToTable("UpdateStatus", tableBuilder =>
|
||||||
|
{
|
||||||
|
tableBuilder.HasCheckConstraint("CK_UpdateStatus_SingletonId", "`Id` = 1");
|
||||||
|
});
|
||||||
|
entity.HasData(new UpdateStatus
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
State = UpdateStatusState.Done,
|
||||||
|
Created = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
Updated = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureUpdateStatusChangesAreValid();
|
||||||
|
var entries = ChangeTracker
|
||||||
|
.Entries()
|
||||||
|
.Where(x => x.Entity is BaseModel && (x.State == EntityState.Added || x.State == EntityState.Modified));
|
||||||
|
foreach (var entityEntry in entries)
|
||||||
|
{
|
||||||
|
((BaseModel)entityEntry.Entity).Updated = DateTime.Now;
|
||||||
|
if (entityEntry.State == EntityState.Added)
|
||||||
|
{
|
||||||
|
((BaseModel)entityEntry.Entity).Created = DateTime.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await base.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int SaveChanges()
|
||||||
|
{
|
||||||
|
EnsureUpdateStatusChangesAreValid();
|
||||||
|
var entries = ChangeTracker
|
||||||
|
.Entries()
|
||||||
|
.Where(x => x.Entity is BaseModel && (x.State == EntityState.Added || x.State == EntityState.Modified));
|
||||||
|
foreach (var entityEntry in entries)
|
||||||
|
{
|
||||||
|
((BaseModel)entityEntry.Entity).Updated = DateTime.Now;
|
||||||
|
if (entityEntry.State == EntityState.Added)
|
||||||
|
{
|
||||||
|
((BaseModel)entityEntry.Entity).Created = DateTime.Now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base.SaveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureUpdateStatusChangesAreValid()
|
||||||
|
{
|
||||||
|
var invalidUpdateStatusChange = ChangeTracker
|
||||||
|
.Entries<UpdateStatus>()
|
||||||
|
.Any(x => x.State is EntityState.Added or EntityState.Deleted);
|
||||||
|
|
||||||
|
if (invalidUpdateStatusChange)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("UpdateStatus is a singleton row. Add and delete operations are not allowed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
BudgetApp.Storage/Migrations/20251029223623_Init.Designer.cs
generated
Normal file
52
BudgetApp.Storage/Migrations/20251029223623_Init.Designer.cs
generated
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using BudgetApp.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(BudgetContext))]
|
||||||
|
[Migration("20251029223623_Init")]
|
||||||
|
partial class Init
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("BudgetApp.Storage.Transaction", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Deleted")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Updated")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Transactions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
BudgetApp.Storage/Migrations/20251029223623_Init.cs
Normal file
42
BudgetApp.Storage/Migrations/20251029223623_Init.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Init : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Transactions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
Created = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
Updated = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
Deleted = table.Column<DateTime>(type: "datetime(6)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Transactions", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Transactions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
BudgetApp.Storage/Migrations/20251030001445_Transactions.Designer.cs
generated
Normal file
77
BudgetApp.Storage/Migrations/20251030001445_Transactions.Designer.cs
generated
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using BudgetApp.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(BudgetContext))]
|
||||||
|
[Migration("20251030001445_Transactions")]
|
||||||
|
partial class Transactions
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("BudgetApp.Storage.Transaction", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Balance")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Deleted")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("OperationDescription")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("TransactionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Updated")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Transactions");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
BudgetApp.Storage/Migrations/20251030001445_Transactions.cs
Normal file
96
BudgetApp.Storage/Migrations/20251030001445_Transactions.cs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Transactions : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "Amount",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "decimal(65,30)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "Balance",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "decimal(65,30)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Category",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateOnly>(
|
||||||
|
name: "Date",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "date",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new DateOnly(1, 1, 1));
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Note",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "OperationDescription",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TransactionId",
|
||||||
|
table: "Transactions",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Amount",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Balance",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Category",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Date",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Note",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OperationDescription",
|
||||||
|
table: "Transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TransactionId",
|
||||||
|
table: "Transactions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
BudgetApp.Storage/Migrations/20260301000000_AddUpdateStatusSingleton.Designer.cs
generated
Normal file
112
BudgetApp.Storage/Migrations/20260301000000_AddUpdateStatusSingleton.Designer.cs
generated
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using BudgetApp.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(BudgetContext))]
|
||||||
|
[Migration("20260301000000_AddUpdateStatusSingleton")]
|
||||||
|
partial class AddUpdateStatusSingleton
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("BudgetApp.Storage.Transaction", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Balance")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Deleted")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("OperationDescription")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("TransactionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Updated")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BudgetApp.Storage.UpdateStatus", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Deleted")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("State")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Updated")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("UpdateStatus", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("CK_UpdateStatus_SingletonId", "`Id` = 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Created = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
Deleted = (DateTime?)null,
|
||||||
|
State = 1,
|
||||||
|
Updated = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUpdateStatusSingleton : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "UpdateStatus",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false),
|
||||||
|
State = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Created = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
Updated = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
Deleted = table.Column<DateTime>(type: "datetime(6)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_UpdateStatus", x => x.Id);
|
||||||
|
table.CheckConstraint("CK_UpdateStatus_SingletonId", "`Id` = 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "UpdateStatus",
|
||||||
|
columns: new[] { "Id", "Created", "Deleted", "State", "Updated" },
|
||||||
|
values: new object[] { 1, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), null, 1, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) });
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"CREATE TRIGGER `TR_UpdateStatus_PreventDelete` BEFORE DELETE ON `UpdateStatus` FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'UpdateStatus row cannot be deleted';");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DROP TRIGGER IF EXISTS `TR_UpdateStatus_PreventDelete`;");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "UpdateStatus");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
BudgetApp.Storage/Migrations/BudgetContextModelSnapshot.cs
Normal file
109
BudgetApp.Storage/Migrations/BudgetContextModelSnapshot.cs
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using BudgetApp.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(BudgetContext))]
|
||||||
|
partial class BudgetContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.10")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("BudgetApp.Storage.Transaction", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Balance")
|
||||||
|
.HasColumnType("decimal(65,30)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Deleted")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("OperationDescription")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<string>("TransactionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("longtext");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Updated")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Transactions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BudgetApp.Storage.UpdateStatus", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Deleted")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("State")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Updated")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("UpdateStatus", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("CK_UpdateStatus_SingletonId", "`Id` = 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
Created = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||||
|
Deleted = (DateTime?)null,
|
||||||
|
State = 1,
|
||||||
|
Updated = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
BudgetApp.Storage/Transaction.cs
Normal file
13
BudgetApp.Storage/Transaction.cs
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
namespace BudgetApp.Storage
|
||||||
|
{
|
||||||
|
public class Transaction : BaseModel
|
||||||
|
{
|
||||||
|
public DateOnly Date { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
public string OperationDescription { get; set; } = string.Empty;
|
||||||
|
public string Note { get; set; } = string.Empty;
|
||||||
|
public string Category { get; set; } = string.Empty;
|
||||||
|
public string TransactionId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
BudgetApp.Storage/UpdateStatus.cs
Normal file
9
BudgetApp.Storage/UpdateStatus.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
using BudgetApp.Enums;
|
||||||
|
|
||||||
|
namespace BudgetApp.Storage
|
||||||
|
{
|
||||||
|
public class UpdateStatus : BaseModel
|
||||||
|
{
|
||||||
|
public UpdateStatusState State { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
BudgetApp.Web/.gitattributes
vendored
Normal file
1
BudgetApp.Web/.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
36
BudgetApp.Web/.gitignore
vendored
Normal file
36
BudgetApp.Web/.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
6
BudgetApp.Web/.prettierrc.json
Normal file
6
BudgetApp.Web/.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
42
BudgetApp.Web/README.md
Normal file
42
BudgetApp.Web/README.md
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
# BudgetApp.Web
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
1
BudgetApp.Web/env.d.ts
vendored
Normal file
1
BudgetApp.Web/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
17
BudgetApp.Web/index.html
Normal file
17
BudgetApp.Web/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<link
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@mdi/font/css/materialdesignicons.min.css"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3064
BudgetApp.Web/package-lock.json
generated
Normal file
3064
BudgetApp.Web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
BudgetApp.Web/package.json
Normal file
37
BudgetApp.Web/package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "budgetapp-web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"format": "prettier --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdi/font": "^7.4.47",
|
||||||
|
"apexcharts": "^5.3.5",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3",
|
||||||
|
"vue3-apexcharts": "^1.10.0",
|
||||||
|
"vuetify": "^3.10.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node22": "^22.0.2",
|
||||||
|
"@types/node": "^22.18.11",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"prettier": "3.6.2",
|
||||||
|
"typescript": "~5.9.0",
|
||||||
|
"vite": "^7.1.11",
|
||||||
|
"vite-plugin-vue-devtools": "^8.0.3",
|
||||||
|
"vue-tsc": "^3.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
BudgetApp.Web/public/favicon.ico
Normal file
BIN
BudgetApp.Web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
171
BudgetApp.Web/src/App.vue
Normal file
171
BudgetApp.Web/src/App.vue
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<v-main>
|
||||||
|
<router-view></router-view>
|
||||||
|
</v-main>
|
||||||
|
<!-- <v-container grid-list-md>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="4">
|
||||||
|
<v-toolbar></v-toolbar>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="8">
|
||||||
|
<v-toolbar>
|
||||||
|
<v-btn icon="mdi-chevron-left"> </v-btn>
|
||||||
|
|
||||||
|
<v-toolbar-title text="October" class="text-center"></v-toolbar-title>
|
||||||
|
|
||||||
|
<v-btn icon="mdi-chevron-right"> </v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="4">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Year</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<month-card title="Total" :data="data"></month-card>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="4"></v-col>
|
||||||
|
<v-col cols="4">
|
||||||
|
<v-card variant="elevated">
|
||||||
|
<v-card-title>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="4" class="text-left">
|
||||||
|
<v-btn icon @click="decreaseMonth">
|
||||||
|
<v-icon>mdi-chevron-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="4" class="text-center">
|
||||||
|
{{ selectedMonth?.title }}
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="4" class="text-right">
|
||||||
|
<v-btn icon @click="increaseMonth">
|
||||||
|
<v-icon>mdi-chevron-right</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<month-detail :data="getDataForMonth(selectedMonthNumber)"></month-detail>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col> </v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="3" v-for="month in months">
|
||||||
|
<month-card :title="month.title" :data="getDataForMonth(month.value)"></month-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container> -->
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { getData } from './backend/sheetsApi'
|
||||||
|
|
||||||
|
import MonthCard from './components/MonthCard.vue'
|
||||||
|
import MonthDetail from './components/MonthDetail.vue'
|
||||||
|
|
||||||
|
const selectedMonthNumber = ref<number>(new Date().getMonth() + 1)
|
||||||
|
const selectedMonth = computed(() => {
|
||||||
|
return months.value.find((x) => x.value === selectedMonthNumber.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
//loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
const months = ref<{ title: string; value: number }[]>([
|
||||||
|
{ title: 'January', value: 1 },
|
||||||
|
{ title: 'February', value: 2 },
|
||||||
|
{ title: 'March', value: 3 },
|
||||||
|
{ title: 'April', value: 4 },
|
||||||
|
{ title: 'May', value: 5 },
|
||||||
|
{ title: 'June', value: 6 },
|
||||||
|
{ title: 'July', value: 7 },
|
||||||
|
{ title: 'August', value: 8 },
|
||||||
|
{ title: 'September', value: 9 },
|
||||||
|
{ title: 'October', value: 10 },
|
||||||
|
{ title: 'November', value: 11 },
|
||||||
|
{ title: 'December', value: 12 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const getDataForMonth = (month: number) => {
|
||||||
|
return data.value.filter((x: any) => x.date.getMonth() + 1 === month)
|
||||||
|
}
|
||||||
|
|
||||||
|
const increaseMonth = () => {
|
||||||
|
selectedMonthNumber.value++
|
||||||
|
if (selectedMonthNumber.value > 12) {
|
||||||
|
selectedMonthNumber.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decreaseMonth = () => {
|
||||||
|
selectedMonthNumber.value--
|
||||||
|
if (selectedMonthNumber.value < 1) {
|
||||||
|
selectedMonthNumber.value = 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = ref<any[]>([])
|
||||||
|
const expenses = computed(() => {
|
||||||
|
return data.value
|
||||||
|
.filter((x: any) => x.type === 'Expense')
|
||||||
|
.map((x: any) => {
|
||||||
|
x.amount = Math.abs(x.amount)
|
||||||
|
return x
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const income = computed(() => {
|
||||||
|
return data.value.filter((x: any) => x.type === 'Income')
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = ref<any[]>([
|
||||||
|
{ title: 'Date', key: 'date' },
|
||||||
|
{ title: 'Amount', key: 'amount' },
|
||||||
|
{ title: 'Category', key: 'category' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const options = ref<any>({
|
||||||
|
chart: { type: 'pie' },
|
||||||
|
labels: [], // <- labels live here
|
||||||
|
legend: { position: 'bottom' }, // optional
|
||||||
|
})
|
||||||
|
|
||||||
|
const series = ref<number[]>([])
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const response = await getData()
|
||||||
|
data.value = response
|
||||||
|
|
||||||
|
const transformed = transformForPieChart(data.value)
|
||||||
|
series.value = transformed.series
|
||||||
|
options.value = {
|
||||||
|
// ensure reactivity picks it up
|
||||||
|
...options.value,
|
||||||
|
labels: transformed.labels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformForPieChart = (rows: { date: Date; amount: number; category: string }[]) => {
|
||||||
|
const totals: Record<string, number> = {}
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = r.category ?? 'Uncategorized'
|
||||||
|
totals[key] = (totals[key] ?? 0) + (Number(r.amount) || 0)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
labels: Object.keys(totals),
|
||||||
|
series: Object.values(totals),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
16
BudgetApp.Web/src/backend/ApiConnector.ts
Normal file
16
BudgetApp.Web/src/backend/ApiConnector.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { ListResponse, YearSummaryDto } from '@/models/TransactionModels'
|
||||||
|
|
||||||
|
const BASE_URL = 'https://localhost:7244/api/Transactions'
|
||||||
|
|
||||||
|
export const getTransactions = async (
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ListResponse<YearSummaryDto>> => {
|
||||||
|
const response = await fetch(`${BASE_URL}/getTransactions`, { signal })
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = `Failed to load transactions: ${response.status} ${response.statusText}`
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as ListResponse<YearSummaryDto>
|
||||||
|
}
|
||||||
25
BudgetApp.Web/src/backend/sheetsApi.ts
Normal file
25
BudgetApp.Web/src/backend/sheetsApi.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export const getData = async () => {
|
||||||
|
const url =
|
||||||
|
'https://sheets.googleapis.com/v4/spreadsheets/1_Y0s1YNhOl2gqD0BXW-H_WlBpIm1wceMhNqylrPHnuY/values/2025!A2:Z?key=AIzaSyDWT9fEbBfk4vtnKJL0JUF8iggxgvC2TNs'
|
||||||
|
const response = await fetch(url)
|
||||||
|
const json = await response.json()
|
||||||
|
|
||||||
|
const data = json.values.map((x: any[]) => {
|
||||||
|
return {
|
||||||
|
date: new Date(`${x[1].split('.')[2]}-${x[1].split('.')[1]}-${x[1].split('.')[0]}`),
|
||||||
|
amount: parseCurrency(x[2]),
|
||||||
|
category: x[16],
|
||||||
|
type: parseCurrency(x[2]) > 0 ? 'Income' : 'Expense',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.warn('DATA', data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseCurrency = (input: string) => {
|
||||||
|
return parseFloat(
|
||||||
|
input
|
||||||
|
.replace(/[^\d.,-]/g, '') // keep digits and separators
|
||||||
|
.replace(/,/g, ''), // remove thousand separators
|
||||||
|
)
|
||||||
|
}
|
||||||
98
BudgetApp.Web/src/components/ComparativeChart.vue
Normal file
98
BudgetApp.Web/src/components/ComparativeChart.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="my-4">
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-chip color="negative" label size="small"> {{ formatter.format(leftTotal) }} </v-chip>
|
||||||
|
</v-col>
|
||||||
|
<v-col class="text-center">
|
||||||
|
<v-chip
|
||||||
|
:color="rightTotal > Math.abs(leftTotal) ? 'positive' : 'negative'"
|
||||||
|
label
|
||||||
|
size="large"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{ formatter.format(rightTotal + leftTotal) }}
|
||||||
|
</v-chip>
|
||||||
|
</v-col>
|
||||||
|
<v-col class="text-right">
|
||||||
|
<v-chip color="positive" label size="small"> {{ formatter.format(rightTotal) }} </v-chip>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col class="pr-0">
|
||||||
|
<v-progress-linear
|
||||||
|
color="negative"
|
||||||
|
v-model="leftValue"
|
||||||
|
reverse
|
||||||
|
:height="chHeight"
|
||||||
|
:chunk-width="chChunkWidth"
|
||||||
|
:chunk-gap="chChunkGap"
|
||||||
|
>
|
||||||
|
</v-progress-linear>
|
||||||
|
</v-col>
|
||||||
|
<v-col class="pl-0">
|
||||||
|
<v-progress-linear
|
||||||
|
color="positive"
|
||||||
|
v-model="rightValue"
|
||||||
|
:height="chHeight"
|
||||||
|
:chunk-width="chChunkWidth"
|
||||||
|
:chunk-gap="chChunkGap"
|
||||||
|
></v-progress-linear>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { formatter } from '@/utils/CurrencyUtils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
leftTitle: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
rightTitle: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
leftData: {
|
||||||
|
type: Array<{ amount: number }>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
rightData: {
|
||||||
|
type: Array<{ amount: number }>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const chHeight = ref<number>(32)
|
||||||
|
const chChunkWidth = ref<number>(6)
|
||||||
|
const chChunkGap = ref<number>(1)
|
||||||
|
|
||||||
|
const total = computed(() => {
|
||||||
|
const left = props.leftData.reduce((a, b: any) => a + b.amount, 0)
|
||||||
|
const right = props.rightData.reduce((a, b: any) => a + b.amount, 0)
|
||||||
|
return Math.abs(left) + Math.abs(right)
|
||||||
|
})
|
||||||
|
|
||||||
|
const leftValue = computed((): number => {
|
||||||
|
const value = props.leftData.reduce((a, b: any) => a + b.amount, 0)
|
||||||
|
return Math.abs((value / total.value) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const leftTotal = computed((): number => {
|
||||||
|
return props.leftData.reduce((a, b: any) => a + b.amount, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightValue = computed((): number => {
|
||||||
|
const value = props.rightData.reduce((a, b: any) => a + b.amount, 0)
|
||||||
|
return Math.abs((value / total.value) * 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightTotal = computed((): number => {
|
||||||
|
return props.rightData.reduce((a, b: any) => a + b.amount, 0)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
125
BudgetApp.Web/src/components/MonthCard.vue
Normal file
125
BudgetApp.Web/src/components/MonthCard.vue
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>
|
||||||
|
<v-row>
|
||||||
|
<v-col>{{ title }}</v-col>
|
||||||
|
<v-col class="text-right">
|
||||||
|
<v-chip :color="total > 0 ? 'success' : 'error'" variant="flat" label>
|
||||||
|
{{ formatter.format(total) }}
|
||||||
|
</v-chip>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<ComparativeChart
|
||||||
|
left-title="Expenses"
|
||||||
|
right-title="Income"
|
||||||
|
:left-data="expenses"
|
||||||
|
:right-data="income"
|
||||||
|
></ComparativeChart>
|
||||||
|
|
||||||
|
<apexchart :options="options" :series="series"></apexchart>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import ComparativeChart from './ComparativeChart.vue'
|
||||||
|
import { formatter } from '@/utils/CurrencyUtils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Array<{ date: Date; amount: number; category: string; type: string }>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const expenses = computed((): { date: Date; amount: number; category: string; type: string }[] => {
|
||||||
|
return props.data.filter((x: any) => x.type === 'Expense')
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalExpenses = computed((): number => {
|
||||||
|
const result = expenses.value.reduce((a, b: any) => a + b.amount, 0) as number
|
||||||
|
return Math.abs(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
const income = computed((): { date: Date; amount: number; category: string; type: string }[] => {
|
||||||
|
return props.data.filter((x: any) => x.type === 'Income')
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalIncome = computed((): number => {
|
||||||
|
const result = income.value.reduce((a, b: any) => a + b.amount, 0) as number
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = computed(() => {
|
||||||
|
return totalIncome.value - totalExpenses.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = ref<any>({
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
events: {
|
||||||
|
dataPointSelection: function (event: MouseEvent, chartContext: HTMLElement, opts: any) {
|
||||||
|
console.log('chart clicked', opts)
|
||||||
|
onChartClick(opts.dataPointIndex)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labels: [], // <- labels live here
|
||||||
|
legend: { position: 'top' }, // optional
|
||||||
|
})
|
||||||
|
|
||||||
|
const series = ref<number[]>([])
|
||||||
|
|
||||||
|
const transformForPieChart = (rows: { date: Date; amount: number; category: string }[]) => {
|
||||||
|
const totals: Record<string, number> = {}
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = r.category ?? 'Uncategorized'
|
||||||
|
totals[key] = (totals[key] ?? 0) + (Number(Math.abs(r.amount)) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTotals = Object.fromEntries(Object.entries(totals).sort(([, a], [, b]) => b - a))
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: Object.keys(sortedTotals),
|
||||||
|
series: Object.values(sortedTotals),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const transformed = transformForPieChart(expenses.value)
|
||||||
|
series.value = transformed.series
|
||||||
|
options.value = {
|
||||||
|
// ensure reactivity picks it up
|
||||||
|
...options.value,
|
||||||
|
labels: transformed.labels,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(expenses, () => {
|
||||||
|
const transformed = transformForPieChart(expenses.value)
|
||||||
|
series.value = transformed.series
|
||||||
|
options.value = {
|
||||||
|
// ensure reactivity picks it up
|
||||||
|
...options.value,
|
||||||
|
labels: transformed.labels,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onChartClick = (seriesIndex: number) => {
|
||||||
|
const seriesName = options.value.labels[seriesIndex]
|
||||||
|
const seriesValue = series.value[seriesIndex]
|
||||||
|
|
||||||
|
// console.log(config)
|
||||||
|
console.log(seriesIndex, seriesName, seriesValue)
|
||||||
|
console.log(props.data.filter((x) => x.category === seriesName))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
119
BudgetApp.Web/src/components/MonthDetail.vue
Normal file
119
BudgetApp.Web/src/components/MonthDetail.vue
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ComparativeChart
|
||||||
|
left-title="Expenses"
|
||||||
|
right-title="Income"
|
||||||
|
:left-data="expenses"
|
||||||
|
:right-data="income"
|
||||||
|
></ComparativeChart>
|
||||||
|
|
||||||
|
<apexchart :options="options" :series="series"></apexchart>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import ComparativeChart from './ComparativeChart.vue'
|
||||||
|
import { formatter } from '@/utils/CurrencyUtils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Array<{ date: Date; amount: number; category: string; type: string }>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const expenses = computed((): { date: Date; amount: number; category: string; type: string }[] => {
|
||||||
|
return props.data.filter((x: any) => x.type === 'Expense')
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalExpenses = computed((): number => {
|
||||||
|
const result = expenses.value.reduce((a, b: any) => a + b.amount, 0) as number
|
||||||
|
return Math.abs(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
const income = computed((): { date: Date; amount: number; category: string; type: string }[] => {
|
||||||
|
return props.data.filter((x: any) => x.type === 'Income')
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalIncome = computed((): number => {
|
||||||
|
const result = income.value.reduce((a, b: any) => a + b.amount, 0) as number
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = computed(() => {
|
||||||
|
return totalIncome.value - totalExpenses.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = ref<any>({
|
||||||
|
chart: {
|
||||||
|
type: 'pie',
|
||||||
|
events: {
|
||||||
|
dataPointSelection: function (event: MouseEvent, chartContext: HTMLElement, opts: any) {
|
||||||
|
console.log('chart clicked', opts)
|
||||||
|
onChartClick(opts.dataPointIndex)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labels: [], // <- labels live here
|
||||||
|
legend: { position: 'top', show: true }, // optional
|
||||||
|
colors: ['#F44336', '#FF9800', '#2196F3', '#4CAF50', '#E91E63', '#009688'],
|
||||||
|
tooltip: {
|
||||||
|
y: {
|
||||||
|
formatter: function (val: number) {
|
||||||
|
return formatCurrency(val)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => formatter.format(value)
|
||||||
|
|
||||||
|
const series = ref<number[]>([])
|
||||||
|
|
||||||
|
const transformForPieChart = (rows: { date: Date; amount: number; category: string }[]) => {
|
||||||
|
const totals: Record<string, number> = {}
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = r.category ?? 'Uncategorized'
|
||||||
|
totals[key] = (totals[key] ?? 0) + (Number(Math.abs(r.amount)) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTotals = Object.fromEntries(Object.entries(totals).sort(([, a], [, b]) => b - a))
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: Object.keys(sortedTotals),
|
||||||
|
series: Object.values(sortedTotals),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const transformed = transformForPieChart(expenses.value)
|
||||||
|
series.value = transformed.series
|
||||||
|
options.value = {
|
||||||
|
// ensure reactivity picks it up
|
||||||
|
...options.value,
|
||||||
|
labels: transformed.labels,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(expenses, () => {
|
||||||
|
const transformed = transformForPieChart(expenses.value)
|
||||||
|
series.value = transformed.series
|
||||||
|
options.value = {
|
||||||
|
// ensure reactivity picks it up
|
||||||
|
...options.value,
|
||||||
|
labels: transformed.labels,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onChartClick = (seriesIndex: number) => {
|
||||||
|
const seriesName = options.value.labels[seriesIndex]
|
||||||
|
const seriesValue = series.value[seriesIndex]
|
||||||
|
|
||||||
|
// console.log(config)
|
||||||
|
console.log(seriesIndex, seriesName, seriesValue)
|
||||||
|
console.log(props.data.filter((x) => x.category === seriesName))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
92
BudgetApp.Web/src/components/dashboard/BalanceChart.vue
Normal file
92
BudgetApp.Web/src/components/dashboard/BalanceChart.vue
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<v-card height="100%">
|
||||||
|
<v-card-title>Balance</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<apexchart :options="options" :series="series"></apexchart>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { DaySummaryDto } from '@/models/TransactionModels'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Array<DaySummaryDto>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
daysInMonth: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = ref<any>({
|
||||||
|
chart: { type: 'line' },
|
||||||
|
labels: [],
|
||||||
|
yaxis: {
|
||||||
|
min: -100000,
|
||||||
|
max: 100000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const series = ref<{ data: number[] }[]>([])
|
||||||
|
|
||||||
|
const dayNumberToString = (day: number) => {
|
||||||
|
return day.toString().padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformForLineChart = () => {
|
||||||
|
if (!props.data) return { series: [], labels: [] }
|
||||||
|
|
||||||
|
var balance = 0
|
||||||
|
const s: Record<number, number> = {}
|
||||||
|
|
||||||
|
for (var d = 1; d <= props.daysInMonth; d++) {
|
||||||
|
const dayString = dayNumberToString(d)
|
||||||
|
var day = props.data.find((x) => x.date.split('-')[2] == dayString)
|
||||||
|
if (!day) {
|
||||||
|
} else {
|
||||||
|
balance += Math.floor(day.balance)
|
||||||
|
}
|
||||||
|
s[d] = balance
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
labels: Object.keys(s),
|
||||||
|
series: Object.values(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getYAxisMin = (val: number) => {
|
||||||
|
return (Math.floor(val / 1000) + 1) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const getYAxisMax = (val: number) => {
|
||||||
|
return (Math.ceil(val / 1000) - 1) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateChart = () => {
|
||||||
|
const transformed = transformForLineChart()
|
||||||
|
console.log('🚀 ~ updateChart ~ transformed:', transformed)
|
||||||
|
series.value = [{ data: [...transformed.series] }]
|
||||||
|
options.value = {
|
||||||
|
...options.value,
|
||||||
|
labels: [...transformed.labels],
|
||||||
|
yaxis: {
|
||||||
|
min: getYAxisMin(Math.min(...transformed.series)),
|
||||||
|
max: getYAxisMax(Math.max(...transformed.series)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data,
|
||||||
|
() => {
|
||||||
|
updateChart()
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
65
BudgetApp.Web/src/components/dashboard/CategoriesChart.vue
Normal file
65
BudgetApp.Web/src/components/dashboard/CategoriesChart.vue
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<v-card height="100%">
|
||||||
|
<v-card-title> Categories </v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<apexchart :options="options" :series="series"></apexchart>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type TransactionDto } from '@/models/TransactionModels'
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Array<TransactionDto>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = ref<any>({
|
||||||
|
chart: { type: 'donut' },
|
||||||
|
labels: [],
|
||||||
|
legend: {
|
||||||
|
show: false,
|
||||||
|
position: 'top',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const series = ref<number[]>([])
|
||||||
|
|
||||||
|
const transformForPieChart = () => {
|
||||||
|
const totals: Record<string, number> = {}
|
||||||
|
for (const r of props.data) {
|
||||||
|
const key = r.category ?? 'Uncategorized'
|
||||||
|
totals[key] = (totals[key] ?? 0) + (Number(Math.abs(r.amount)) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTotals = Object.fromEntries(Object.entries(totals).sort(([, a], [, b]) => b - a))
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: Object.keys(sortedTotals),
|
||||||
|
series: Object.values(sortedTotals),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateChart = () => {
|
||||||
|
const transformed = transformForPieChart()
|
||||||
|
series.value = [...transformed.series]
|
||||||
|
options.value = {
|
||||||
|
...options.value,
|
||||||
|
labels: [...transformed.labels],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.data,
|
||||||
|
() => {
|
||||||
|
updateChart()
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<v-alert v-if="!data" type="warning"> NO DATA FOR SELECTED PERIOD </v-alert>
|
||||||
|
<div v-else>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title> Balance </v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<ComparativeChart
|
||||||
|
left-title="Expenses"
|
||||||
|
right-title="Income"
|
||||||
|
:left-data="expenses"
|
||||||
|
:right-data="income"
|
||||||
|
></ComparativeChart>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="6">
|
||||||
|
<CategoriesChart :data="expenses"></CategoriesChart>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<BalanceChart :data="data.days" :days-in-month="daysInMonth"></BalanceChart>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<TransactionsTable :data="transactions"></TransactionsTable>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MonthSummaryDto } from '@/models/TransactionModels'
|
||||||
|
import { computed, type PropType } from 'vue'
|
||||||
|
import ComparativeChart from '../ComparativeChart.vue'
|
||||||
|
import CategoriesChart from './CategoriesChart.vue'
|
||||||
|
import BalanceChart from './BalanceChart.vue'
|
||||||
|
import TransactionsTable from './TransactionsTable.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object as PropType<MonthSummaryDto> | undefined,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const transactions = computed(() => {
|
||||||
|
if (!props.data) return []
|
||||||
|
const records = []
|
||||||
|
for (var d = 0; d < props.data.days.length; d++) {
|
||||||
|
const day = props.data.days[d]
|
||||||
|
if (!day) continue
|
||||||
|
records.push(...day.transactions)
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
})
|
||||||
|
|
||||||
|
const expenses = computed(() => {
|
||||||
|
return transactions.value.filter((x) => x.amount <= 0)
|
||||||
|
})
|
||||||
|
const income = computed(() => {
|
||||||
|
return transactions.value.filter((x) => x.amount > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const daysInMonth = computed((): number => {
|
||||||
|
if (!props.data) return 0
|
||||||
|
|
||||||
|
switch (props.data.month) {
|
||||||
|
case 1:
|
||||||
|
case 3:
|
||||||
|
case 5:
|
||||||
|
case 7:
|
||||||
|
case 8:
|
||||||
|
case 10:
|
||||||
|
case 12:
|
||||||
|
return 31
|
||||||
|
case 4:
|
||||||
|
case 6:
|
||||||
|
case 9:
|
||||||
|
case 11:
|
||||||
|
return 30
|
||||||
|
case 2:
|
||||||
|
return 29
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<v-card height="100%">
|
||||||
|
<v-card-title> Monthly Balance </v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item v-for="month in months" :key="month.value" variant="elevated" class="my-1">
|
||||||
|
<v-list-item-title> {{ month.title }} </v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-chip :color="getMonthColor(month.value)" label variant="flat">
|
||||||
|
{{ getMonthBalance(month.value) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { type MonthSummaryDto } from '@/models/TransactionModels'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { formatter } from '@/utils/CurrencyUtils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Array<MonthSummaryDto>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const months = ref<{ title: string; value: number }[]>([
|
||||||
|
{ title: 'January', value: 1 },
|
||||||
|
{ title: 'February', value: 2 },
|
||||||
|
{ title: 'March', value: 3 },
|
||||||
|
{ title: 'April', value: 4 },
|
||||||
|
{ title: 'May', value: 5 },
|
||||||
|
{ title: 'June', value: 6 },
|
||||||
|
{ title: 'July', value: 7 },
|
||||||
|
{ title: 'August', value: 8 },
|
||||||
|
{ title: 'September', value: 9 },
|
||||||
|
{ title: 'October', value: 10 },
|
||||||
|
{ title: 'November', value: 11 },
|
||||||
|
{ title: 'December', value: 12 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const getMonthColor = (n: number) => {
|
||||||
|
var month = getMonthData(n)
|
||||||
|
if (!month || month.balance == 0) {
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
return month.balance > 0 ? 'positive' : 'negative'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMonthData = (n: number) => {
|
||||||
|
var month = props.data.find((x) => x.month == n)
|
||||||
|
if (!month) {
|
||||||
|
return {
|
||||||
|
balance: 0,
|
||||||
|
month: n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return month
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMonthBalance = (n: number) => {
|
||||||
|
var month = getMonthData(n)
|
||||||
|
return formatter.format(month.balance)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
48
BudgetApp.Web/src/components/dashboard/TransactionsTable.vue
Normal file
48
BudgetApp.Web/src/components/dashboard/TransactionsTable.vue
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Transactions</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
append-icon="mdi-magnify"
|
||||||
|
label="Search"
|
||||||
|
single-line
|
||||||
|
hide-details
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
></v-text-field>
|
||||||
|
<v-data-table :headers="headers" :items="data" :search="search">
|
||||||
|
<template v-slot:item.amount="{ item }">
|
||||||
|
<v-chip color="negative" label variant="flat" size="small">
|
||||||
|
{{ formatter.format(item.amount) }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { TransactionDto } from '@/models/TransactionModels'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { formatter } from '@/utils/CurrencyUtils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Array<TransactionDto>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const headers = ref<{ title: string; key: string }[]>([
|
||||||
|
{ title: 'Date', key: 'date' },
|
||||||
|
{ title: 'Amount', key: 'amount' },
|
||||||
|
{ title: 'Category', key: 'category' },
|
||||||
|
{ title: 'Note', key: 'note' },
|
||||||
|
{ title: 'Description', key: 'operationDescription' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const search = ref<string>('')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
68
BudgetApp.Web/src/components/dashboard/YearSummaryDetail.vue
Normal file
68
BudgetApp.Web/src/components/dashboard/YearSummaryDetail.vue
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<v-alert v-if="!data" type="warning"> NO DATA FOR SELECTED PERIOD </v-alert>
|
||||||
|
<div v-else>
|
||||||
|
<v-row>
|
||||||
|
<v-col>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Balance</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<comparative-chart
|
||||||
|
left-title="Expenses"
|
||||||
|
right-title="Income"
|
||||||
|
:left-data="expenses"
|
||||||
|
:right-data="income"
|
||||||
|
></comparative-chart>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="8">
|
||||||
|
<CategoriesChart :data="expenses"></CategoriesChart>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="4">
|
||||||
|
<monthly-balance-grid :data="data.months"></monthly-balance-grid>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { YearSummaryDto } from '@/models/TransactionModels'
|
||||||
|
import { computed, type PropType } from 'vue'
|
||||||
|
|
||||||
|
import ComparativeChart from '../ComparativeChart.vue'
|
||||||
|
import CategoriesChart from './CategoriesChart.vue'
|
||||||
|
import MonthlyBalanceGrid from './MonthlyBalanceGrid.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object as PropType<YearSummaryDto> | undefined,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const transactions = computed(() => {
|
||||||
|
if (!props.data) return []
|
||||||
|
const records = []
|
||||||
|
for (var m = 0; m < props.data.months.length; m++) {
|
||||||
|
const month = props.data.months[m]
|
||||||
|
if (!month) continue
|
||||||
|
for (var d = 0; d < month.days.length; d++) {
|
||||||
|
const day = month.days[d]
|
||||||
|
if (!day) continue
|
||||||
|
records.push(...day.transactions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
})
|
||||||
|
|
||||||
|
const expenses = computed(() => {
|
||||||
|
return transactions.value.filter((x) => x.amount <= 0)
|
||||||
|
})
|
||||||
|
const income = computed(() => {
|
||||||
|
return transactions.value.filter((x) => x.amount > 0)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
18
BudgetApp.Web/src/main.ts
Normal file
18
BudgetApp.Web/src/main.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
// Vuetify
|
||||||
|
import 'vuetify/styles'
|
||||||
|
import vuetify from './plugins/vuetify'
|
||||||
|
|
||||||
|
// ApexCharts
|
||||||
|
import VueApexCharts from 'vue3-apexcharts'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(vuetify)
|
||||||
|
app.use(router)
|
||||||
|
app.use(VueApexCharts)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
41
BudgetApp.Web/src/models/TransactionModels.ts
Normal file
41
BudgetApp.Web/src/models/TransactionModels.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
export type TransactionDto = {
|
||||||
|
id: number
|
||||||
|
date: string
|
||||||
|
amount: number
|
||||||
|
balance: number
|
||||||
|
operationDescription: string
|
||||||
|
note: string
|
||||||
|
category: string
|
||||||
|
transactionId: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
deleted: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthSummaryDto = {
|
||||||
|
month: number
|
||||||
|
balance: number
|
||||||
|
days: DaySummaryDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DaySummaryDto = {
|
||||||
|
date: string
|
||||||
|
balance: number
|
||||||
|
transactions: TransactionDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type YearSummaryDto = {
|
||||||
|
year: number
|
||||||
|
balance: number
|
||||||
|
months: MonthSummaryDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaseResponse = {
|
||||||
|
success: boolean
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResponse<T> extends BaseResponse {
|
||||||
|
data: T[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
35
BudgetApp.Web/src/plugins/vuetify.ts
Normal file
35
BudgetApp.Web/src/plugins/vuetify.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import 'vuetify/styles'
|
||||||
|
import { createVuetify } from 'vuetify'
|
||||||
|
import * as components from 'vuetify/components'
|
||||||
|
import * as directives from 'vuetify/directives'
|
||||||
|
|
||||||
|
const vuetify = createVuetify({
|
||||||
|
components,
|
||||||
|
directives,
|
||||||
|
theme: {
|
||||||
|
defaultTheme: 'light',
|
||||||
|
themes: {
|
||||||
|
light: {
|
||||||
|
colors: {
|
||||||
|
background: '#9ab9c9ff',
|
||||||
|
surface: '#ffffff',
|
||||||
|
primary: '#13949E',
|
||||||
|
secondary: '#00a0dc',
|
||||||
|
error: '#d20f22',
|
||||||
|
info: '#607D8B',
|
||||||
|
success: '#42b36b',
|
||||||
|
warning: '#e7900d',
|
||||||
|
light: '#B2DFDB',
|
||||||
|
highlight1: '#81d4fa',
|
||||||
|
highlight2: '#b3e5fc',
|
||||||
|
highlight3: '#e1f5fe',
|
||||||
|
disabled: '#bdbdbd',
|
||||||
|
positive: '#8BC34A',
|
||||||
|
negative: '#FF9800',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default vuetify
|
||||||
19
BudgetApp.Web/src/router/index.ts
Normal file
19
BudgetApp.Web/src/router/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
component: () => import('../App.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('../views/Dashboard.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
1
BudgetApp.Web/src/utils/CurrencyUtils.ts
Normal file
1
BudgetApp.Web/src/utils/CurrencyUtils.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const formatter = new Intl.NumberFormat('cs-CZ', { style: 'currency', currency: 'CZK' })
|
||||||
134
BudgetApp.Web/src/views/Dashboard.vue
Normal file
134
BudgetApp.Web/src/views/Dashboard.vue
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog v-if="loading == true" v-model="loading" width="640">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title> Loading data </v-card-title>
|
||||||
|
<v-list>
|
||||||
|
<v-list-item>
|
||||||
|
<v-list-item-title> Loading transactions </v-list-item-title>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
<v-container v-else="" grid-list-md>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-toolbar>
|
||||||
|
<v-btn icon @click="decreaseYear">
|
||||||
|
<v-icon>mdi-chevron-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-toolbar-title :text="`${selectedYear}`" class="text-center"></v-toolbar-title>
|
||||||
|
<v-btn icon @click="increaseYear">
|
||||||
|
<v-icon>mdi-chevron-right</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<v-toolbar>
|
||||||
|
<v-btn icon @click="decreaseMonth">
|
||||||
|
<v-icon>mdi-chevron-left</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-toolbar-title :text="selectedMonthName" class="text-center"></v-toolbar-title>
|
||||||
|
<v-btn icon @click="increaseMonth">
|
||||||
|
<v-icon>mdi-chevron-right</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="6">
|
||||||
|
<year-summary-detail :data="yearSummary"></year-summary-detail>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="6">
|
||||||
|
<month-summary-detail :data="monthSummary"></month-summary-detail>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { getTransactions } from '@/backend/ApiConnector'
|
||||||
|
import type { MonthSummaryDto, YearSummaryDto } from '@/models/TransactionModels'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import YearSummaryDetail from '@/components/dashboard/YearSummaryDetail.vue'
|
||||||
|
import MonthSummaryDetail from '@/components/dashboard/MonthSummaryDetail.vue'
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTransactions()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------- SELECTED PERIOD --------
|
||||||
|
|
||||||
|
const selectedYear = ref<number>(new Date().getFullYear())
|
||||||
|
const selectedMonth = ref<number>(new Date().getMonth() + 1)
|
||||||
|
|
||||||
|
const increaseMonth = () => {
|
||||||
|
if (selectedMonth.value === 12) {
|
||||||
|
selectedMonth.value = 1
|
||||||
|
selectedYear.value++
|
||||||
|
} else {
|
||||||
|
selectedMonth.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const decreaseMonth = () => {
|
||||||
|
if (selectedMonth.value === 1) {
|
||||||
|
selectedMonth.value = 12
|
||||||
|
selectedYear.value--
|
||||||
|
} else {
|
||||||
|
selectedMonth.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const increaseYear = () => {
|
||||||
|
selectedYear.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
const decreaseYear = () => {
|
||||||
|
selectedYear.value--
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMonthName = computed(() => {
|
||||||
|
return months.value.find((x) => x.value === selectedMonth.value)?.title
|
||||||
|
})
|
||||||
|
|
||||||
|
const months = ref<{ title: string; value: number }[]>([
|
||||||
|
{ title: 'January', value: 1 },
|
||||||
|
{ title: 'February', value: 2 },
|
||||||
|
{ title: 'March', value: 3 },
|
||||||
|
{ title: 'April', value: 4 },
|
||||||
|
{ title: 'May', value: 5 },
|
||||||
|
{ title: 'June', value: 6 },
|
||||||
|
{ title: 'July', value: 7 },
|
||||||
|
{ title: 'August', value: 8 },
|
||||||
|
{ title: 'September', value: 9 },
|
||||||
|
{ title: 'October', value: 10 },
|
||||||
|
{ title: 'November', value: 11 },
|
||||||
|
{ title: 'December', value: 12 },
|
||||||
|
])
|
||||||
|
|
||||||
|
// -------- TRANSACTIONS --------
|
||||||
|
|
||||||
|
const loading = ref<boolean>(true)
|
||||||
|
const data = ref<YearSummaryDto[]>([])
|
||||||
|
|
||||||
|
const yearSummary = computed((): YearSummaryDto | undefined => {
|
||||||
|
return data.value.find((x) => x.year === selectedYear.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthSummary = computed((): MonthSummaryDto | undefined => {
|
||||||
|
return yearSummary.value?.months.find((x) => x.month === selectedMonth.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadTransactions = async () => {
|
||||||
|
const response = await getTransactions()
|
||||||
|
data.value = response.data
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
12
BudgetApp.Web/tsconfig.app.json
Normal file
12
BudgetApp.Web/tsconfig.app.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
BudgetApp.Web/tsconfig.json
Normal file
11
BudgetApp.Web/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
BudgetApp.Web/tsconfig.node.json
Normal file
19
BudgetApp.Web/tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
18
BudgetApp.Web/vite.config.ts
Normal file
18
BudgetApp.Web/vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
49
BudgetApp.sln
Normal file
49
BudgetApp.sln
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BudgetApp.Api", "BudgetApp.Api\BudgetApp.Api.csproj", "{245A49CC-CCD4-435E-B42E-F5616E22D647}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BudgetApp.Storage", "BudgetApp.Storage\BudgetApp.Storage.csproj", "{FF67026C-56D0-4DEA-A51F-19726A73F33C}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BudgetApp.Services", "BudgetApp.Services\BudgetApp.Services.csproj", "{DC80024E-B612-46D9-8FF1-C80109D0D5BB}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BudgetApp.PublicModels", "BudgetApp.PublicModels\BudgetApp.PublicModels.csproj", "{F6494EC1-7ED5-4A41-9827-3DF13C9AE228}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BudgetApp.Enums", "BudgetApp.Enums\BudgetApp.Enums.csproj", "{AC64A7E4-EB9B-4585-8007-34C07B716DAB}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{245A49CC-CCD4-435E-B42E-F5616E22D647}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{245A49CC-CCD4-435E-B42E-F5616E22D647}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{245A49CC-CCD4-435E-B42E-F5616E22D647}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{245A49CC-CCD4-435E-B42E-F5616E22D647}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{FF67026C-56D0-4DEA-A51F-19726A73F33C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{FF67026C-56D0-4DEA-A51F-19726A73F33C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{FF67026C-56D0-4DEA-A51F-19726A73F33C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{FF67026C-56D0-4DEA-A51F-19726A73F33C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{DC80024E-B612-46D9-8FF1-C80109D0D5BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{DC80024E-B612-46D9-8FF1-C80109D0D5BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{DC80024E-B612-46D9-8FF1-C80109D0D5BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{DC80024E-B612-46D9-8FF1-C80109D0D5BB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{F6494EC1-7ED5-4A41-9827-3DF13C9AE228}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{F6494EC1-7ED5-4A41-9827-3DF13C9AE228}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{F6494EC1-7ED5-4A41-9827-3DF13C9AE228}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{F6494EC1-7ED5-4A41-9827-3DF13C9AE228}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{AC64A7E4-EB9B-4585-8007-34C07B716DAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{AC64A7E4-EB9B-4585-8007-34C07B716DAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{AC64A7E4-EB9B-4585-8007-34C07B716DAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{AC64A7E4-EB9B-4585-8007-34C07B716DAB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {E54C9C88-FD4B-4F49-A96A-8C50367C763B}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
Loading…
Reference in New Issue
Block a user