Initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Martin Svrcina 2026-03-21 01:52:43 +01:00
commit e14e552388
61 changed files with 5920 additions and 0 deletions

51
.gitignore vendored Normal file
View 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

View 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>

View File

@ -0,0 +1,6 @@
@BudgetApp.Api_HostAddress = http://localhost:5240
GET {{BudgetApp.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View 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

View 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);
}
}
}

View 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
View 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();

View 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"
}
}
}
}

View File

@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"MainDatabase": ""
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace BudgetApp.Enums
{
public enum UpdateStatusState
{
Processing = 0,
Done = 1
}
}

View 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; }
}
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View 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; }
}
}

View 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>

View 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);
}
}

View 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);
}
}

View 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; }
}
}

View 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>

View 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.");
}
}
}
}

View 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
}
}
}

View 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");
}
}
}

View 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
}
}
}

View 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");
}
}
}

View 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
}
}
}

View File

@ -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");
}
}
}

View 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
}
}
}

View 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;
}
}

View 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
View File

@ -0,0 +1 @@
* text=auto eol=lf

36
BudgetApp.Web/.gitignore vendored Normal file
View 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__/

View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

42
BudgetApp.Web/README.md Normal file
View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

17
BudgetApp.Web/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

171
BudgetApp.Web/src/App.vue Normal file
View 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>

View 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>
}

View 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
)
}

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View 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>

View 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
View 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')

View 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
}

View 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

View 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

View File

@ -0,0 +1 @@
export const formatter = new Intl.NumberFormat('cs-CZ', { style: 'currency', currency: 'CZK' })

View 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>

View 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/*"]
}
}
}

View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View 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"]
}
}

View 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
View 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