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