Compare commits
No commits in common. "b71628290c9036e0fb1f56fba3b58692971a5451" and "c9b40f980a0eabfd8379013249de099c99468c92" have entirely different histories.
b71628290c
...
c9b40f980a
831
.gitignore
vendored
831
.gitignore
vendored
@ -1,417 +1,414 @@
|
|||||||
# ---> VisualStudio
|
# ---> VisualStudio
|
||||||
## Ignore Visual Studio temporary files, build results, and
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
## files generated by popular Visual Studio add-ons.
|
## files generated by popular Visual Studio add-ons.
|
||||||
##
|
##
|
||||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
*.suo
|
*.suo
|
||||||
*.user
|
*.user
|
||||||
*.userosscache
|
*.userosscache
|
||||||
*.sln.docstates
|
*.sln.docstates
|
||||||
|
|
||||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
*.userprefs
|
*.userprefs
|
||||||
|
|
||||||
# Mono auto generated files
|
# Mono auto generated files
|
||||||
mono_crash.*
|
mono_crash.*
|
||||||
|
|
||||||
# Build results
|
# Build results
|
||||||
[Dd]ebug/
|
[Dd]ebug/
|
||||||
[Dd]ebugPublic/
|
[Dd]ebugPublic/
|
||||||
[Rr]elease/
|
[Rr]elease/
|
||||||
[Rr]eleases/
|
[Rr]eleases/
|
||||||
x64/
|
x64/
|
||||||
x86/
|
x86/
|
||||||
[Ww][Ii][Nn]32/
|
[Ww][Ii][Nn]32/
|
||||||
[Aa][Rr][Mm]/
|
[Aa][Rr][Mm]/
|
||||||
[Aa][Rr][Mm]64/
|
[Aa][Rr][Mm]64/
|
||||||
bld/
|
bld/
|
||||||
[Bb]in/
|
[Bb]in/
|
||||||
[Oo]bj/
|
[Oo]bj/
|
||||||
[Ll]og/
|
[Ll]og/
|
||||||
[Ll]ogs/
|
[Ll]ogs/
|
||||||
|
|
||||||
# Visual Studio 2015/2017 cache/options directory
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
.vs/
|
.vs/
|
||||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
#wwwroot/
|
#wwwroot/
|
||||||
|
|
||||||
# Visual Studio 2017 auto generated files
|
# Visual Studio 2017 auto generated files
|
||||||
Generated\ Files/
|
Generated\ Files/
|
||||||
|
|
||||||
# MSTest test Results
|
# MSTest test Results
|
||||||
[Tt]est[Rr]esult*/
|
[Tt]est[Rr]esult*/
|
||||||
[Bb]uild[Ll]og.*
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
# NUnit
|
# NUnit
|
||||||
*.VisualState.xml
|
*.VisualState.xml
|
||||||
TestResult.xml
|
TestResult.xml
|
||||||
nunit-*.xml
|
nunit-*.xml
|
||||||
|
|
||||||
# Build Results of an ATL Project
|
# Build Results of an ATL Project
|
||||||
[Dd]ebugPS/
|
[Dd]ebugPS/
|
||||||
[Rr]eleasePS/
|
[Rr]eleasePS/
|
||||||
dlldata.c
|
dlldata.c
|
||||||
|
|
||||||
# Benchmark Results
|
# Benchmark Results
|
||||||
BenchmarkDotNet.Artifacts/
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
# .NET Core
|
# .NET Core
|
||||||
project.lock.json
|
project.lock.json
|
||||||
project.fragment.lock.json
|
project.fragment.lock.json
|
||||||
artifacts/
|
artifacts/
|
||||||
|
|
||||||
# ASP.NET Scaffolding
|
# ASP.NET Scaffolding
|
||||||
ScaffoldingReadMe.txt
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
# StyleCop
|
# StyleCop
|
||||||
StyleCopReport.xml
|
StyleCopReport.xml
|
||||||
|
|
||||||
# Files built by Visual Studio
|
# Files built by Visual Studio
|
||||||
*_i.c
|
*_i.c
|
||||||
*_p.c
|
*_p.c
|
||||||
*_h.h
|
*_h.h
|
||||||
*.ilk
|
*.ilk
|
||||||
*.meta
|
*.meta
|
||||||
*.obj
|
*.obj
|
||||||
*.iobj
|
*.iobj
|
||||||
*.pch
|
*.pch
|
||||||
*.pdb
|
*.pdb
|
||||||
*.ipdb
|
*.ipdb
|
||||||
*.pgc
|
*.pgc
|
||||||
*.pgd
|
*.pgd
|
||||||
*.rsp
|
*.rsp
|
||||||
*.sbr
|
*.sbr
|
||||||
*.tlb
|
*.tlb
|
||||||
*.tli
|
*.tli
|
||||||
*.tlh
|
*.tlh
|
||||||
*.tmp
|
*.tmp
|
||||||
*.tmp_proj
|
*.tmp_proj
|
||||||
*_wpftmp.csproj
|
*_wpftmp.csproj
|
||||||
*.log
|
*.log
|
||||||
*.tlog
|
*.tlog
|
||||||
*.vspscc
|
*.vspscc
|
||||||
*.vssscc
|
*.vssscc
|
||||||
.builds
|
.builds
|
||||||
*.pidb
|
*.pidb
|
||||||
*.svclog
|
*.svclog
|
||||||
*.scc
|
*.scc
|
||||||
|
|
||||||
# Chutzpah Test files
|
# Chutzpah Test files
|
||||||
_Chutzpah*
|
_Chutzpah*
|
||||||
|
|
||||||
# Visual C++ cache files
|
# Visual C++ cache files
|
||||||
ipch/
|
ipch/
|
||||||
*.aps
|
*.aps
|
||||||
*.ncb
|
*.ncb
|
||||||
*.opendb
|
*.opendb
|
||||||
*.opensdf
|
*.opensdf
|
||||||
*.sdf
|
*.sdf
|
||||||
*.cachefile
|
*.cachefile
|
||||||
*.VC.db
|
*.VC.db
|
||||||
*.VC.VC.opendb
|
*.VC.VC.opendb
|
||||||
|
|
||||||
# Visual Studio profiler
|
# Visual Studio profiler
|
||||||
*.psess
|
*.psess
|
||||||
*.vsp
|
*.vsp
|
||||||
*.vspx
|
*.vspx
|
||||||
*.sap
|
*.sap
|
||||||
|
|
||||||
# Visual Studio Trace Files
|
# Visual Studio Trace Files
|
||||||
*.e2e
|
*.e2e
|
||||||
|
|
||||||
# TFS 2012 Local Workspace
|
# TFS 2012 Local Workspace
|
||||||
$tf/
|
$tf/
|
||||||
|
|
||||||
# Guidance Automation Toolkit
|
# Guidance Automation Toolkit
|
||||||
*.gpState
|
*.gpState
|
||||||
|
|
||||||
# ReSharper is a .NET coding add-in
|
# ReSharper is a .NET coding add-in
|
||||||
_ReSharper*/
|
_ReSharper*/
|
||||||
*.[Rr]e[Ss]harper
|
*.[Rr]e[Ss]harper
|
||||||
*.DotSettings.user
|
*.DotSettings.user
|
||||||
|
|
||||||
# TeamCity is a build add-in
|
# TeamCity is a build add-in
|
||||||
_TeamCity*
|
_TeamCity*
|
||||||
|
|
||||||
# DotCover is a Code Coverage Tool
|
# DotCover is a Code Coverage Tool
|
||||||
*.dotCover
|
*.dotCover
|
||||||
|
|
||||||
# AxoCover is a Code Coverage Tool
|
# AxoCover is a Code Coverage Tool
|
||||||
.axoCover/*
|
.axoCover/*
|
||||||
!.axoCover/settings.json
|
!.axoCover/settings.json
|
||||||
|
|
||||||
# Coverlet is a free, cross platform Code Coverage Tool
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
coverage*.json
|
coverage*.json
|
||||||
coverage*.xml
|
coverage*.xml
|
||||||
coverage*.info
|
coverage*.info
|
||||||
|
|
||||||
# Visual Studio code coverage results
|
# Visual Studio code coverage results
|
||||||
*.coverage
|
*.coverage
|
||||||
*.coveragexml
|
*.coveragexml
|
||||||
|
|
||||||
# NCrunch
|
# NCrunch
|
||||||
_NCrunch_*
|
_NCrunch_*
|
||||||
.*crunch*.local.xml
|
.*crunch*.local.xml
|
||||||
nCrunchTemp_*
|
nCrunchTemp_*
|
||||||
|
|
||||||
# MightyMoose
|
# MightyMoose
|
||||||
*.mm.*
|
*.mm.*
|
||||||
AutoTest.Net/
|
AutoTest.Net/
|
||||||
|
|
||||||
# Web workbench (sass)
|
# Web workbench (sass)
|
||||||
.sass-cache/
|
.sass-cache/
|
||||||
|
|
||||||
# Installshield output folder
|
# Installshield output folder
|
||||||
[Ee]xpress/
|
[Ee]xpress/
|
||||||
|
|
||||||
# DocProject is a documentation generator add-in
|
# DocProject is a documentation generator add-in
|
||||||
DocProject/buildhelp/
|
DocProject/buildhelp/
|
||||||
DocProject/Help/*.HxT
|
DocProject/Help/*.HxT
|
||||||
DocProject/Help/*.HxC
|
DocProject/Help/*.HxC
|
||||||
DocProject/Help/*.hhc
|
DocProject/Help/*.hhc
|
||||||
DocProject/Help/*.hhk
|
DocProject/Help/*.hhk
|
||||||
DocProject/Help/*.hhp
|
DocProject/Help/*.hhp
|
||||||
DocProject/Help/Html2
|
DocProject/Help/Html2
|
||||||
DocProject/Help/html
|
DocProject/Help/html
|
||||||
|
|
||||||
# Click-Once directory
|
# Click-Once directory
|
||||||
publish/
|
publish/
|
||||||
|
|
||||||
# Publish Web Output
|
# Publish Web Output
|
||||||
*.[Pp]ublish.xml
|
*.[Pp]ublish.xml
|
||||||
*.azurePubxml
|
*.azurePubxml
|
||||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
# but database connection strings (with potential passwords) will be unencrypted
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
*.pubxml
|
*.pubxml
|
||||||
*.publishproj
|
*.publishproj
|
||||||
|
|
||||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
# in these scripts will be unencrypted
|
# in these scripts will be unencrypted
|
||||||
PublishScripts/
|
PublishScripts/
|
||||||
|
|
||||||
# NuGet Packages
|
# NuGet Packages
|
||||||
*.nupkg
|
*.nupkg
|
||||||
# NuGet Symbol Packages
|
# NuGet Symbol Packages
|
||||||
*.snupkg
|
*.snupkg
|
||||||
# The packages folder can be ignored because of Package Restore
|
# The packages folder can be ignored because of Package Restore
|
||||||
**/[Pp]ackages/*
|
**/[Pp]ackages/*
|
||||||
# except build/, which is used as an MSBuild target.
|
# except build/, which is used as an MSBuild target.
|
||||||
!**/[Pp]ackages/build/
|
!**/[Pp]ackages/build/
|
||||||
# Uncomment if necessary however generally it will be regenerated when needed
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
#!**/[Pp]ackages/repositories.config
|
#!**/[Pp]ackages/repositories.config
|
||||||
# NuGet v3's project.json files produces more ignorable files
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
*.nuget.props
|
*.nuget.props
|
||||||
*.nuget.targets
|
*.nuget.targets
|
||||||
|
|
||||||
# Microsoft Azure Build Output
|
# Microsoft Azure Build Output
|
||||||
csx/
|
csx/
|
||||||
*.build.csdef
|
*.build.csdef
|
||||||
|
|
||||||
# Microsoft Azure Emulator
|
# Microsoft Azure Emulator
|
||||||
ecf/
|
ecf/
|
||||||
rcf/
|
rcf/
|
||||||
|
|
||||||
# Windows Store app package directories and files
|
# Windows Store app package directories and files
|
||||||
AppPackages/
|
AppPackages/
|
||||||
BundleArtifacts/
|
BundleArtifacts/
|
||||||
Package.StoreAssociation.xml
|
Package.StoreAssociation.xml
|
||||||
_pkginfo.txt
|
_pkginfo.txt
|
||||||
*.appx
|
*.appx
|
||||||
*.appxbundle
|
*.appxbundle
|
||||||
*.appxupload
|
*.appxupload
|
||||||
|
|
||||||
# Visual Studio cache files
|
# Visual Studio cache files
|
||||||
# files ending in .cache can be ignored
|
# files ending in .cache can be ignored
|
||||||
*.[Cc]ache
|
*.[Cc]ache
|
||||||
# but keep track of directories ending in .cache
|
# but keep track of directories ending in .cache
|
||||||
!?*.[Cc]ache/
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
ClientBin/
|
ClientBin/
|
||||||
~$*
|
~$*
|
||||||
*~
|
*~
|
||||||
*.dbmdl
|
*.dbmdl
|
||||||
*.dbproj.schemaview
|
*.dbproj.schemaview
|
||||||
*.jfm
|
*.jfm
|
||||||
*.pfx
|
*.pfx
|
||||||
*.publishsettings
|
*.publishsettings
|
||||||
orleans.codegen.cs
|
orleans.codegen.cs
|
||||||
|
|
||||||
# Including strong name files can present a security risk
|
# Including strong name files can present a security risk
|
||||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
#*.snk
|
#*.snk
|
||||||
|
|
||||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
#bower_components/
|
#bower_components/
|
||||||
|
|
||||||
# RIA/Silverlight projects
|
# RIA/Silverlight projects
|
||||||
Generated_Code/
|
Generated_Code/
|
||||||
|
|
||||||
# Backup & report files from converting an old project file
|
# Backup & report files from converting an old project file
|
||||||
# to a newer Visual Studio version. Backup files are not needed,
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
# because we have git ;-)
|
# because we have git ;-)
|
||||||
_UpgradeReport_Files/
|
_UpgradeReport_Files/
|
||||||
Backup*/
|
Backup*/
|
||||||
UpgradeLog*.XML
|
UpgradeLog*.XML
|
||||||
UpgradeLog*.htm
|
UpgradeLog*.htm
|
||||||
ServiceFabricBackup/
|
ServiceFabricBackup/
|
||||||
*.rptproj.bak
|
*.rptproj.bak
|
||||||
|
|
||||||
# SQL Server files
|
# SQL Server files
|
||||||
*.mdf
|
*.mdf
|
||||||
*.ldf
|
*.ldf
|
||||||
*.ndf
|
*.ndf
|
||||||
|
|
||||||
# Business Intelligence projects
|
# Business Intelligence projects
|
||||||
*.rdl.data
|
*.rdl.data
|
||||||
*.bim.layout
|
*.bim.layout
|
||||||
*.bim_*.settings
|
*.bim_*.settings
|
||||||
*.rptproj.rsuser
|
*.rptproj.rsuser
|
||||||
*- [Bb]ackup.rdl
|
*- [Bb]ackup.rdl
|
||||||
*- [Bb]ackup ([0-9]).rdl
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
# Microsoft Fakes
|
# Microsoft Fakes
|
||||||
FakesAssemblies/
|
FakesAssemblies/
|
||||||
|
|
||||||
# GhostDoc plugin setting file
|
# GhostDoc plugin setting file
|
||||||
*.GhostDoc.xml
|
*.GhostDoc.xml
|
||||||
|
|
||||||
# Node.js Tools for Visual Studio
|
# Node.js Tools for Visual Studio
|
||||||
.ntvs_analysis.dat
|
.ntvs_analysis.dat
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Visual Studio 6 build log
|
# Visual Studio 6 build log
|
||||||
*.plg
|
*.plg
|
||||||
|
|
||||||
# Visual Studio 6 workspace options file
|
# Visual Studio 6 workspace options file
|
||||||
*.opt
|
*.opt
|
||||||
|
|
||||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
*.vbw
|
*.vbw
|
||||||
|
|
||||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
*.vbp
|
*.vbp
|
||||||
|
|
||||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
*.dsw
|
*.dsw
|
||||||
*.dsp
|
*.dsp
|
||||||
|
|
||||||
# Visual Studio 6 technical files
|
# Visual Studio 6 technical files
|
||||||
*.ncb
|
*.ncb
|
||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
# Visual Studio LightSwitch build output
|
# Visual Studio LightSwitch build output
|
||||||
**/*.HTMLClient/GeneratedArtifacts
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
**/*.DesktopClient/GeneratedArtifacts
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
**/*.DesktopClient/ModelManifest.xml
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
**/*.Server/GeneratedArtifacts
|
**/*.Server/GeneratedArtifacts
|
||||||
**/*.Server/ModelManifest.xml
|
**/*.Server/ModelManifest.xml
|
||||||
_Pvt_Extensions
|
_Pvt_Extensions
|
||||||
|
|
||||||
# Paket dependency manager
|
# Paket dependency manager
|
||||||
.paket/paket.exe
|
.paket/paket.exe
|
||||||
paket-files/
|
paket-files/
|
||||||
|
|
||||||
# FAKE - F# Make
|
# FAKE - F# Make
|
||||||
.fake/
|
.fake/
|
||||||
|
|
||||||
# CodeRush personal settings
|
# CodeRush personal settings
|
||||||
.cr/personal
|
.cr/personal
|
||||||
|
|
||||||
# Python Tools for Visual Studio (PTVS)
|
# Python Tools for Visual Studio (PTVS)
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Cake - Uncomment if you are using it
|
# Cake - Uncomment if you are using it
|
||||||
# tools/**
|
# tools/**
|
||||||
# !tools/packages.config
|
# !tools/packages.config
|
||||||
|
|
||||||
# Tabs Studio
|
# Tabs Studio
|
||||||
*.tss
|
*.tss
|
||||||
|
|
||||||
# Telerik's JustMock configuration file
|
# Telerik's JustMock configuration file
|
||||||
*.jmconfig
|
*.jmconfig
|
||||||
|
|
||||||
# BizTalk build output
|
# BizTalk build output
|
||||||
*.btp.cs
|
*.btp.cs
|
||||||
*.btm.cs
|
*.btm.cs
|
||||||
*.odx.cs
|
*.odx.cs
|
||||||
*.xsd.cs
|
*.xsd.cs
|
||||||
|
|
||||||
# OpenCover UI analysis results
|
# OpenCover UI analysis results
|
||||||
OpenCover/
|
OpenCover/
|
||||||
|
|
||||||
# Azure Stream Analytics local run output
|
# Azure Stream Analytics local run output
|
||||||
ASALocalRun/
|
ASALocalRun/
|
||||||
|
|
||||||
# MSBuild Binary and Structured Log
|
# MSBuild Binary and Structured Log
|
||||||
*.binlog
|
*.binlog
|
||||||
|
|
||||||
# NVidia Nsight GPU debugger configuration file
|
# NVidia Nsight GPU debugger configuration file
|
||||||
*.nvuser
|
*.nvuser
|
||||||
|
|
||||||
# MFractors (Xamarin productivity tool) working folder
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
.mfractor/
|
.mfractor/
|
||||||
|
|
||||||
# Local History for Visual Studio
|
# Local History for Visual Studio
|
||||||
.localhistory/
|
.localhistory/
|
||||||
|
|
||||||
# Visual Studio History (VSHistory) files
|
# Visual Studio History (VSHistory) files
|
||||||
.vshistory/
|
.vshistory/
|
||||||
|
|
||||||
# BeatPulse healthcheck temp database
|
# BeatPulse healthcheck temp database
|
||||||
healthchecksdb
|
healthchecksdb
|
||||||
|
|
||||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
MigrationBackup/
|
MigrationBackup/
|
||||||
|
|
||||||
# Ionide (cross platform F# VS Code tools) working folder
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
.ionide/
|
.ionide/
|
||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
# VS Code files for those working on multiple tools
|
# VS Code files for those working on multiple tools
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
|
|
||||||
# Local History for Visual Studio Code
|
# Local History for Visual Studio Code
|
||||||
.history/
|
.history/
|
||||||
|
|
||||||
# Windows Installer files from build outputs
|
# Windows Installer files from build outputs
|
||||||
*.cab
|
*.cab
|
||||||
*.msi
|
*.msi
|
||||||
*.msix
|
*.msix
|
||||||
*.msm
|
*.msm
|
||||||
*.msp
|
*.msp
|
||||||
|
|
||||||
# JetBrains Rider
|
# JetBrains Rider
|
||||||
*.sln.iml
|
*.sln.iml
|
||||||
|
|
||||||
# Superpowers brainstorming sessions
|
# ---> VisualStudioCode
|
||||||
.superpowers/
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
# ---> VisualStudioCode
|
!.vscode/tasks.json
|
||||||
.vscode/*
|
!.vscode/launch.json
|
||||||
!.vscode/settings.json
|
!.vscode/extensions.json
|
||||||
!.vscode/tasks.json
|
!.vscode/*.code-snippets
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
# Local History for Visual Studio Code
|
||||||
!.vscode/*.code-snippets
|
.history/
|
||||||
|
|
||||||
# Local History for Visual Studio Code
|
# Built Visual Studio Code Extensions
|
||||||
.history/
|
*.vsix
|
||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
|
||||||
*.vsix
|
|
||||||
|
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.0.31903.59
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{1A128832-A516-43AF-B7A5-3124FC2F7A74}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountTracking.Api", "src\AccountTracking.Api\AccountTracking.Api.csproj", "{F173558B-FA34-4B12-A545-BDB64BA7CDCB}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AccountTracking.Api.Tests", "src\AccountTracking.Api.Tests\AccountTracking.Api.Tests.csproj", "{0986E9DA-6758-4FA1-863F-9A60A35D86E5}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{F173558B-FA34-4B12-A545-BDB64BA7CDCB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{F173558B-FA34-4B12-A545-BDB64BA7CDCB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{F173558B-FA34-4B12-A545-BDB64BA7CDCB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{F173558B-FA34-4B12-A545-BDB64BA7CDCB}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{0986E9DA-6758-4FA1-863F-9A60A35D86E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{0986E9DA-6758-4FA1-863F-9A60A35D86E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{0986E9DA-6758-4FA1-863F-9A60A35D86E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{0986E9DA-6758-4FA1-863F-9A60A35D86E5}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(NestedProjects) = preSolution
|
|
||||||
{F173558B-FA34-4B12-A545-BDB64BA7CDCB} = {1A128832-A516-43AF-B7A5-3124FC2F7A74}
|
|
||||||
{0986E9DA-6758-4FA1-863F-9A60A35D86E5} = {1A128832-A516-43AF-B7A5-3124FC2F7A74}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
18
LICENSE
18
LICENSE
@ -1,9 +1,9 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 martin
|
Copyright (c) 2026 martin
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,298 +0,0 @@
|
|||||||
# Finance Tracker — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-03-19
|
|
||||||
**Stack:** .NET 8 · MySQL 8 · Vue.js 3 · Vuetify 3 · NSwag · ApexCharts · Docker
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A personal web app for tracking finances. The primary workflow is importing CSV bank exports (ČSOB format) and visualising spending over time. Single-user with a login screen for security. Deployed via docker-compose on a home server behind an existing Nginx reverse proxy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Three docker-compose services sharing an internal network:
|
|
||||||
|
|
||||||
| Service | Image | Exposed Port | Role |
|
|
||||||
|---------|-------|-------------|------|
|
|
||||||
| `api` | .NET 8 (custom) | 5000 | REST API + business logic |
|
|
||||||
| `web` | Nginx (custom, serves Vue build) | 3000 | Frontend SPA |
|
|
||||||
| `db` | MySQL 8 | 3306 (internal only) | Persistence |
|
|
||||||
|
|
||||||
**Host Nginx routing** — the .NET API mounts all routes under `/api/...`, so Nginx passes the full URI through without stripping the prefix:
|
|
||||||
```nginx
|
|
||||||
location /api/ { proxy_pass http://localhost:5000; }
|
|
||||||
location / { proxy_pass http://localhost:3000; }
|
|
||||||
```
|
|
||||||
|
|
||||||
**CORS** — the API enables CORS for `http://localhost:3000` (dev) and the production frontend origin via the `ALLOWED_ORIGIN` env var.
|
|
||||||
|
|
||||||
Secrets provided via `.env` file (not committed to git):
|
|
||||||
- `DB_CONNECTION` — MySQL connection string
|
|
||||||
- `JWT_SECRET` — 32-byte random value, Base64-encoded (e.g. `openssl rand -base64 32`)
|
|
||||||
- `APP_USERNAME` / `APP_PASSWORD` — single user credentials (password stored as bcrypt hash in `.env`)
|
|
||||||
- `ALLOWED_ORIGIN` — frontend origin for CORS
|
|
||||||
|
|
||||||
**DB persistence & migrations** — a named Docker volume persists MySQL data across restarts. The `db` service has a health check (`mysqladmin ping`). The `api` service depends on `db` with `condition: service_healthy`. Schema is managed via EF Core migrations, applied automatically at API startup using Polly retry: 5 attempts, exponential backoff (1s, 2s, 4s, 8s, 16s). If all attempts fail, the container exits.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## NSwag Client Generation
|
|
||||||
|
|
||||||
The TypeScript API client is generated from the .NET OpenAPI spec at build time — no running API or database is needed.
|
|
||||||
|
|
||||||
**Mechanism:**
|
|
||||||
1. `AccountTracking.Api` uses the `NSwag.MSBuild` NuGet package, which generates `openapi.json` into the project output directory as part of `dotnet build`.
|
|
||||||
2. The `AccountTracking.Web` Dockerfile is a two-stage build:
|
|
||||||
- **Stage 1** (`sdk`): runs `dotnet build` on the API project (no DB needed) — produces `openapi.json`.
|
|
||||||
- **Stage 2** (`node`): copies `openapi.json` from stage 1, runs `nswag run nswag.json` to generate the TypeScript client into `src/api/`, then runs `npm run build`.
|
|
||||||
3. The final image is Nginx serving the Vue build output.
|
|
||||||
|
|
||||||
**NSwag client base URL:** configured as `/api` (relative), so it works identically in development (through Vite's dev server proxy) and production (through Nginx proxy). No build-time URL argument needed.
|
|
||||||
|
|
||||||
**Vite dev proxy** — `vite.config.ts` proxies `/api` → `http://localhost:5000` with no path rewriting (since the API mounts all routes under `/api/...`):
|
|
||||||
```ts
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': { target: 'http://localhost:5000', changeOrigin: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
All `DATETIME` columns are stored as UTC. EF Core models use `DateTimeKind.Utc` on datetime properties (required when using Pomelo's MySQL EF Core provider to avoid implicit UTC conversion issues).
|
|
||||||
|
|
||||||
### `transactions`
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| id | INT PK AUTO_INCREMENT | |
|
|
||||||
| account_number | VARCHAR(30) | e.g. `216868554/0300` |
|
|
||||||
| booking_date | DATE | |
|
|
||||||
| amount | DECIMAL(15,2) | negative = outgoing |
|
|
||||||
| currency | VARCHAR(3) | CZK |
|
|
||||||
| balance | DECIMAL(15,2) | account balance after transaction |
|
|
||||||
| counter_party_name | VARCHAR(255) | merchant or sender name |
|
|
||||||
| operation_description | VARCHAR(255) | e.g. "Transakce platební kartou" |
|
|
||||||
| message | TEXT | full description from CSV |
|
|
||||||
| category | VARCHAR(100) | from bank CSV, used as-is |
|
|
||||||
| variable_symbol | VARCHAR(30) | |
|
|
||||||
| bank_note | VARCHAR(255) | "vlastní poznámka" from CSV (read-only, not user-editable) |
|
|
||||||
| transaction_id | VARCHAR(512) UNIQUE, collation utf8mb4_bin | case-sensitive; format `transhist-v-YYYY-MM_<sha>` |
|
|
||||||
|
|
||||||
Additional CSV columns (counter bank code, constant symbol, specific symbol, order name, exchange rate, E2E id, payer reference, original payer, final recipient, original transaction) stored as nullable VARCHAR(255) but not used in UI.
|
|
||||||
|
|
||||||
### `import_logs`
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| id | INT PK AUTO_INCREMENT | |
|
|
||||||
| imported_at | DATETIME | stored as UTC |
|
|
||||||
| filename | VARCHAR(255) | original filename |
|
|
||||||
| records_imported | INT | new transactions saved |
|
|
||||||
| records_skipped | INT | duplicates ignored |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ČSOB CSV Format
|
|
||||||
|
|
||||||
The CSV is semicolon-delimited, UTF-8. Lines 1–2 are a bank-generated title. The parser locates the header row by scanning for the first line that starts with `číslo účtu` (case-insensitive) rather than hardcoding a line number.
|
|
||||||
|
|
||||||
Column mapping:
|
|
||||||
|
|
||||||
| CSV Column (Czech) | Model Field |
|
|
||||||
|-------------------|-------------|
|
|
||||||
| číslo účtu | account_number |
|
|
||||||
| datum zaúčtování | booking_date |
|
|
||||||
| částka | amount |
|
|
||||||
| měna | currency |
|
|
||||||
| zůstatek | balance |
|
|
||||||
| jméno protistrany | counter_party_name |
|
|
||||||
| označení operace | operation_description |
|
|
||||||
| zpráva | message |
|
|
||||||
| kategorie | category |
|
|
||||||
| variabilní symbol | variable_symbol |
|
|
||||||
| vlastní poznámka | bank_note |
|
|
||||||
| ID transakce | transaction_id |
|
|
||||||
|
|
||||||
Amount and balance values use Czech locale formatting (comma as decimal separator, space as thousands separator) — normalise before parsing (remove spaces, replace comma with dot).
|
|
||||||
|
|
||||||
**Error handling:**
|
|
||||||
- Header row not found, or required columns (`částka`, `ID transakce`, `datum zaúčtování`) missing → HTTP 400 `{ error: string }`
|
|
||||||
- File exceeds 10 MB → HTTP 400 `{ error: "File too large" }` (Kestrel `MaxRequestBodySize` set to 10 MB; framework error caught and returned as structured response)
|
|
||||||
- Zero new transactions and zero duplicates → HTTP 200 `{ recordsImported: 0, recordsSkipped: 0 }`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
All endpoints except login require `Authorization: Bearer <token>`. JWT tokens expire after **30 days**; `expiresAt` is an ISO 8601 UTC string (e.g. `"2026-04-18T10:00:00Z"`). The frontend stores both `token` and `expiresAt` in `localStorage`. The Vue Router navigation guard checks `Date.now() < Date.parse(expiresAt)` to detect expiry locally. On any 401 API response, the auth store clears both values and redirects to `/login`.
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/auth/login
|
|
||||||
Body: { username, password }
|
|
||||||
Returns 200: { token, expiresAt }
|
|
||||||
Returns 401: wrong credentials
|
|
||||||
|
|
||||||
POST /api/transactions/import
|
|
||||||
Body: multipart/form-data (CSV file, max 10 MB)
|
|
||||||
Returns 200: { recordsImported, recordsSkipped }
|
|
||||||
Returns 400: { error: string }
|
|
||||||
|
|
||||||
GET /api/transactions
|
|
||||||
Query: year (required), month (optional), category (optional),
|
|
||||||
search (optional), page (default 1), pageSize (default 50, max 200)
|
|
||||||
— search: case-insensitive contains match (%term%) on counter_party_name
|
|
||||||
OR message; uses database collation (utf8mb4_unicode_ci)
|
|
||||||
Returns 200: { items: [...], totalCount, page, pageSize }
|
|
||||||
|
|
||||||
GET /api/transactions/categories
|
|
||||||
No query params — returns all-time distinct categories (not period-filtered)
|
|
||||||
Returns 200: string[] sorted alphabetically
|
|
||||||
|
|
||||||
GET /api/dashboard/summary
|
|
||||||
Query: year (required), month (optional)
|
|
||||||
— if month omitted: totals are for the entire year
|
|
||||||
— if month provided: totals are for that month only
|
|
||||||
Returns 200: { totalSpent, totalIncome }
|
|
||||||
— totalSpent: absolute value of sum of negative amounts for the period
|
|
||||||
— totalIncome: sum of positive amounts for the period
|
|
||||||
|
|
||||||
GET /api/dashboard/spending-by-category
|
|
||||||
Query: year (required), month (optional)
|
|
||||||
— if month omitted: totals for the entire year
|
|
||||||
— if month provided: totals for that month only
|
|
||||||
Returns 200: [{ category, total }]
|
|
||||||
— total: absolute value of sum of negative amounts for that category in period
|
|
||||||
— positive amounts within any category are excluded from totals
|
|
||||||
— sorted descending by total
|
|
||||||
|
|
||||||
GET /api/dashboard/monthly-balances
|
|
||||||
Query: year (required)
|
|
||||||
Returns 200: [{ month, closingBalance }]
|
|
||||||
— one entry per month that has transactions (months with no transactions omitted)
|
|
||||||
— closingBalance: balance from the transaction with the latest booking_date
|
|
||||||
in that month (highest id as tiebreaker for same-date rows)
|
|
||||||
— always year-scoped
|
|
||||||
|
|
||||||
GET /api/dashboard/cumulative-spending
|
|
||||||
Query: year (required), month (required)
|
|
||||||
Returns 200: [{ day, cumulativeSpent }]
|
|
||||||
— one entry per day that has outgoing transactions within the given month
|
|
||||||
— cumulativeSpent: running absolute total of negative amounts from day 1
|
|
||||||
through that day (sorted ascending by day)
|
|
||||||
— days with no spending are included with the previous day's cumulative value
|
|
||||||
so the line chart has no gaps
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend
|
|
||||||
|
|
||||||
### Pages
|
|
||||||
|
|
||||||
| Page | Route | Auth Required |
|
|
||||||
|------|-------|--------------|
|
|
||||||
| Login | `/login` | No |
|
|
||||||
| Dashboard | `/` | Yes |
|
|
||||||
| Transactions | `/transactions` | Yes |
|
|
||||||
|
|
||||||
JWT and `expiresAt` stored in `localStorage`. Vue Router navigation guard checks local expiry and redirects unauthenticated/expired users to `/login`. On 401 API response, auth store clears storage and redirects to `/login`.
|
|
||||||
|
|
||||||
### Dashboard Layout
|
|
||||||
|
|
||||||
**App bar** — spans full width at top. Contains app title on the left and an "Upload CSV" button on the right. Clicking Upload CSV opens a file picker dialog; the selected file is uploaded via `POST /api/transactions/import` with an inline result snackbar ("Imported X, skipped Y" or error message). No separate import page.
|
|
||||||
|
|
||||||
**Two-column body** — equal-width columns side by side below the app bar.
|
|
||||||
|
|
||||||
**Left column — Year Summary**
|
|
||||||
- Toolbar: previous-year button, selected year label, next-year button
|
|
||||||
- Summary row: Total Expenses (red) | Total Income (green) — year-only totals (see API note below)
|
|
||||||
- Two sub-columns:
|
|
||||||
- **Doughnut chart** (ApexCharts) — expenses by category for the selected year; legend with category name and amount
|
|
||||||
- **Monthly balances bar chart** (ApexCharts) — closing balance per month for the selected year, from `/api/dashboard/monthly-balances`
|
|
||||||
|
|
||||||
**Right column — Month Summary**
|
|
||||||
- Toolbar: previous-month button, selected month+year label, next-month button (wraps year when crossing Jan/Dec boundary)
|
|
||||||
- Summary row: Total Expenses (red) | Total Income (green) — from `/api/dashboard/summary` (year+month)
|
|
||||||
- Two sub-columns:
|
|
||||||
- **Doughnut chart** (ApexCharts) — expenses by category for the selected month; legend with category name and amount
|
|
||||||
- **Cumulative spending line chart** (ApexCharts) — running total of expenses day by day through the selected month, from `/api/dashboard/cumulative-spending`
|
|
||||||
|
|
||||||
All widgets show a skeleton loader while fetching and a subtle error indicator on failure.
|
|
||||||
|
|
||||||
**Shared period state** — both columns share a single `{ year, month }` value. The toolbars are two views of the same state:
|
|
||||||
- Next/prev **month** (right toolbar): increments/decrements the month, wrapping the year (e.g. Dec 2024 → Jan 2025 updates both columns to year=2025, month=1)
|
|
||||||
- Next/prev **year** (left toolbar): increments/decrements the year, keeping the current month (e.g. year 2025, month=1 → next year → year=2026, month=1, right column updates to Jan 2026)
|
|
||||||
|
|
||||||
### Transactions Page
|
|
||||||
|
|
||||||
Vuetify data table:
|
|
||||||
- Columns: Date, Counter Party, Category, Amount, Balance
|
|
||||||
- Filters: year (required), month (optional), category dropdown — server-side query params
|
|
||||||
- Search box — server-side (`search` query param, debounced 300 ms)
|
|
||||||
- Server-side pagination (page, pageSize=50)
|
|
||||||
|
|
||||||
### State Management (Pinia)
|
|
||||||
|
|
||||||
- `auth` — token, expiresAt, login/logout actions, 401 handler
|
|
||||||
- `dashPeriod` — `{ year, month }` shared by both dashboard columns; next/prev month wraps year, next/prev year keeps month
|
|
||||||
- `txPeriod` — selected year/month for the transactions page (independent)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
account-tracking/
|
|
||||||
├── docker-compose.yml
|
|
||||||
├── .env # secrets (gitignored)
|
|
||||||
├── .env.example # template with placeholder values
|
|
||||||
├── src/
|
|
||||||
│ ├── AccountTracking.Api/ # .NET 8 Web API
|
|
||||||
│ │ ├── Controllers/
|
|
||||||
│ │ ├── Services/
|
|
||||||
│ │ ├── Models/
|
|
||||||
│ │ ├── Data/ # EF Core DbContext + migrations
|
|
||||||
│ │ └── Dockerfile
|
|
||||||
│ └── AccountTracking.Web/ # Vue 3 + Vuetify frontend
|
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── api/ # NSwag-generated client (do not edit manually)
|
|
||||||
│ │ ├── stores/ # Pinia: auth, period
|
|
||||||
│ │ ├── views/ # Login, Dashboard, Transactions
|
|
||||||
│ │ └── components/ # YearSummary, MonthSummary, DonutChart,
|
|
||||||
│ │ # MonthlyBalancesChart, CumulativeSpendingChart
|
|
||||||
│ ├── nswag.json # NSwag config (input: openapi.json, output: src/api/)
|
|
||||||
│ ├── vite.config.ts # includes /api proxy to http://localhost:5000
|
|
||||||
│ └── Dockerfile
|
|
||||||
└── docs/
|
|
||||||
└── superpowers/specs/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Decisions
|
|
||||||
|
|
||||||
- **Single user from config** — no user table; credentials in `.env`; password stored as bcrypt hash.
|
|
||||||
- **JWT lifetime 30 days** — long-lived for convenience; Base64-encoded 32-byte secret; expiry in ISO 8601 UTC; checked locally in router guard; redirect on expiry or 401.
|
|
||||||
- **Bank categories used as-is** — no re-categorisation UI needed.
|
|
||||||
- **Deduplication via `transaction_id`** — format `transhist-v-YYYY-MM_<sha>`; VARCHAR(512) utf8mb4_bin (case-sensitive) for correctness and future-proofing.
|
|
||||||
- **CSV header detection** — scan for line starting with `číslo účtu`; do not hardcode line number.
|
|
||||||
- **Spending totals exclude positive amounts per category** — refunds within a spending category are excluded from totals.
|
|
||||||
- **Dashboard columns share one period** — a single `{ year, month }` drives both columns; month toolbar wraps the year on overflow, year toolbar keeps the month.
|
|
||||||
- **Upload CSV in app bar** — file picker dialog in-place, no separate import page; result shown as snackbar.
|
|
||||||
- **Month summary line chart is cumulative spending** — running total of expenses day by day through the selected month; gaps filled so line is continuous.
|
|
||||||
- **Monthly balances bar chart** — closing balance per month for the year (sparse, months with no data omitted).
|
|
||||||
- **Search is contains-match (OR)** — `%term%` on counter_party_name OR message; case-insensitive via utf8mb4_unicode_ci.
|
|
||||||
- **Server-side search and pagination** — no client-side filtering of paginated data.
|
|
||||||
- **NSwag via MSBuild** — `openapi.json` generated at `dotnet build`; web Dockerfile copies it; no running API needed.
|
|
||||||
- **NSwag client base URL: `/api`** — relative; Vite dev proxy and Nginx prod proxy both resolve it correctly.
|
|
||||||
- **EF Core migrations at startup** — Polly retry: 5 attempts, exponential backoff; container exits on all-fail.
|
|
||||||
- **EF Core UTC datetime** — `DateTimeKind.Utc` on all datetime model properties (Pomelo requirement).
|
|
||||||
- **`bank_note` is read-only** — maps to ČSOB's "vlastní poznámka"; no user-edit UI.
|
|
||||||
- **ApexCharts** — Vue 3 compatible, dark theme compatible with Vuetify.
|
|
||||||
1032
resources/2024.csv
1032
resources/2024.csv
File diff suppressed because it is too large
Load Diff
1032
resources/2025.csv
1032
resources/2025.csv
File diff suppressed because it is too large
Load Diff
@ -1,30 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
|
|
||||||
<IsPackable>false</IsPackable>
|
|
||||||
<IsTestProject>true</IsTestProject>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
|
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
|
|
||||||
<PackageReference Include="xunit" Version="2.4.2" />
|
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\AccountTracking.Api\AccountTracking.Api.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
using AccountTracking.Api.Data;
|
|
||||||
using AccountTracking.Api.Services;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Tests;
|
|
||||||
|
|
||||||
public class CsvImportServiceTests
|
|
||||||
{
|
|
||||||
private static AppDbContext CreateDb()
|
|
||||||
{
|
|
||||||
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
|
||||||
return new AppDbContext(opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CsvImportService CreateService(AppDbContext db)
|
|
||||||
=> new(db);
|
|
||||||
|
|
||||||
// Minimal valid CSV with two data rows
|
|
||||||
private const string ValidCsv = """
|
|
||||||
Pohyby na účtu 216868554/0300 dne 19.03.2026
|
|
||||||
|
|
||||||
číslo účtu;datum zaúčtování;částka;měna;zůstatek;číslo protiúčtu;kód banky protiúčtu;jméno protistrany;adresa protistrany;konstantní symbol;variabilní symbol;specifický symbol;označení operace;název trálého příkazu;vlastní poznámka;zpráva;kategorie;původní transakce;kurz;E2E identifikace;reference plátce;původní plátce;konečný příjemce;ID transakce
|
|
||||||
216868554/0300;15.03.2026;-1 740,80;CZK;93 346,57;;;;;;205000001;;Transakce platební kartou;;;Zpráva 1;Potraviny;;;;;;;tx-001
|
|
||||||
216868554/0300;14.03.2026;-800,00;CZK;95 087,37;;;;;;205000002;;Transakce platební kartou;;;Zpráva 2;Restaurace;;;;;;;tx-002
|
|
||||||
""";
|
|
||||||
|
|
||||||
private const string CsvMissingHeader = """
|
|
||||||
Pohyby na účtu
|
|
||||||
Nějaky obsah bez správné hlavičky
|
|
||||||
datum;částka
|
|
||||||
""";
|
|
||||||
|
|
||||||
private const string CsvMissingRequiredColumn = """
|
|
||||||
číslo účtu;datum zaúčtování;měna;zůstatek;ID transakce
|
|
||||||
216868554/0300;15.03.2026;CZK;93346,57;tx-001
|
|
||||||
""";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Import_ParsesRowsCorrectly()
|
|
||||||
{
|
|
||||||
using var db = CreateDb();
|
|
||||||
var svc = CreateService(db);
|
|
||||||
|
|
||||||
var result = await svc.ImportAsync(ValidCsv, "test.csv");
|
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
|
||||||
Assert.Equal(2, result.Value!.RecordsImported);
|
|
||||||
Assert.Equal(0, result.Value.RecordsSkipped);
|
|
||||||
|
|
||||||
var transactions = await db.Transactions.ToListAsync();
|
|
||||||
Assert.Equal(2, transactions.Count);
|
|
||||||
|
|
||||||
var tx = transactions.First(t => t.TransactionId == "tx-001");
|
|
||||||
Assert.Equal(-1740.80m, tx.Amount);
|
|
||||||
Assert.Equal(93346.57m, tx.Balance);
|
|
||||||
Assert.Equal("Potraviny", tx.Category);
|
|
||||||
Assert.Equal(new DateOnly(2026, 3, 15), tx.BookingDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Import_SkipsDuplicateTransactionIds()
|
|
||||||
{
|
|
||||||
using var db = CreateDb();
|
|
||||||
var svc = CreateService(db);
|
|
||||||
|
|
||||||
await svc.ImportAsync(ValidCsv, "first.csv");
|
|
||||||
var result = await svc.ImportAsync(ValidCsv, "second.csv");
|
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
|
||||||
Assert.Equal(0, result.Value!.RecordsImported);
|
|
||||||
Assert.Equal(2, result.Value.RecordsSkipped);
|
|
||||||
Assert.Equal(2, await db.Transactions.CountAsync());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Import_FindsHeaderByScanning_NotByLineNumber()
|
|
||||||
{
|
|
||||||
// Header is on line 3 in ValidCsv — but we scan, not hardcode
|
|
||||||
using var db = CreateDb();
|
|
||||||
var svc = CreateService(db);
|
|
||||||
|
|
||||||
var result = await svc.ImportAsync(ValidCsv, "test.csv");
|
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Import_ReturnsError_WhenHeaderNotFound()
|
|
||||||
{
|
|
||||||
using var db = CreateDb();
|
|
||||||
var svc = CreateService(db);
|
|
||||||
|
|
||||||
var result = await svc.ImportAsync(CsvMissingHeader, "bad.csv");
|
|
||||||
|
|
||||||
Assert.False(result.IsSuccess);
|
|
||||||
Assert.Contains("hlavičku", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Import_ReturnsError_WhenRequiredColumnMissing()
|
|
||||||
{
|
|
||||||
using var db = CreateDb();
|
|
||||||
var svc = CreateService(db);
|
|
||||||
|
|
||||||
var result = await svc.ImportAsync(CsvMissingRequiredColumn, "bad.csv");
|
|
||||||
|
|
||||||
Assert.False(result.IsSuccess);
|
|
||||||
Assert.Contains("částka", result.Error, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("1 740,80", 1740.80)]
|
|
||||||
[InlineData("-4 263,15", -4263.15)]
|
|
||||||
[InlineData("-800,00", -800.00)]
|
|
||||||
[InlineData("93 346,57", 93346.57)]
|
|
||||||
public void ParseAmount_HandlesChechLocaleFormat(string input, decimal expected)
|
|
||||||
{
|
|
||||||
var result = CsvImportService.ParseCzechDecimal(input);
|
|
||||||
Assert.Equal(expected, result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
using AccountTracking.Api.Data;
|
|
||||||
using AccountTracking.Api.Models;
|
|
||||||
using AccountTracking.Api.Services;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Tests;
|
|
||||||
|
|
||||||
public class DashboardServiceTests
|
|
||||||
{
|
|
||||||
private static AppDbContext CreateDb(params Transaction[] seed)
|
|
||||||
{
|
|
||||||
var opts = new DbContextOptionsBuilder<AppDbContext>()
|
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
|
||||||
var db = new AppDbContext(opts);
|
|
||||||
db.Transactions.AddRange(seed);
|
|
||||||
db.SaveChanges();
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Transaction Tx(string date, decimal amount, decimal balance,
|
|
||||||
string category = "Test", string txId = "") =>
|
|
||||||
new()
|
|
||||||
{
|
|
||||||
BookingDate = DateOnly.Parse(date),
|
|
||||||
Amount = amount,
|
|
||||||
Balance = balance,
|
|
||||||
Category = category,
|
|
||||||
TransactionId = txId == "" ? Guid.NewGuid().ToString() : txId,
|
|
||||||
Currency = "CZK",
|
|
||||||
AccountNumber = "test"
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Summary ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Summary_YearOnly_ReturnsTotalsForWholeYear()
|
|
||||||
{
|
|
||||||
using var db = CreateDb(
|
|
||||||
Tx("2025-01-10", -1000, 9000),
|
|
||||||
Tx("2025-06-15", -2000, 7000),
|
|
||||||
Tx("2025-03-01", 5000, 12000),
|
|
||||||
Tx("2024-12-31", -500, 9500) // different year — excluded
|
|
||||||
);
|
|
||||||
var svc = new DashboardService(db);
|
|
||||||
|
|
||||||
var result = await svc.GetSummaryAsync(2025, null);
|
|
||||||
|
|
||||||
Assert.Equal(3000m, result.TotalSpent); // 1000 + 2000
|
|
||||||
Assert.Equal(5000m, result.TotalIncome);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Summary_YearAndMonth_ReturnsTotalsForMonth()
|
|
||||||
{
|
|
||||||
using var db = CreateDb(
|
|
||||||
Tx("2025-03-10", -1000, 9000),
|
|
||||||
Tx("2025-03-20", -500, 8500),
|
|
||||||
Tx("2025-04-01", -800, 7700) // different month — excluded
|
|
||||||
);
|
|
||||||
var svc = new DashboardService(db);
|
|
||||||
|
|
||||||
var result = await svc.GetSummaryAsync(2025, 3);
|
|
||||||
|
|
||||||
Assert.Equal(1500m, result.TotalSpent);
|
|
||||||
Assert.Equal(0m, result.TotalIncome);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SpendingByCategory ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SpendingByCategory_ReturnsAbsoluteValuesSortedDescending()
|
|
||||||
{
|
|
||||||
using var db = CreateDb(
|
|
||||||
Tx("2025-03-01", -100, 900, "A"),
|
|
||||||
Tx("2025-03-02", -300, 600, "B"),
|
|
||||||
Tx("2025-03-03", -200, 400, "A")
|
|
||||||
);
|
|
||||||
var svc = new DashboardService(db);
|
|
||||||
|
|
||||||
var result = await svc.GetSpendingByCategoryAsync(2025, 3);
|
|
||||||
|
|
||||||
Assert.Equal(2, result.Count);
|
|
||||||
Assert.Equal("A", result[0].Category);
|
|
||||||
Assert.Equal(300m, result[0].Total); // 100 + 200
|
|
||||||
Assert.Equal("B", result[1].Category);
|
|
||||||
Assert.Equal(300m, result[1].Total);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SpendingByCategory_ExcludesPositiveAmounts()
|
|
||||||
{
|
|
||||||
using var db = CreateDb(
|
|
||||||
Tx("2025-03-01", -500, 9500, "Potraviny"),
|
|
||||||
Tx("2025-03-02", 200, 9700, "Potraviny") // refund — excluded
|
|
||||||
);
|
|
||||||
var svc = new DashboardService(db);
|
|
||||||
|
|
||||||
var result = await svc.GetSpendingByCategoryAsync(2025, 3);
|
|
||||||
|
|
||||||
Assert.Single(result);
|
|
||||||
Assert.Equal(500m, result[0].Total);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MonthlyBalances ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task MonthlyBalances_ReturnsLastBalancePerMonth()
|
|
||||||
{
|
|
||||||
using var db = CreateDb(
|
|
||||||
Tx("2025-01-10", -100, 900),
|
|
||||||
Tx("2025-01-20", -200, 700), // last in Jan — use this balance
|
|
||||||
Tx("2025-03-05", -150, 550) // March — Feb omitted (no transactions)
|
|
||||||
);
|
|
||||||
var svc = new DashboardService(db);
|
|
||||||
|
|
||||||
var result = await svc.GetMonthlyBalancesAsync(2025);
|
|
||||||
|
|
||||||
Assert.Equal(2, result.Count);
|
|
||||||
Assert.Equal(1, result[0].Month);
|
|
||||||
Assert.Equal(700m, result[0].ClosingBalance);
|
|
||||||
Assert.Equal(3, result[1].Month);
|
|
||||||
Assert.Equal(550m, result[1].ClosingBalance);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CumulativeSpending ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CumulativeSpending_FillsGapsAndAccumulates()
|
|
||||||
{
|
|
||||||
using var db = CreateDb(
|
|
||||||
Tx("2025-03-01", -100, 900),
|
|
||||||
Tx("2025-03-03", -200, 700), // gap on day 2
|
|
||||||
Tx("2025-03-03", -50, 650) // two transactions on same day
|
|
||||||
);
|
|
||||||
var svc = new DashboardService(db);
|
|
||||||
|
|
||||||
var result = await svc.GetCumulativeSpendingAsync(2025, 3);
|
|
||||||
|
|
||||||
// Day 1: 100, Day 2: filled = 100, Day 3: 100 + 250 = 350
|
|
||||||
Assert.Equal(3, result.Count);
|
|
||||||
Assert.Equal(1, result[0].Day); Assert.Equal(100m, result[0].CumulativeSpent);
|
|
||||||
Assert.Equal(2, result[1].Day); Assert.Equal(100m, result[1].CumulativeSpent);
|
|
||||||
Assert.Equal(3, result[2].Day); Assert.Equal(350m, result[2].CumulativeSpent);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CumulativeSpending_ExcludesPositiveAmounts()
|
|
||||||
{
|
|
||||||
using var db = CreateDb(
|
|
||||||
Tx("2025-03-01", -100, 900),
|
|
||||||
Tx("2025-03-01", 500, 1400) // income — not counted
|
|
||||||
);
|
|
||||||
var svc = new DashboardService(db);
|
|
||||||
|
|
||||||
var result = await svc.GetCumulativeSpendingAsync(2025, 3);
|
|
||||||
|
|
||||||
Assert.Single(result);
|
|
||||||
Assert.Equal(100m, result[0].CumulativeSpent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
global using Xunit;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Tests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
|
|
||||||
<PackageReference Include="NSwag.AspNetCore" Version="14.1.0" />
|
|
||||||
<PackageReference Include="NSwag.MSBuild" Version="14.1.0">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Polly" Version="8.4.1" />
|
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<Target Name="NSwag" AfterTargets="Build" Condition="'$(GenerateOpenApiSpec)' == 'true'">
|
|
||||||
<Exec Command="$(NSwagExe_Net80) run nswag.json /variables:Configuration=$(Configuration)"
|
|
||||||
WorkingDirectory="$(ProjectDir)" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
@AccountTracking.Api_HostAddress = http://localhost:5074
|
|
||||||
|
|
||||||
GET {{AccountTracking.Api_HostAddress}}/weatherforecast/
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
###
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using AccountTracking.Api.Models.Dtos;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/auth")]
|
|
||||||
public class AuthController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly AppCredentials _credentials;
|
|
||||||
|
|
||||||
public AuthController(AppCredentials credentials)
|
|
||||||
=> _credentials = credentials;
|
|
||||||
|
|
||||||
[HttpPost("login")]
|
|
||||||
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
|
||||||
public IActionResult Login([FromBody] LoginRequest request)
|
|
||||||
{
|
|
||||||
if (request.Username != _credentials.Username
|
|
||||||
|| !BCrypt.Net.BCrypt.Verify(request.Password, _credentials.PasswordHash))
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var expiry = DateTime.UtcNow.AddDays(30);
|
|
||||||
var token = GenerateToken(expiry);
|
|
||||||
|
|
||||||
return Ok(new LoginResponse(token, expiry.ToString("O")));
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GenerateToken(DateTime expiry)
|
|
||||||
{
|
|
||||||
var key = new SymmetricSecurityKey(_credentials.JwtKeyBytes);
|
|
||||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
|
||||||
var jwt = new JwtSecurityToken(
|
|
||||||
claims: [new Claim(ClaimTypes.Name, _credentials.Username)],
|
|
||||||
expires: expiry,
|
|
||||||
signingCredentials: creds);
|
|
||||||
return new JwtSecurityTokenHandler().WriteToken(jwt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
using AccountTracking.Api.Models.Dtos;
|
|
||||||
using AccountTracking.Api.Services;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/dashboard")]
|
|
||||||
[Authorize]
|
|
||||||
public class DashboardController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly DashboardService _svc;
|
|
||||||
public DashboardController(DashboardService svc) => _svc = svc;
|
|
||||||
|
|
||||||
[HttpGet("summary")]
|
|
||||||
[ProducesResponseType(typeof(SummaryDto), StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> Summary([FromQuery] int year, [FromQuery] int? month)
|
|
||||||
=> Ok(await _svc.GetSummaryAsync(year, month));
|
|
||||||
|
|
||||||
[HttpGet("spending-by-category")]
|
|
||||||
[ProducesResponseType(typeof(List<CategorySpendingDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> SpendingByCategory([FromQuery] int year, [FromQuery] int? month)
|
|
||||||
=> Ok(await _svc.GetSpendingByCategoryAsync(year, month));
|
|
||||||
|
|
||||||
[HttpGet("monthly-balances")]
|
|
||||||
[ProducesResponseType(typeof(List<MonthlyBalanceDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> MonthlyBalances([FromQuery] int year)
|
|
||||||
=> Ok(await _svc.GetMonthlyBalancesAsync(year));
|
|
||||||
|
|
||||||
[HttpGet("cumulative-spending")]
|
|
||||||
[ProducesResponseType(typeof(List<CumulativeSpendingDto>), StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> CumulativeSpending([FromQuery] int year, [FromQuery] int month)
|
|
||||||
=> Ok(await _svc.GetCumulativeSpendingAsync(year, month));
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
using AccountTracking.Api.Data;
|
|
||||||
using AccountTracking.Api.Models.Dtos;
|
|
||||||
using AccountTracking.Api.Services;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/transactions")]
|
|
||||||
[Authorize]
|
|
||||||
public class TransactionsController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _db;
|
|
||||||
private readonly CsvImportService _importService;
|
|
||||||
|
|
||||||
public TransactionsController(AppDbContext db, CsvImportService importService)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
_importService = importService;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("import")]
|
|
||||||
[ProducesResponseType(typeof(ImportResult), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(ErrorResult), StatusCodes.Status400BadRequest)]
|
|
||||||
public async Task<IActionResult> Import(IFormFile file)
|
|
||||||
{
|
|
||||||
if (file == null || file.Length == 0)
|
|
||||||
return BadRequest(new ErrorResult("Nebyl vybrán žádný soubor."));
|
|
||||||
|
|
||||||
string content;
|
|
||||||
using (var reader = new StreamReader(file.OpenReadStream(), System.Text.Encoding.UTF8))
|
|
||||||
content = await reader.ReadToEndAsync();
|
|
||||||
|
|
||||||
var result = await _importService.ImportAsync(content, file.FileName);
|
|
||||||
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
return BadRequest(new ErrorResult(result.Error!));
|
|
||||||
|
|
||||||
return Ok(result.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
[ProducesResponseType(typeof(TransactionListResponse), StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> List(
|
|
||||||
[FromQuery] int year,
|
|
||||||
[FromQuery] int? month,
|
|
||||||
[FromQuery] string? category,
|
|
||||||
[FromQuery] string? search,
|
|
||||||
[FromQuery] int page = 1,
|
|
||||||
[FromQuery] int pageSize = 50)
|
|
||||||
{
|
|
||||||
pageSize = Math.Clamp(pageSize, 1, 200);
|
|
||||||
page = Math.Max(1, page);
|
|
||||||
|
|
||||||
var query = _db.Transactions.AsQueryable();
|
|
||||||
|
|
||||||
query = query.Where(t => t.BookingDate.Year == year);
|
|
||||||
if (month.HasValue)
|
|
||||||
query = query.Where(t => t.BookingDate.Month == month.Value);
|
|
||||||
if (!string.IsNullOrWhiteSpace(category))
|
|
||||||
query = query.Where(t => t.Category == category);
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
|
||||||
query = query.Where(t =>
|
|
||||||
(t.CounterPartyName != null && EF.Functions.Like(t.CounterPartyName, $"%{search}%")) ||
|
|
||||||
(t.Message != null && EF.Functions.Like(t.Message, $"%{search}%")));
|
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
|
||||||
var items = await query
|
|
||||||
.OrderByDescending(t => t.BookingDate)
|
|
||||||
.ThenByDescending(t => t.Id)
|
|
||||||
.Skip((page - 1) * pageSize)
|
|
||||||
.Take(pageSize)
|
|
||||||
.Select(t => new TransactionDto(
|
|
||||||
t.Id, t.BookingDate, t.CounterPartyName,
|
|
||||||
t.Category, t.Amount, t.Balance, t.Message))
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return Ok(new TransactionListResponse(items, total, page, pageSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("categories")]
|
|
||||||
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> Categories()
|
|
||||||
{
|
|
||||||
var cats = await _db.Transactions
|
|
||||||
.Where(t => t.Category != null)
|
|
||||||
.Select(t => t.Category!)
|
|
||||||
.Distinct()
|
|
||||||
.OrderBy(c => c)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return Ok(cats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
using AccountTracking.Api.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Data;
|
|
||||||
|
|
||||||
public class AppDbContext : DbContext
|
|
||||||
{
|
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
|
||||||
|
|
||||||
public DbSet<Transaction> Transactions => Set<Transaction>();
|
|
||||||
public DbSet<ImportLog> ImportLogs => Set<ImportLog>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
// transaction_id: case-sensitive unique index using utf8mb4_bin
|
|
||||||
modelBuilder.Entity<Transaction>()
|
|
||||||
.HasIndex(t => t.TransactionId)
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
modelBuilder.Entity<Transaction>()
|
|
||||||
.Property(t => t.TransactionId)
|
|
||||||
.UseCollation("utf8mb4_bin");
|
|
||||||
|
|
||||||
// ImportedAt stored as UTC
|
|
||||||
modelBuilder.Entity<ImportLog>()
|
|
||||||
.Property(l => l.ImportedAt)
|
|
||||||
.HasConversion(
|
|
||||||
v => DateTime.SpecifyKind(v, DateTimeKind.Utc),
|
|
||||||
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record CategorySpendingDto(string Category, decimal Total);
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record CumulativeSpendingDto(int Day, decimal CumulativeSpent);
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record ImportResult(int RecordsImported, int RecordsSkipped);
|
|
||||||
public record ErrorResult(string Error);
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record LoginRequest(string Username, string Password);
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record LoginResponse(string Token, string ExpiresAt);
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record MonthlyBalanceDto(int Month, decimal ClosingBalance);
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record SummaryDto(decimal TotalSpent, decimal TotalIncome);
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record TransactionDto(
|
|
||||||
int Id,
|
|
||||||
DateOnly BookingDate,
|
|
||||||
string? CounterPartyName,
|
|
||||||
string? Category,
|
|
||||||
decimal Amount,
|
|
||||||
decimal Balance,
|
|
||||||
string? Message
|
|
||||||
);
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
namespace AccountTracking.Api.Models.Dtos;
|
|
||||||
public record TransactionListResponse(
|
|
||||||
IEnumerable<TransactionDto> Items,
|
|
||||||
int TotalCount,
|
|
||||||
int Page,
|
|
||||||
int PageSize
|
|
||||||
);
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Models;
|
|
||||||
|
|
||||||
[Table("import_logs")]
|
|
||||||
public class ImportLog
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
[Column("id")]
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
[Column("imported_at")]
|
|
||||||
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
|
|
||||||
[Column("filename")]
|
|
||||||
[MaxLength(255)]
|
|
||||||
public string Filename { get; set; } = "";
|
|
||||||
|
|
||||||
[Column("records_imported")]
|
|
||||||
public int RecordsImported { get; set; }
|
|
||||||
|
|
||||||
[Column("records_skipped")]
|
|
||||||
public int RecordsSkipped { get; set; }
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Models;
|
|
||||||
|
|
||||||
[Table("transactions")]
|
|
||||||
public class Transaction
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
[Column("id")]
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
[Column("account_number")]
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string AccountNumber { get; set; } = "";
|
|
||||||
|
|
||||||
[Column("booking_date")]
|
|
||||||
public DateOnly BookingDate { get; set; }
|
|
||||||
|
|
||||||
[Column("amount")]
|
|
||||||
[Precision(15, 2)]
|
|
||||||
public decimal Amount { get; set; }
|
|
||||||
|
|
||||||
[Column("currency")]
|
|
||||||
[MaxLength(3)]
|
|
||||||
public string Currency { get; set; } = "CZK";
|
|
||||||
|
|
||||||
[Column("balance")]
|
|
||||||
[Precision(15, 2)]
|
|
||||||
public decimal Balance { get; set; }
|
|
||||||
|
|
||||||
[Column("counter_party_name")]
|
|
||||||
[MaxLength(255)]
|
|
||||||
public string? CounterPartyName { get; set; }
|
|
||||||
|
|
||||||
[Column("operation_description")]
|
|
||||||
[MaxLength(255)]
|
|
||||||
public string? OperationDescription { get; set; }
|
|
||||||
|
|
||||||
[Column("message")]
|
|
||||||
public string? Message { get; set; }
|
|
||||||
|
|
||||||
[Column("category")]
|
|
||||||
[MaxLength(100)]
|
|
||||||
public string? Category { get; set; }
|
|
||||||
|
|
||||||
[Column("variable_symbol")]
|
|
||||||
[MaxLength(30)]
|
|
||||||
public string? VariableSymbol { get; set; }
|
|
||||||
|
|
||||||
[Column("bank_note")]
|
|
||||||
[MaxLength(255)]
|
|
||||||
public string? BankNote { get; set; }
|
|
||||||
|
|
||||||
[Column("transaction_id")]
|
|
||||||
[MaxLength(512)]
|
|
||||||
public string TransactionId { get; set; } = "";
|
|
||||||
|
|
||||||
// Extra CSV columns stored but not used in UI
|
|
||||||
[Column("counter_bank_code")] [MaxLength(255)] public string? CounterBankCode { get; set; }
|
|
||||||
[Column("constant_symbol")] [MaxLength(255)] public string? ConstantSymbol { get; set; }
|
|
||||||
[Column("specific_symbol")] [MaxLength(255)] public string? SpecificSymbol { get; set; }
|
|
||||||
[Column("order_name")] [MaxLength(255)] public string? OrderName { get; set; }
|
|
||||||
[Column("exchange_rate")] [MaxLength(255)] public string? ExchangeRate { get; set; }
|
|
||||||
[Column("e2e_id")] [MaxLength(255)] public string? E2EId { get; set; }
|
|
||||||
[Column("payer_reference")] [MaxLength(255)] public string? PayerReference { get; set; }
|
|
||||||
[Column("original_payer")] [MaxLength(255)] public string? OriginalPayer { get; set; }
|
|
||||||
[Column("final_recipient")] [MaxLength(255)] public string? FinalRecipient { get; set; }
|
|
||||||
[Column("original_transaction")] [MaxLength(255)] public string? OriginalTransaction { get; set; }
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using AccountTracking.Api.Data;
|
|
||||||
using AccountTracking.Api.Services;
|
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Polly;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// ── Configuration ──────────────────────────────────────────────────────────
|
|
||||||
var connectionString = builder.Configuration["DB_CONNECTION"];
|
|
||||||
var jwtSecret = builder.Configuration["JWT_SECRET"]
|
|
||||||
?? throw new InvalidOperationException("JWT_SECRET is required");
|
|
||||||
var appUsername = builder.Configuration["APP_USERNAME"]
|
|
||||||
?? throw new InvalidOperationException("APP_USERNAME is required");
|
|
||||||
var appPassword = builder.Configuration["APP_PASSWORD"]
|
|
||||||
?? throw new InvalidOperationException("APP_PASSWORD is required");
|
|
||||||
var allowedOrigin = builder.Configuration["ALLOWED_ORIGIN"] ?? "http://localhost:3000";
|
|
||||||
|
|
||||||
// ── Database ────────────────────────────────────────────────────────────────
|
|
||||||
if (!string.IsNullOrEmpty(connectionString))
|
|
||||||
{
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(opts =>
|
|
||||||
opts.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// NSwag build-time spec generation: no real DB available
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(opts =>
|
|
||||||
opts.UseInMemoryDatabase("nswag_gen"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services ────────────────────────────────────────────────────────────────
|
|
||||||
builder.Services.AddScoped<CsvImportService>();
|
|
||||||
builder.Services.AddScoped<DashboardService>();
|
|
||||||
|
|
||||||
// ── Auth ────────────────────────────────────────────────────────────────────
|
|
||||||
var keyBytes = Convert.FromBase64String(jwtSecret);
|
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|
||||||
.AddJwtBearer(opts =>
|
|
||||||
{
|
|
||||||
opts.TokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
|
|
||||||
ValidateIssuer = false,
|
|
||||||
ValidateAudience = false,
|
|
||||||
ClockSkew = TimeSpan.Zero
|
|
||||||
};
|
|
||||||
});
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
|
|
||||||
// Store credentials for use by AuthController
|
|
||||||
builder.Services.AddSingleton(new AppCredentials(appUsername, appPassword, keyBytes));
|
|
||||||
|
|
||||||
// ── CORS ────────────────────────────────────────────────────────────────────
|
|
||||||
builder.Services.AddCors(opts =>
|
|
||||||
{
|
|
||||||
opts.AddDefaultPolicy(policy =>
|
|
||||||
policy.WithOrigins(allowedOrigin, "http://localhost:3000")
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowAnyMethod());
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Controllers & OpenAPI ───────────────────────────────────────────────────
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
builder.Services.AddOpenApiDocument(config =>
|
|
||||||
{
|
|
||||||
config.Title = "AccountTracking API";
|
|
||||||
config.Version = "v1";
|
|
||||||
config.AddSecurity("Bearer", new NSwag.OpenApiSecurityScheme
|
|
||||||
{
|
|
||||||
Type = NSwag.OpenApiSecuritySchemeType.Http,
|
|
||||||
Scheme = "bearer",
|
|
||||||
BearerFormat = "JWT"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Kestrel: limit upload to 10 MB ─────────────────────────────────────────
|
|
||||||
builder.WebHost.ConfigureKestrel(opts =>
|
|
||||||
opts.Limits.MaxRequestBodySize = 10 * 1024 * 1024);
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// ── Migrate DB (skip when running without a real DB) ───────────────────────
|
|
||||||
if (!string.IsNullOrEmpty(connectionString))
|
|
||||||
{
|
|
||||||
var retryPolicy = Policy
|
|
||||||
.Handle<Exception>()
|
|
||||||
.WaitAndRetry(
|
|
||||||
5,
|
|
||||||
attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)),
|
|
||||||
(ex, delay, attempt, _) =>
|
|
||||||
Console.WriteLine($"DB migration attempt {attempt} failed: {ex.Message}. Retrying in {delay.TotalSeconds}s..."));
|
|
||||||
|
|
||||||
using var scope = app.Services.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
|
||||||
retryPolicy.Execute(() => db.Database.Migrate());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Middleware pipeline ─────────────────────────────────────────────────────
|
|
||||||
app.UseCors();
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.UseOpenApi();
|
|
||||||
app.UseSwaggerUi();
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
|
|
||||||
// Accessible by tests
|
|
||||||
public partial class Program { }
|
|
||||||
|
|
||||||
// Credential container registered in DI
|
|
||||||
public record AppCredentials(string Username, string PasswordHash, byte[] JwtKeyBytes);
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
|
||||||
"iisSettings": {
|
|
||||||
"windowsAuthentication": false,
|
|
||||||
"anonymousAuthentication": true,
|
|
||||||
"iisExpress": {
|
|
||||||
"applicationUrl": "http://localhost:59363",
|
|
||||||
"sslPort": 44369
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"launchUrl": "weatherforecast",
|
|
||||||
"applicationUrl": "http://localhost:5074",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"launchUrl": "weatherforecast",
|
|
||||||
"applicationUrl": "https://localhost:7024;http://localhost:5074",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"IIS Express": {
|
|
||||||
"commandName": "IISExpress",
|
|
||||||
"launchBrowser": true,
|
|
||||||
"launchUrl": "weatherforecast",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
using AccountTracking.Api.Data;
|
|
||||||
using AccountTracking.Api.Models;
|
|
||||||
using AccountTracking.Api.Models.Dtos;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Services;
|
|
||||||
|
|
||||||
public class CsvImportService(AppDbContext db)
|
|
||||||
{
|
|
||||||
private static readonly string[] RequiredColumns = ["částka", "ID transakce", "datum zaúčtování"];
|
|
||||||
|
|
||||||
public async Task<ImportServiceResult> ImportAsync(string csvContent, string filename)
|
|
||||||
{
|
|
||||||
var lines = csvContent
|
|
||||||
.Split('\n', StringSplitOptions.None)
|
|
||||||
.Select(l => l.TrimEnd('\r'))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
// Find header row by scanning for line starting with "číslo účtu"
|
|
||||||
int headerIndex = -1;
|
|
||||||
for (int i = 0; i < lines.Length; i++)
|
|
||||||
{
|
|
||||||
if (lines[i].StartsWith("číslo účtu", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
headerIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (headerIndex < 0)
|
|
||||||
return ImportServiceResult.Failure("Soubor neobsahuje správnou hlavičku (řádek začínající 'číslo účtu').");
|
|
||||||
|
|
||||||
var headers = lines[headerIndex].Split(';');
|
|
||||||
var columnIndex = BuildColumnIndex(headers);
|
|
||||||
|
|
||||||
// Validate required columns
|
|
||||||
foreach (var col in RequiredColumns)
|
|
||||||
{
|
|
||||||
if (!columnIndex.ContainsKey(col))
|
|
||||||
return ImportServiceResult.Failure($"Souboru chybí povinný sloupec: '{col}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load existing transaction IDs to detect duplicates
|
|
||||||
var existingIds = db.Transactions
|
|
||||||
.Select(t => t.TransactionId)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
int imported = 0, skipped = 0;
|
|
||||||
var toInsert = new List<Transaction>();
|
|
||||||
|
|
||||||
for (int i = headerIndex + 1; i < lines.Length; i++)
|
|
||||||
{
|
|
||||||
var line = lines[i];
|
|
||||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
|
||||||
|
|
||||||
var fields = line.Split(';');
|
|
||||||
if (fields.Length < headers.Length) continue;
|
|
||||||
|
|
||||||
var txId = GetField(fields, columnIndex, "ID transakce");
|
|
||||||
if (string.IsNullOrEmpty(txId)) continue;
|
|
||||||
|
|
||||||
if (existingIds.Contains(txId))
|
|
||||||
{
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
existingIds.Add(txId); // prevent duplicates within the same file
|
|
||||||
|
|
||||||
toInsert.Add(new Transaction
|
|
||||||
{
|
|
||||||
AccountNumber = GetField(fields, columnIndex, "číslo účtu"),
|
|
||||||
BookingDate = DateOnly.ParseExact(GetField(fields, columnIndex, "datum zaúčtování"), "d.M.yyyy"),
|
|
||||||
Amount = ParseCzechDecimal(GetField(fields, columnIndex, "částka")),
|
|
||||||
Currency = GetField(fields, columnIndex, "měna"),
|
|
||||||
Balance = ParseCzechDecimal(GetField(fields, columnIndex, "zůstatek")),
|
|
||||||
CounterPartyName = GetField(fields, columnIndex, "jméno protistrany"),
|
|
||||||
OperationDescription = GetField(fields, columnIndex, "označení operace"),
|
|
||||||
Message = GetField(fields, columnIndex, "zpráva"),
|
|
||||||
Category = GetField(fields, columnIndex, "kategorie"),
|
|
||||||
VariableSymbol = GetField(fields, columnIndex, "variabilní symbol"),
|
|
||||||
BankNote = GetField(fields, columnIndex, "vlastní poznámka"),
|
|
||||||
TransactionId = txId,
|
|
||||||
CounterBankCode = GetField(fields, columnIndex, "kód banky protiúčtu"),
|
|
||||||
ConstantSymbol = GetField(fields, columnIndex, "konstantní symbol"),
|
|
||||||
SpecificSymbol = GetField(fields, columnIndex, "specifický symbol"),
|
|
||||||
OrderName = GetField(fields, columnIndex, "název trvalého příkazu"),
|
|
||||||
ExchangeRate = GetField(fields, columnIndex, "kurz"),
|
|
||||||
E2EId = GetField(fields, columnIndex, "E2E identifikace"),
|
|
||||||
PayerReference = GetField(fields, columnIndex, "reference plátce"),
|
|
||||||
OriginalPayer = GetField(fields, columnIndex, "původní plátce"),
|
|
||||||
FinalRecipient = GetField(fields, columnIndex, "konečný příjemce"),
|
|
||||||
OriginalTransaction = GetField(fields, columnIndex, "původní transakce"),
|
|
||||||
});
|
|
||||||
imported++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toInsert.Count > 0)
|
|
||||||
{
|
|
||||||
db.Transactions.AddRange(toInsert);
|
|
||||||
db.ImportLogs.Add(new ImportLog
|
|
||||||
{
|
|
||||||
Filename = filename,
|
|
||||||
RecordsImported = imported,
|
|
||||||
RecordsSkipped = skipped,
|
|
||||||
ImportedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ImportServiceResult.Success(new ImportResult(imported, skipped));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static decimal ParseCzechDecimal(string value)
|
|
||||||
{
|
|
||||||
// Czech format: "1 740,80" → remove spaces, replace comma with dot
|
|
||||||
var normalised = value.Replace(" ", "").Replace(",", ".");
|
|
||||||
return decimal.Parse(normalised, System.Globalization.CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, int> BuildColumnIndex(string[] headers)
|
|
||||||
=> headers
|
|
||||||
.Select((h, i) => (Header: h.Trim(), Index: i))
|
|
||||||
.Where(x => !string.IsNullOrEmpty(x.Header))
|
|
||||||
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
private static string GetField(string[] fields, Dictionary<string, int> index, string column)
|
|
||||||
{
|
|
||||||
if (!index.TryGetValue(column, out int i)) return "";
|
|
||||||
return i < fields.Length ? fields[i].Trim() : "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ImportServiceResult
|
|
||||||
{
|
|
||||||
public bool IsSuccess { get; private init; }
|
|
||||||
public ImportResult? Value { get; private init; }
|
|
||||||
public string? Error { get; private init; }
|
|
||||||
|
|
||||||
public static ImportServiceResult Success(ImportResult value)
|
|
||||||
=> new() { IsSuccess = true, Value = value };
|
|
||||||
|
|
||||||
public static ImportServiceResult Failure(string error)
|
|
||||||
=> new() { IsSuccess = false, Error = error };
|
|
||||||
}
|
|
||||||
@ -1,91 +0,0 @@
|
|||||||
using AccountTracking.Api.Data;
|
|
||||||
using AccountTracking.Api.Models.Dtos;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace AccountTracking.Api.Services;
|
|
||||||
|
|
||||||
public class DashboardService(AppDbContext db)
|
|
||||||
{
|
|
||||||
public async Task<SummaryDto> GetSummaryAsync(int year, int? month)
|
|
||||||
{
|
|
||||||
var query = db.Transactions.Where(t => t.BookingDate.Year == year);
|
|
||||||
if (month.HasValue)
|
|
||||||
query = query.Where(t => t.BookingDate.Month == month.Value);
|
|
||||||
|
|
||||||
var spent = await query
|
|
||||||
.Where(t => t.Amount < 0)
|
|
||||||
.SumAsync(t => (decimal?)t.Amount) ?? 0;
|
|
||||||
var income = await query
|
|
||||||
.Where(t => t.Amount > 0)
|
|
||||||
.SumAsync(t => (decimal?)t.Amount) ?? 0;
|
|
||||||
|
|
||||||
return new SummaryDto(Math.Abs(spent), income);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<CategorySpendingDto>> GetSpendingByCategoryAsync(int year, int? month)
|
|
||||||
{
|
|
||||||
var query = db.Transactions
|
|
||||||
.Where(t => t.BookingDate.Year == year && t.Amount < 0);
|
|
||||||
|
|
||||||
if (month.HasValue)
|
|
||||||
query = query.Where(t => t.BookingDate.Month == month.Value);
|
|
||||||
|
|
||||||
return await query
|
|
||||||
.GroupBy(t => t.Category ?? "Nezatříděno")
|
|
||||||
.Select(g => new CategorySpendingDto(g.Key, Math.Abs(g.Sum(t => t.Amount))))
|
|
||||||
.OrderByDescending(c => c.Total)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<MonthlyBalanceDto>> GetMonthlyBalancesAsync(int year)
|
|
||||||
{
|
|
||||||
// Fetch minimal columns, then group in memory to get closing balance per month
|
|
||||||
// (latest booking_date, highest id as tiebreaker)
|
|
||||||
var rows = await db.Transactions
|
|
||||||
.Where(t => t.BookingDate.Year == year)
|
|
||||||
.Select(t => new { t.BookingDate, t.Id, t.Balance })
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return rows
|
|
||||||
.GroupBy(t => t.BookingDate.Month)
|
|
||||||
.Select(g => new MonthlyBalanceDto(
|
|
||||||
g.Key,
|
|
||||||
g.OrderByDescending(t => t.BookingDate).ThenByDescending(t => t.Id).First().Balance))
|
|
||||||
.OrderBy(r => r.Month)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<CumulativeSpendingDto>> GetCumulativeSpendingAsync(int year, int month)
|
|
||||||
{
|
|
||||||
// Get daily spending totals (absolute values, negative amounts only)
|
|
||||||
var dailySpending = await db.Transactions
|
|
||||||
.Where(t => t.BookingDate.Year == year
|
|
||||||
&& t.BookingDate.Month == month
|
|
||||||
&& t.Amount < 0)
|
|
||||||
.GroupBy(t => t.BookingDate.Day)
|
|
||||||
.Select(g => new { Day = g.Key, Spent = Math.Abs(g.Sum(t => t.Amount)) })
|
|
||||||
.OrderBy(g => g.Day)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
if (dailySpending.Count == 0)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
// Fill gaps: for each day from day 1 to last day with spending,
|
|
||||||
// carry forward the previous cumulative value
|
|
||||||
var result = new List<CumulativeSpendingDto>();
|
|
||||||
decimal running = 0;
|
|
||||||
int lastDay = dailySpending.Max(d => d.Day);
|
|
||||||
var spendingByDay = dailySpending.ToDictionary(d => d.Day, d => d.Spent);
|
|
||||||
|
|
||||||
for (int day = 1; day <= lastDay; day++)
|
|
||||||
{
|
|
||||||
if (spendingByDay.TryGetValue(day, out var spent))
|
|
||||||
running += spent;
|
|
||||||
|
|
||||||
if (day <= lastDay)
|
|
||||||
result.Add(new CumulativeSpendingDto(day, running));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"runtime": "Net80",
|
|
||||||
"documentGenerator": {
|
|
||||||
"aspNetCoreToOpenApi": {
|
|
||||||
"project": "AccountTracking.Api.csproj",
|
|
||||||
"msBuildProjectExtensionsPath": null,
|
|
||||||
"configuration": "Debug",
|
|
||||||
"noBuild": true,
|
|
||||||
"verbose": false,
|
|
||||||
"outputType": "OpenApi3",
|
|
||||||
"output": "openapi.json"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"codeGenerators": {}
|
|
||||||
}
|
|
||||||
@ -1,567 +0,0 @@
|
|||||||
{
|
|
||||||
"x-generator": "NSwag v14.1.0.0 (NJsonSchema v11.0.2.0 (Newtonsoft.Json v13.0.0.0))",
|
|
||||||
"openapi": "3.0.0",
|
|
||||||
"info": {
|
|
||||||
"title": "AccountTracking API",
|
|
||||||
"version": "v1"
|
|
||||||
},
|
|
||||||
"servers": [
|
|
||||||
{
|
|
||||||
"url": "http://localhost:5099"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"/api/auth/login": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Auth"
|
|
||||||
],
|
|
||||||
"operationId": "Auth_Login",
|
|
||||||
"requestBody": {
|
|
||||||
"x-name": "request",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/LoginRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true,
|
|
||||||
"x-position": 1
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/LoginResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ProblemDetails"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/dashboard/summary": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Dashboard"
|
|
||||||
],
|
|
||||||
"operationId": "Dashboard_Summary",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "year",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"x-position": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "month",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"x-position": 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/SummaryDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/dashboard/spending-by-category": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Dashboard"
|
|
||||||
],
|
|
||||||
"operationId": "Dashboard_SpendingByCategory",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "year",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"x-position": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "month",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"x-position": 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/CategorySpendingDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/dashboard/monthly-balances": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Dashboard"
|
|
||||||
],
|
|
||||||
"operationId": "Dashboard_MonthlyBalances",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "year",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"x-position": 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/MonthlyBalanceDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/dashboard/cumulative-spending": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Dashboard"
|
|
||||||
],
|
|
||||||
"operationId": "Dashboard_CumulativeSpending",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "year",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"x-position": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "month",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"x-position": 2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/CumulativeSpendingDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/transactions/import": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Transactions"
|
|
||||||
],
|
|
||||||
"operationId": "Transactions_Import",
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"multipart/form-data": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"file": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "binary",
|
|
||||||
"nullable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ImportResult"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/ErrorResult"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/transactions": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Transactions"
|
|
||||||
],
|
|
||||||
"operationId": "Transactions_List",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "year",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"x-position": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "month",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"x-position": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "category",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"x-position": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "search",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"x-position": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "page",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32",
|
|
||||||
"default": 1
|
|
||||||
},
|
|
||||||
"x-position": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "pageSize",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32",
|
|
||||||
"default": 50
|
|
||||||
},
|
|
||||||
"x-position": 6
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/TransactionListResponse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/api/transactions/categories": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Transactions"
|
|
||||||
],
|
|
||||||
"operationId": "Transactions_Categories",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"components": {
|
|
||||||
"schemas": {
|
|
||||||
"LoginResponse": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"token": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"expiresAt": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ProblemDetails": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": {
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"instance": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"LoginRequest": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"username": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"SummaryDto": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"totalSpent": {
|
|
||||||
"type": "number",
|
|
||||||
"format": "decimal"
|
|
||||||
},
|
|
||||||
"totalIncome": {
|
|
||||||
"type": "number",
|
|
||||||
"format": "decimal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"CategorySpendingDto": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"category": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"total": {
|
|
||||||
"type": "number",
|
|
||||||
"format": "decimal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"MonthlyBalanceDto": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"month": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"closingBalance": {
|
|
||||||
"type": "number",
|
|
||||||
"format": "decimal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"CumulativeSpendingDto": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"day": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"cumulativeSpent": {
|
|
||||||
"type": "number",
|
|
||||||
"format": "decimal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ImportResult": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"recordsImported": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"recordsSkipped": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ErrorResult": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"error": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"TransactionListResponse": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"items": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/TransactionDto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"totalCount": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"page": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"pageSize": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"TransactionDto": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "integer",
|
|
||||||
"format": "int32"
|
|
||||||
},
|
|
||||||
"bookingDate": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date"
|
|
||||||
},
|
|
||||||
"counterPartyName": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"category": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
},
|
|
||||||
"amount": {
|
|
||||||
"type": "number",
|
|
||||||
"format": "decimal"
|
|
||||||
},
|
|
||||||
"balance": {
|
|
||||||
"type": "number",
|
|
||||||
"format": "decimal"
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"securitySchemes": {
|
|
||||||
"Bearer": {
|
|
||||||
"type": "http",
|
|
||||||
"scheme": "bearer",
|
|
||||||
"bearerFormat": "JWT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4
src/AccountTracking.Web/.gitignore
vendored
4
src/AccountTracking.Web/.gitignore
vendored
@ -1,4 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
openapi.json
|
|
||||||
vite.config.js
|
|
||||||
1
src/AccountTracking.Web/env.d.ts
vendored
1
src/AccountTracking.Web/env.d.ts
vendored
@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="cs">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" href="/favicon.ico" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Finance Tracker</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
{
|
|
||||||
"runtime": "Default",
|
|
||||||
"defaultVariables": null,
|
|
||||||
"documentGenerator": {
|
|
||||||
"fromDocument": {
|
|
||||||
"url": "openapi.json",
|
|
||||||
"output": null,
|
|
||||||
"newLineBehavior": "Auto"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"codeGenerators": {
|
|
||||||
"openApiToTypeScriptClient": {
|
|
||||||
"className": "{controller}Client",
|
|
||||||
"moduleName": "",
|
|
||||||
"namespace": "",
|
|
||||||
"typeScriptVersion": 5.0,
|
|
||||||
"template": "Fetch",
|
|
||||||
"promiseType": "Promise",
|
|
||||||
"httpClass": "HttpClient",
|
|
||||||
"withCredentials": false,
|
|
||||||
"useSingletonProvider": false,
|
|
||||||
"injectionTokenType": "OpaqueToken",
|
|
||||||
"rxJsVersion": 6.0,
|
|
||||||
"dateTimeType": "String",
|
|
||||||
"nullValue": "Undefined",
|
|
||||||
"generateClientClasses": true,
|
|
||||||
"generateClientInterfaces": false,
|
|
||||||
"generateOptionalParameters": false,
|
|
||||||
"exportTypes": true,
|
|
||||||
"wrapDtoExceptions": false,
|
|
||||||
"exceptionClass": "SwaggerException",
|
|
||||||
"clientBaseClass": null,
|
|
||||||
"wrapResponses": false,
|
|
||||||
"wrapResponseMethods": [],
|
|
||||||
"generateResponseClasses": true,
|
|
||||||
"responseClass": "SwaggerResponse",
|
|
||||||
"protectedMethods": [],
|
|
||||||
"configurationClass": null,
|
|
||||||
"useTransformOptionsMethod": false,
|
|
||||||
"useTransformResultMethod": false,
|
|
||||||
"generateDtoTypes": true,
|
|
||||||
"operationGenerationMode": "MultipleClientsFromOperationId",
|
|
||||||
"includedOperationIds": [],
|
|
||||||
"excludedOperationIds": [],
|
|
||||||
"markOptionalProperties": true,
|
|
||||||
"generateCloneMethod": false,
|
|
||||||
"typeStyle": "Interface",
|
|
||||||
"enumStyle": "Enum",
|
|
||||||
"useLeafType": false,
|
|
||||||
"classTypes": [],
|
|
||||||
"extendedClasses": [],
|
|
||||||
"extensionCode": null,
|
|
||||||
"generateDefaultValues": true,
|
|
||||||
"excludedTypeNames": [],
|
|
||||||
"excludedParameterNames": [],
|
|
||||||
"handleReferences": false,
|
|
||||||
"generateTypeCheckFunctions": false,
|
|
||||||
"generateConstructorInterface": true,
|
|
||||||
"convertConstructorInterfaceData": false,
|
|
||||||
"importRequiredTypes": true,
|
|
||||||
"useGetBaseUrlMethod": false,
|
|
||||||
"baseUrlTokenName": "API_BASE_URL",
|
|
||||||
"queryNullValue": "",
|
|
||||||
"useAbortSignal": false,
|
|
||||||
"inlineNamedDictionaries": false,
|
|
||||||
"inlineNamedAny": false,
|
|
||||||
"includeHttpContext": false,
|
|
||||||
"templateDirectory": null,
|
|
||||||
"serviceHost": null,
|
|
||||||
"serviceSchemes": null,
|
|
||||||
"output": "src/api/apiClient.ts",
|
|
||||||
"newLineBehavior": "Auto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1848
src/AccountTracking.Web/package-lock.json
generated
1848
src/AccountTracking.Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "accounttracking-web",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vue-tsc -b && vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mdi/font": "^7.4.47",
|
|
||||||
"apexcharts": "^3.54.0",
|
|
||||||
"pinia": "^2.3.1",
|
|
||||||
"vue": "^3.5.13",
|
|
||||||
"vue-router": "^4.5.0",
|
|
||||||
"vue3-apexcharts": "^1.8.0",
|
|
||||||
"vuetify": "^3.6.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tsconfig/node22": "^22.0.5",
|
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
|
||||||
"@vue/tsconfig": "^0.9.0",
|
|
||||||
"nswag": "^14.1.0",
|
|
||||||
"typescript": "~5.8.0",
|
|
||||||
"vite": "^6.2.2",
|
|
||||||
"vite-plugin-vuetify": "^2.0.4",
|
|
||||||
"vue-tsc": "^2.2.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-app>
|
|
||||||
<router-view />
|
|
||||||
</v-app>
|
|
||||||
</template>
|
|
||||||
@ -1,470 +0,0 @@
|
|||||||
//----------------------
|
|
||||||
// <auto-generated>
|
|
||||||
// Generated using the NSwag toolchain v14.6.3.0 (NJsonSchema v11.5.2.0 (Newtonsoft.Json v13.0.0.0)) (http://NSwag.org)
|
|
||||||
// </auto-generated>
|
|
||||||
//----------------------
|
|
||||||
|
|
||||||
/* eslint-disable */
|
|
||||||
// ReSharper disable InconsistentNaming
|
|
||||||
|
|
||||||
export class AuthClient {
|
|
||||||
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
|
|
||||||
private baseUrl: string;
|
|
||||||
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
|
|
||||||
|
|
||||||
constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }) {
|
|
||||||
this.http = http ? http : window as any;
|
|
||||||
this.baseUrl = baseUrl ?? "http://localhost:5099";
|
|
||||||
}
|
|
||||||
|
|
||||||
login(request: LoginRequest): Promise<LoginResponse> {
|
|
||||||
let url_ = this.baseUrl + "/api/auth/login";
|
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
|
||||||
|
|
||||||
const content_ = JSON.stringify(request);
|
|
||||||
|
|
||||||
let options_: RequestInit = {
|
|
||||||
body: content_,
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
|
||||||
return this.processLogin(_response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected processLogin(response: Response): Promise<LoginResponse> {
|
|
||||||
const status = response.status;
|
|
||||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
|
||||||
if (status === 200) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result200: any = null;
|
|
||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as LoginResponse;
|
|
||||||
return result200;
|
|
||||||
});
|
|
||||||
} else if (status === 401) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result401: any = null;
|
|
||||||
result401 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
|
||||||
return throwException("A server side error occurred.", status, _responseText, _headers, result401);
|
|
||||||
});
|
|
||||||
} else if (status !== 200 && status !== 204) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve<LoginResponse>(null as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DashboardClient {
|
|
||||||
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
|
|
||||||
private baseUrl: string;
|
|
||||||
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
|
|
||||||
|
|
||||||
constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }) {
|
|
||||||
this.http = http ? http : window as any;
|
|
||||||
this.baseUrl = baseUrl ?? "http://localhost:5099";
|
|
||||||
}
|
|
||||||
|
|
||||||
summary(year: number | undefined, month: number | null | undefined): Promise<SummaryDto> {
|
|
||||||
let url_ = this.baseUrl + "/api/dashboard/summary?";
|
|
||||||
if (year === null)
|
|
||||||
throw new globalThis.Error("The parameter 'year' cannot be null.");
|
|
||||||
else if (year !== undefined)
|
|
||||||
url_ += "year=" + encodeURIComponent("" + year) + "&";
|
|
||||||
if (month !== undefined && month !== null)
|
|
||||||
url_ += "month=" + encodeURIComponent("" + month) + "&";
|
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
|
||||||
|
|
||||||
let options_: RequestInit = {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
|
||||||
return this.processSummary(_response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected processSummary(response: Response): Promise<SummaryDto> {
|
|
||||||
const status = response.status;
|
|
||||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
|
||||||
if (status === 200) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result200: any = null;
|
|
||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as SummaryDto;
|
|
||||||
return result200;
|
|
||||||
});
|
|
||||||
} else if (status !== 200 && status !== 204) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve<SummaryDto>(null as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
spendingByCategory(year: number | undefined, month: number | null | undefined): Promise<CategorySpendingDto[]> {
|
|
||||||
let url_ = this.baseUrl + "/api/dashboard/spending-by-category?";
|
|
||||||
if (year === null)
|
|
||||||
throw new globalThis.Error("The parameter 'year' cannot be null.");
|
|
||||||
else if (year !== undefined)
|
|
||||||
url_ += "year=" + encodeURIComponent("" + year) + "&";
|
|
||||||
if (month !== undefined && month !== null)
|
|
||||||
url_ += "month=" + encodeURIComponent("" + month) + "&";
|
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
|
||||||
|
|
||||||
let options_: RequestInit = {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
|
||||||
return this.processSpendingByCategory(_response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected processSpendingByCategory(response: Response): Promise<CategorySpendingDto[]> {
|
|
||||||
const status = response.status;
|
|
||||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
|
||||||
if (status === 200) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result200: any = null;
|
|
||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CategorySpendingDto[];
|
|
||||||
return result200;
|
|
||||||
});
|
|
||||||
} else if (status !== 200 && status !== 204) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve<CategorySpendingDto[]>(null as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
monthlyBalances(year: number | undefined): Promise<MonthlyBalanceDto[]> {
|
|
||||||
let url_ = this.baseUrl + "/api/dashboard/monthly-balances?";
|
|
||||||
if (year === null)
|
|
||||||
throw new globalThis.Error("The parameter 'year' cannot be null.");
|
|
||||||
else if (year !== undefined)
|
|
||||||
url_ += "year=" + encodeURIComponent("" + year) + "&";
|
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
|
||||||
|
|
||||||
let options_: RequestInit = {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
|
||||||
return this.processMonthlyBalances(_response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected processMonthlyBalances(response: Response): Promise<MonthlyBalanceDto[]> {
|
|
||||||
const status = response.status;
|
|
||||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
|
||||||
if (status === 200) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result200: any = null;
|
|
||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as MonthlyBalanceDto[];
|
|
||||||
return result200;
|
|
||||||
});
|
|
||||||
} else if (status !== 200 && status !== 204) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve<MonthlyBalanceDto[]>(null as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
cumulativeSpending(year: number | undefined, month: number | undefined): Promise<CumulativeSpendingDto[]> {
|
|
||||||
let url_ = this.baseUrl + "/api/dashboard/cumulative-spending?";
|
|
||||||
if (year === null)
|
|
||||||
throw new globalThis.Error("The parameter 'year' cannot be null.");
|
|
||||||
else if (year !== undefined)
|
|
||||||
url_ += "year=" + encodeURIComponent("" + year) + "&";
|
|
||||||
if (month === null)
|
|
||||||
throw new globalThis.Error("The parameter 'month' cannot be null.");
|
|
||||||
else if (month !== undefined)
|
|
||||||
url_ += "month=" + encodeURIComponent("" + month) + "&";
|
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
|
||||||
|
|
||||||
let options_: RequestInit = {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
|
||||||
return this.processCumulativeSpending(_response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected processCumulativeSpending(response: Response): Promise<CumulativeSpendingDto[]> {
|
|
||||||
const status = response.status;
|
|
||||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
|
||||||
if (status === 200) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result200: any = null;
|
|
||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as CumulativeSpendingDto[];
|
|
||||||
return result200;
|
|
||||||
});
|
|
||||||
} else if (status !== 200 && status !== 204) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve<CumulativeSpendingDto[]>(null as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TransactionsClient {
|
|
||||||
private http: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> };
|
|
||||||
private baseUrl: string;
|
|
||||||
protected jsonParseReviver: ((key: string, value: any) => any) | undefined = undefined;
|
|
||||||
|
|
||||||
constructor(baseUrl?: string, http?: { fetch(url: RequestInfo, init?: RequestInit): Promise<Response> }) {
|
|
||||||
this.http = http ? http : window as any;
|
|
||||||
this.baseUrl = baseUrl ?? "http://localhost:5099";
|
|
||||||
}
|
|
||||||
|
|
||||||
import(file: FileParameter | null | undefined): Promise<ImportResult> {
|
|
||||||
let url_ = this.baseUrl + "/api/transactions/import";
|
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
|
||||||
|
|
||||||
const content_ = new FormData();
|
|
||||||
if (file !== null && file !== undefined)
|
|
||||||
content_.append("file", file.data, file.fileName ? file.fileName : "file");
|
|
||||||
|
|
||||||
let options_: RequestInit = {
|
|
||||||
body: content_,
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
|
||||||
return this.processImport(_response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected processImport(response: Response): Promise<ImportResult> {
|
|
||||||
const status = response.status;
|
|
||||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
|
||||||
if (status === 200) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result200: any = null;
|
|
||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ImportResult;
|
|
||||||
return result200;
|
|
||||||
});
|
|
||||||
} else if (status === 400) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result400: any = null;
|
|
||||||
result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ErrorResult;
|
|
||||||
return throwException("A server side error occurred.", status, _responseText, _headers, result400);
|
|
||||||
});
|
|
||||||
} else if (status !== 200 && status !== 204) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve<ImportResult>(null as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
list(year: number | undefined, month: number | null | undefined, category: string | null | undefined, search: string | null | undefined, page: number | undefined, pageSize: number | undefined): Promise<TransactionListResponse> {
|
|
||||||
let url_ = this.baseUrl + "/api/transactions?";
|
|
||||||
if (year === null)
|
|
||||||
throw new globalThis.Error("The parameter 'year' cannot be null.");
|
|
||||||
else if (year !== undefined)
|
|
||||||
url_ += "year=" + encodeURIComponent("" + year) + "&";
|
|
||||||
if (month !== undefined && month !== null)
|
|
||||||
url_ += "month=" + encodeURIComponent("" + month) + "&";
|
|
||||||
if (category !== undefined && category !== null)
|
|
||||||
url_ += "category=" + encodeURIComponent("" + category) + "&";
|
|
||||||
if (search !== undefined && search !== null)
|
|
||||||
url_ += "search=" + encodeURIComponent("" + search) + "&";
|
|
||||||
if (page === null)
|
|
||||||
throw new globalThis.Error("The parameter 'page' cannot be null.");
|
|
||||||
else if (page !== undefined)
|
|
||||||
url_ += "page=" + encodeURIComponent("" + page) + "&";
|
|
||||||
if (pageSize === null)
|
|
||||||
throw new globalThis.Error("The parameter 'pageSize' cannot be null.");
|
|
||||||
else if (pageSize !== undefined)
|
|
||||||
url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&";
|
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
|
||||||
|
|
||||||
let options_: RequestInit = {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
|
||||||
return this.processList(_response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected processList(response: Response): Promise<TransactionListResponse> {
|
|
||||||
const status = response.status;
|
|
||||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
|
||||||
if (status === 200) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result200: any = null;
|
|
||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as TransactionListResponse;
|
|
||||||
return result200;
|
|
||||||
});
|
|
||||||
} else if (status !== 200 && status !== 204) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve<TransactionListResponse>(null as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
categories(): Promise<string[]> {
|
|
||||||
let url_ = this.baseUrl + "/api/transactions/categories";
|
|
||||||
url_ = url_.replace(/[?&]$/, "");
|
|
||||||
|
|
||||||
let options_: RequestInit = {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Accept": "application/json"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
|
||||||
return this.processCategories(_response);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected processCategories(response: Response): Promise<string[]> {
|
|
||||||
const status = response.status;
|
|
||||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
|
||||||
if (status === 200) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
let result200: any = null;
|
|
||||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as string[];
|
|
||||||
return result200;
|
|
||||||
});
|
|
||||||
} else if (status !== 200 && status !== 204) {
|
|
||||||
return response.text().then((_responseText) => {
|
|
||||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.resolve<string[]>(null as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
token?: string;
|
|
||||||
expiresAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProblemDetails {
|
|
||||||
type?: string | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
status?: number | undefined;
|
|
||||||
detail?: string | undefined;
|
|
||||||
instance?: string | undefined;
|
|
||||||
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SummaryDto {
|
|
||||||
totalSpent?: number;
|
|
||||||
totalIncome?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CategorySpendingDto {
|
|
||||||
category?: string;
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MonthlyBalanceDto {
|
|
||||||
month?: number;
|
|
||||||
closingBalance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CumulativeSpendingDto {
|
|
||||||
day?: number;
|
|
||||||
cumulativeSpent?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportResult {
|
|
||||||
recordsImported?: number;
|
|
||||||
recordsSkipped?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorResult {
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TransactionListResponse {
|
|
||||||
items?: TransactionDto[];
|
|
||||||
totalCount?: number;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TransactionDto {
|
|
||||||
id?: number;
|
|
||||||
bookingDate?: string;
|
|
||||||
counterPartyName?: string | undefined;
|
|
||||||
category?: string | undefined;
|
|
||||||
amount?: number;
|
|
||||||
balance?: number;
|
|
||||||
message?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileParameter {
|
|
||||||
data: any;
|
|
||||||
fileName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SwaggerException extends Error {
|
|
||||||
override message: string;
|
|
||||||
status: number;
|
|
||||||
response: string;
|
|
||||||
headers: { [key: string]: any; };
|
|
||||||
result: any;
|
|
||||||
|
|
||||||
constructor(message: string, status: number, response: string, headers: { [key: string]: any; }, result: any) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.message = message;
|
|
||||||
this.status = status;
|
|
||||||
this.response = response;
|
|
||||||
this.headers = headers;
|
|
||||||
this.result = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected isSwaggerException = true;
|
|
||||||
|
|
||||||
static isSwaggerException(obj: any): obj is SwaggerException {
|
|
||||||
return obj.isSwaggerException === true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function throwException(message: string, status: number, response: string, headers: { [key: string]: any; }, result?: any): any {
|
|
||||||
if (result !== null && result !== undefined)
|
|
||||||
throw result;
|
|
||||||
else
|
|
||||||
throw new SwaggerException(message, status, response, headers, null);
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { AuthClient, TransactionsClient, DashboardClient } from './apiClient'
|
|
||||||
|
|
||||||
// Custom fetch that injects the JWT and handles 401
|
|
||||||
class AuthFetch {
|
|
||||||
fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const headers = new Headers(init?.headers)
|
|
||||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
|
||||||
|
|
||||||
return fetch(url, { ...init, headers }).then((res) => {
|
|
||||||
if (res.status === 401) {
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
localStorage.removeItem('expiresAt')
|
|
||||||
// Navigate to login — import router lazily to avoid circular dep
|
|
||||||
import('../router').then(({ default: router }) => router.push('/login'))
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const httpClient = new AuthFetch()
|
|
||||||
|
|
||||||
// Base URL is empty: NSwag generates full paths including /api/ prefix
|
|
||||||
export const authApi = new AuthClient('', httpClient)
|
|
||||||
export const transactionsApi = new TransactionsClient('', httpClient)
|
|
||||||
export const dashboardApi = new DashboardClient('', httpClient)
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { CumulativeSpendingDto } from '../api/apiClient'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
data: CumulativeSpendingDto[]
|
|
||||||
loading: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
|
||||||
chart: { type: 'line', background: 'transparent', toolbar: { show: false } },
|
|
||||||
xaxis: { categories: props.data.map(d => `${d.day}.`) },
|
|
||||||
theme: { mode: 'dark' },
|
|
||||||
stroke: { curve: 'smooth', width: 2 },
|
|
||||||
dataLabels: { enabled: false },
|
|
||||||
tooltip: {
|
|
||||||
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
|
||||||
},
|
|
||||||
colors: ['#ef5350'],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const series = computed(() => [{
|
|
||||||
name: 'Kumulativní výdaje',
|
|
||||||
data: props.data.map(d => d.cumulativeSpent ?? 0)
|
|
||||||
}])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="loading"><v-skeleton-loader type="image" /></div>
|
|
||||||
<div v-else-if="data.length === 0" class="text-center text-medium-emphasis py-4">Žádné výdaje</div>
|
|
||||||
<apexchart v-else type="line" :options="chartOptions" :series="series" height="220" />
|
|
||||||
</template>
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { CategorySpendingDto } from '../api/apiClient'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
data: CategorySpendingDto[]
|
|
||||||
loading: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
|
||||||
chart: { type: 'donut', background: 'transparent' },
|
|
||||||
labels: props.data.map(d => d.category ?? 'Nezatříděno'),
|
|
||||||
theme: { mode: 'dark' },
|
|
||||||
legend: { show: false },
|
|
||||||
dataLabels: { enabled: false },
|
|
||||||
tooltip: {
|
|
||||||
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
const series = computed(() => props.data.map(d => d.total ?? 0))
|
|
||||||
|
|
||||||
const legendItems = computed(() =>
|
|
||||||
props.data.map((d, i) => ({
|
|
||||||
label: d.category ?? 'Nezatříděno',
|
|
||||||
total: d.total ?? 0,
|
|
||||||
color: getColor(i),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
function getColor(i: number) {
|
|
||||||
const colors = ['#ef5350','#42a5f5','#66bb6a','#ffa726','#ab47bc',
|
|
||||||
'#26c6da','#d4e157','#ff7043','#8d6e63','#78909c']
|
|
||||||
return colors[i % colors.length]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="loading">
|
|
||||||
<v-skeleton-loader type="image" />
|
|
||||||
</div>
|
|
||||||
<div v-else-if="data.length === 0" class="text-center text-medium-emphasis py-4">
|
|
||||||
Žádné výdaje
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<apexchart type="donut" :options="chartOptions" :series="series" height="200" />
|
|
||||||
<div class="mt-2">
|
|
||||||
<div
|
|
||||||
v-for="item in legendItems"
|
|
||||||
:key="item.label"
|
|
||||||
class="d-flex align-center gap-2 mb-1"
|
|
||||||
>
|
|
||||||
<div :style="{ width: '10px', height: '10px', borderRadius: '50%', background: item.color, flexShrink: 0 }" />
|
|
||||||
<span class="text-body-2 flex-grow-1">{{ item.label }}</span>
|
|
||||||
<span class="text-body-2 text-medium-emphasis">{{ item.total.toLocaleString('cs-CZ') }} Kč</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useDashPeriodStore } from '../stores/dashPeriod'
|
|
||||||
import { dashboardApi } from '../api'
|
|
||||||
import type { CategorySpendingDto, CumulativeSpendingDto, SummaryDto } from '../api/apiClient'
|
|
||||||
import DonutChart from './DonutChart.vue'
|
|
||||||
import CumulativeSpendingChart from './CumulativeSpendingChart.vue'
|
|
||||||
|
|
||||||
const period = useDashPeriodStore()
|
|
||||||
|
|
||||||
const summary = ref<SummaryDto | null>(null)
|
|
||||||
const categories = ref<CategorySpendingDto[]>([])
|
|
||||||
const cumulative = ref<CumulativeSpendingDto[]>([])
|
|
||||||
const loadingSummary = ref(false)
|
|
||||||
const loadingCategories = ref(false)
|
|
||||||
const loadingCumulative = ref(false)
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loadingSummary.value = true
|
|
||||||
loadingCategories.value = true
|
|
||||||
loadingCumulative.value = true
|
|
||||||
try {
|
|
||||||
const [s, c, cu] = await Promise.all([
|
|
||||||
dashboardApi.summary(period.year, period.month),
|
|
||||||
dashboardApi.spendingByCategory(period.year, period.month),
|
|
||||||
dashboardApi.cumulativeSpending(period.year, period.month),
|
|
||||||
])
|
|
||||||
summary.value = s
|
|
||||||
categories.value = c
|
|
||||||
cumulative.value = cu
|
|
||||||
} finally {
|
|
||||||
loadingSummary.value = false
|
|
||||||
loadingCategories.value = false
|
|
||||||
loadingCumulative.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([() => period.year, () => period.month], load, { immediate: true })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-card height="100%">
|
|
||||||
<v-card-title class="d-flex align-center pa-3">
|
|
||||||
<v-btn icon="mdi-chevron-left" variant="text" @click="period.prevMonth" />
|
|
||||||
<span class="flex-grow-1 text-center text-h6">{{ period.monthLabel }}</span>
|
|
||||||
<v-btn icon="mdi-chevron-right" variant="text" @click="period.nextMonth" />
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-card-text>
|
|
||||||
<v-skeleton-loader v-if="loadingSummary" type="text,text" />
|
|
||||||
<v-row v-else class="mb-4">
|
|
||||||
<v-col cols="6" class="text-center">
|
|
||||||
<div class="text-caption text-medium-emphasis">VÝDAJE</div>
|
|
||||||
<div class="text-h6 text-error">{{ (summary?.totalSpent ?? 0).toLocaleString('cs-CZ') }} Kč</div>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="6" class="text-center">
|
|
||||||
<div class="text-caption text-medium-emphasis">PŘÍJMY</div>
|
|
||||||
<div class="text-h6 text-success">{{ (summary?.totalIncome ?? 0).toLocaleString('cs-CZ') }} Kč</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12" md="5">
|
|
||||||
<div class="text-caption text-medium-emphasis mb-1">VÝDAJE DLE KATEGORIÍ</div>
|
|
||||||
<DonutChart :data="categories" :loading="loadingCategories" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="7">
|
|
||||||
<div class="text-caption text-medium-emphasis mb-1">KUMULATIVNÍ VÝDAJE</div>
|
|
||||||
<CumulativeSpendingChart :data="cumulative" :loading="loadingCumulative" />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</template>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { MonthlyBalanceDto } from '../api/apiClient'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
data: MonthlyBalanceDto[]
|
|
||||||
loading: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const MONTH_NAMES = ['Led','Úno','Bře','Dub','Kvě','Čvn','Čvc','Srp','Zář','Říj','Lis','Pro']
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
|
||||||
chart: { type: 'bar', background: 'transparent', toolbar: { show: false } },
|
|
||||||
xaxis: { categories: props.data.map(d => MONTH_NAMES[(d.month ?? 1) - 1]) },
|
|
||||||
theme: { mode: 'dark' },
|
|
||||||
dataLabels: { enabled: false },
|
|
||||||
tooltip: {
|
|
||||||
y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
|
|
||||||
},
|
|
||||||
colors: ['#42a5f5'],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const series = computed(() => [{
|
|
||||||
name: 'Zůstatek',
|
|
||||||
data: props.data.map(d => d.closingBalance ?? 0)
|
|
||||||
}])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="loading"><v-skeleton-loader type="image" /></div>
|
|
||||||
<apexchart v-else type="bar" :options="chartOptions" :series="series" height="220" />
|
|
||||||
</template>
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { transactionsApi } from '../api'
|
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const snackbar = ref(false)
|
|
||||||
const snackbarText = ref('')
|
|
||||||
const snackbarColor = ref('success')
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
function openPicker() {
|
|
||||||
fileInput.value?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onFileSelected(event: Event) {
|
|
||||||
const file = (event.target as HTMLInputElement).files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await transactionsApi.import({ fileName: file.name, data: file })
|
|
||||||
snackbarText.value = `Importováno ${result.recordsImported}, přeskočeno ${result.recordsSkipped}.`
|
|
||||||
snackbarColor.value = 'success'
|
|
||||||
} catch (e: any) {
|
|
||||||
const body = await e?.response?.json().catch(() => null)
|
|
||||||
snackbarText.value = body?.error ?? 'Chyba při importu.'
|
|
||||||
snackbarColor.value = 'error'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
snackbar.value = true
|
|
||||||
if (fileInput.value) fileInput.value.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
style="display:none"
|
|
||||||
@change="onFileSelected"
|
|
||||||
/>
|
|
||||||
<v-btn
|
|
||||||
prepend-icon="mdi-upload"
|
|
||||||
color="primary"
|
|
||||||
variant="outlined"
|
|
||||||
:loading="loading"
|
|
||||||
@click="openPicker"
|
|
||||||
>
|
|
||||||
Nahrát CSV
|
|
||||||
</v-btn>
|
|
||||||
<v-snackbar v-model="snackbar" :color="snackbarColor" timeout="4000">
|
|
||||||
{{ snackbarText }}
|
|
||||||
</v-snackbar>
|
|
||||||
</template>
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useDashPeriodStore } from '../stores/dashPeriod'
|
|
||||||
import { dashboardApi } from '../api'
|
|
||||||
import type { CategorySpendingDto, MonthlyBalanceDto, SummaryDto } from '../api/apiClient'
|
|
||||||
import DonutChart from './DonutChart.vue'
|
|
||||||
import MonthlyBalancesChart from './MonthlyBalancesChart.vue'
|
|
||||||
|
|
||||||
const period = useDashPeriodStore()
|
|
||||||
|
|
||||||
const summary = ref<SummaryDto | null>(null)
|
|
||||||
const categories = ref<CategorySpendingDto[]>([])
|
|
||||||
const balances = ref<MonthlyBalanceDto[]>([])
|
|
||||||
const loadingSummary = ref(false)
|
|
||||||
const loadingCategories = ref(false)
|
|
||||||
const loadingBalances = ref(false)
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loadingSummary.value = true
|
|
||||||
loadingCategories.value = true
|
|
||||||
loadingBalances.value = true
|
|
||||||
try {
|
|
||||||
const [s, c, b] = await Promise.all([
|
|
||||||
dashboardApi.summary(period.year, undefined),
|
|
||||||
dashboardApi.spendingByCategory(period.year, undefined),
|
|
||||||
dashboardApi.monthlyBalances(period.year),
|
|
||||||
])
|
|
||||||
summary.value = s
|
|
||||||
categories.value = c
|
|
||||||
balances.value = b
|
|
||||||
} finally {
|
|
||||||
loadingSummary.value = false
|
|
||||||
loadingCategories.value = false
|
|
||||||
loadingBalances.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => period.year, load, { immediate: true })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-card height="100%">
|
|
||||||
<v-card-title class="d-flex align-center pa-3">
|
|
||||||
<v-btn icon="mdi-chevron-left" variant="text" @click="period.prevYear" />
|
|
||||||
<span class="flex-grow-1 text-center text-h6">{{ period.year }}</span>
|
|
||||||
<v-btn icon="mdi-chevron-right" variant="text" @click="period.nextYear" />
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-card-text>
|
|
||||||
<v-skeleton-loader v-if="loadingSummary" type="text,text" />
|
|
||||||
<v-row v-else class="mb-4">
|
|
||||||
<v-col cols="6" class="text-center">
|
|
||||||
<div class="text-caption text-medium-emphasis">VÝDAJE</div>
|
|
||||||
<div class="text-h6 text-error">{{ (summary?.totalSpent ?? 0).toLocaleString('cs-CZ') }} Kč</div>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="6" class="text-center">
|
|
||||||
<div class="text-caption text-medium-emphasis">PŘÍJMY</div>
|
|
||||||
<div class="text-h6 text-success">{{ (summary?.totalIncome ?? 0).toLocaleString('cs-CZ') }} Kč</div>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12" md="5">
|
|
||||||
<div class="text-caption text-medium-emphasis mb-1">VÝDAJE DLE KATEGORIÍ</div>
|
|
||||||
<DonutChart :data="categories" :loading="loadingCategories" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="7">
|
|
||||||
<div class="text-caption text-medium-emphasis mb-1">MĚSÍČNÍ ZŮSTATKY</div>
|
|
||||||
<MonthlyBalancesChart :data="balances" :loading="loadingBalances" />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</template>
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import { createVuetify } from 'vuetify'
|
|
||||||
import { aliases, mdi } from 'vuetify/iconsets/mdi'
|
|
||||||
import * as components from 'vuetify/components'
|
|
||||||
import * as directives from 'vuetify/directives'
|
|
||||||
import VueApexCharts from 'vue3-apexcharts'
|
|
||||||
import router from './router'
|
|
||||||
import App from './App.vue'
|
|
||||||
|
|
||||||
import 'vuetify/styles'
|
|
||||||
import '@mdi/font/css/materialdesignicons.css'
|
|
||||||
|
|
||||||
const vuetify = createVuetify({
|
|
||||||
components,
|
|
||||||
directives,
|
|
||||||
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
|
|
||||||
theme: {
|
|
||||||
defaultTheme: 'dark',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
createApp(App)
|
|
||||||
.use(createPinia())
|
|
||||||
.use(router)
|
|
||||||
.use(vuetify)
|
|
||||||
.use(VueApexCharts)
|
|
||||||
.mount('#app')
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes: [
|
|
||||||
{ path: '/login', component: () => import('../views/LoginView.vue'), meta: { public: true } },
|
|
||||||
{ path: '/', component: () => import('../views/DashboardView.vue') },
|
|
||||||
{ path: '/transactions', component: () => import('../views/TransactionsView.vue') },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
router.beforeEach((to) => {
|
|
||||||
if (to.meta.public) return true
|
|
||||||
const auth = useAuthStore()
|
|
||||||
if (!auth.isAuthenticated) return '/login'
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { authApi } from '../api'
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const token = ref<string | null>(localStorage.getItem('token'))
|
|
||||||
const expiresAt = ref<string | null>(localStorage.getItem('expiresAt'))
|
|
||||||
|
|
||||||
const isAuthenticated = computed(() => {
|
|
||||||
if (!token.value || !expiresAt.value) return false
|
|
||||||
return Date.now() < Date.parse(expiresAt.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function login(username: string, password: string): Promise<void> {
|
|
||||||
const response = await authApi.login({ username, password })
|
|
||||||
token.value = response.token ?? null
|
|
||||||
expiresAt.value = response.expiresAt ?? null
|
|
||||||
if (token.value) localStorage.setItem('token', token.value)
|
|
||||||
if (expiresAt.value) localStorage.setItem('expiresAt', expiresAt.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
token.value = null
|
|
||||||
expiresAt.value = null
|
|
||||||
localStorage.removeItem('token')
|
|
||||||
localStorage.removeItem('expiresAt')
|
|
||||||
}
|
|
||||||
|
|
||||||
return { token, isAuthenticated, login, logout }
|
|
||||||
})
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
|
|
||||||
export const useDashPeriodStore = defineStore('dashPeriod', () => {
|
|
||||||
const now = new Date()
|
|
||||||
const year = ref(now.getFullYear())
|
|
||||||
const month = ref(now.getMonth() + 1) // 1-based
|
|
||||||
|
|
||||||
const monthLabel = computed(() => {
|
|
||||||
const d = new Date(year.value, month.value - 1, 1)
|
|
||||||
return d.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
|
|
||||||
})
|
|
||||||
|
|
||||||
function prevMonth() {
|
|
||||||
if (month.value === 1) { month.value = 12; year.value-- }
|
|
||||||
else month.value--
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextMonth() {
|
|
||||||
if (month.value === 12) { month.value = 1; year.value++ }
|
|
||||||
else month.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevYear() { year.value-- }
|
|
||||||
function nextYear() { year.value++ }
|
|
||||||
|
|
||||||
return { year, month, monthLabel, prevMonth, nextMonth, prevYear, nextYear }
|
|
||||||
})
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
export const useTxPeriodStore = defineStore('txPeriod', () => {
|
|
||||||
const now = new Date()
|
|
||||||
const year = ref(now.getFullYear())
|
|
||||||
const month = ref<number | null>(null)
|
|
||||||
|
|
||||||
return { year, month }
|
|
||||||
})
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import YearSummary from '../components/YearSummary.vue'
|
|
||||||
import MonthSummary from '../components/MonthSummary.vue'
|
|
||||||
import UploadCsvButton from '../components/UploadCsvButton.vue'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
auth.logout()
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-app-bar>
|
|
||||||
<v-app-bar-title>Finance Tracker</v-app-bar-title>
|
|
||||||
<template #append>
|
|
||||||
<UploadCsvButton class="mr-2" />
|
|
||||||
<v-btn to="/transactions" variant="text" class="mr-2">Transakce</v-btn>
|
|
||||||
<v-btn icon="mdi-logout" variant="text" @click="logout" />
|
|
||||||
</template>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<v-main>
|
|
||||||
<v-container fluid class="pa-4">
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<YearSummary />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<MonthSummary />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-container>
|
|
||||||
</v-main>
|
|
||||||
</template>
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const username = ref('')
|
|
||||||
const password = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await auth.login(username.value, password.value)
|
|
||||||
router.push('/')
|
|
||||||
} catch {
|
|
||||||
error.value = 'Nesprávné přihlašovací údaje.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-container class="fill-height" fluid>
|
|
||||||
<v-row align="center" justify="center">
|
|
||||||
<v-col cols="12" sm="8" md="4">
|
|
||||||
<v-card>
|
|
||||||
<v-card-title class="text-h5 pa-6">Finance Tracker</v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
<v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
|
|
||||||
<v-text-field
|
|
||||||
v-model="username"
|
|
||||||
label="Uživatelské jméno"
|
|
||||||
prepend-inner-icon="mdi-account"
|
|
||||||
@keyup.enter="submit"
|
|
||||||
/>
|
|
||||||
<v-text-field
|
|
||||||
v-model="password"
|
|
||||||
label="Heslo"
|
|
||||||
type="password"
|
|
||||||
prepend-inner-icon="mdi-lock"
|
|
||||||
@keyup.enter="submit"
|
|
||||||
/>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions class="pa-6 pt-0">
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
color="primary"
|
|
||||||
size="large"
|
|
||||||
:loading="loading"
|
|
||||||
@click="submit"
|
|
||||||
>
|
|
||||||
Přihlásit se
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { transactionsApi } from '../api'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
import { useTxPeriodStore } from '../stores/txPeriod'
|
|
||||||
import type { TransactionDto } from '../api/apiClient'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const period = useTxPeriodStore()
|
|
||||||
|
|
||||||
const items = ref<TransactionDto[]>([])
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const page = ref(1)
|
|
||||||
const loading = ref(false)
|
|
||||||
const categories = ref<string[]>([])
|
|
||||||
const selectedCategory = ref<string | null>(null)
|
|
||||||
const search = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
|
|
||||||
const MONTHS = [
|
|
||||||
{ title: 'Vše', value: null },
|
|
||||||
...Array.from({ length: 12 }, (_, i) => ({
|
|
||||||
title: new Date(2000, i, 1).toLocaleDateString('cs-CZ', { month: 'long' }),
|
|
||||||
value: i + 1
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
|
|
||||||
const YEARS = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
|
|
||||||
|
|
||||||
const headers = [
|
|
||||||
{ title: 'Datum', key: 'bookingDate', sortable: false },
|
|
||||||
{ title: 'Protiúčet / Zpráva', key: 'counterPartyName', sortable: false },
|
|
||||||
{ title: 'Kategorie', key: 'category', sortable: false },
|
|
||||||
{ title: 'Částka', key: 'amount', sortable: false, align: 'end' as const },
|
|
||||||
{ title: 'Zůstatek', key: 'balance', sortable: false, align: 'end' as const },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function loadCategories() {
|
|
||||||
categories.value = await transactionsApi.categories()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTransactions() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const result = await transactionsApi.list(
|
|
||||||
period.year,
|
|
||||||
period.month ?? undefined,
|
|
||||||
selectedCategory.value ?? undefined,
|
|
||||||
search.value || undefined,
|
|
||||||
page.value,
|
|
||||||
50
|
|
||||||
)
|
|
||||||
items.value = result.items ?? []
|
|
||||||
totalCount.value = result.totalCount ?? 0
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSearchInput() {
|
|
||||||
if (searchTimeout) clearTimeout(searchTimeout)
|
|
||||||
searchTimeout = setTimeout(() => { page.value = 1; loadTransactions() }, 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch([() => period.year, () => period.month, selectedCategory, page], loadTransactions)
|
|
||||||
|
|
||||||
loadCategories()
|
|
||||||
loadTransactions()
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
auth.logout()
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAmount(val: number) {
|
|
||||||
return `${val.toLocaleString('cs-CZ')} Kč`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-app-bar>
|
|
||||||
<v-app-bar-title>Finance Tracker — Transakce</v-app-bar-title>
|
|
||||||
<template #append>
|
|
||||||
<v-btn to="/" variant="text" class="mr-2">Dashboard</v-btn>
|
|
||||||
<v-btn icon="mdi-logout" variant="text" @click="logout" />
|
|
||||||
</template>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<v-main>
|
|
||||||
<v-container fluid class="pa-4">
|
|
||||||
<v-row class="mb-2">
|
|
||||||
<v-col cols="6" md="2">
|
|
||||||
<v-select
|
|
||||||
v-model="period.year"
|
|
||||||
:items="YEARS"
|
|
||||||
label="Rok"
|
|
||||||
density="compact"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="6" md="2">
|
|
||||||
<v-select
|
|
||||||
v-model="period.month"
|
|
||||||
:items="MONTHS"
|
|
||||||
item-title="title"
|
|
||||||
item-value="value"
|
|
||||||
label="Měsíc"
|
|
||||||
density="compact"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="3">
|
|
||||||
<v-select
|
|
||||||
v-model="selectedCategory"
|
|
||||||
:items="[{ title: 'Vše', value: null }, ...categories.map(c => ({ title: c, value: c }))]"
|
|
||||||
item-title="title"
|
|
||||||
item-value="value"
|
|
||||||
label="Kategorie"
|
|
||||||
density="compact"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="5">
|
|
||||||
<v-text-field
|
|
||||||
v-model="search"
|
|
||||||
label="Hledat"
|
|
||||||
prepend-inner-icon="mdi-magnify"
|
|
||||||
density="compact"
|
|
||||||
clearable
|
|
||||||
@input="onSearchInput"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-data-table-server
|
|
||||||
:headers="headers"
|
|
||||||
:items="items"
|
|
||||||
:items-length="totalCount"
|
|
||||||
:loading="loading"
|
|
||||||
:items-per-page="50"
|
|
||||||
v-model:page="page"
|
|
||||||
@update:options="loadTransactions"
|
|
||||||
>
|
|
||||||
<template #item.bookingDate="{ item }">
|
|
||||||
{{ item.bookingDate }}
|
|
||||||
</template>
|
|
||||||
<template #item.counterPartyName="{ item }">
|
|
||||||
<div>{{ item.counterPartyName }}</div>
|
|
||||||
<div class="text-caption text-medium-emphasis">{{ item.message }}</div>
|
|
||||||
</template>
|
|
||||||
<template #item.amount="{ item }">
|
|
||||||
<span :class="(item.amount ?? 0) < 0 ? 'text-error' : 'text-success'">
|
|
||||||
{{ formatAmount(item.amount ?? 0) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #item.balance="{ item }">
|
|
||||||
{{ formatAmount(item.balance ?? 0) }}
|
|
||||||
</template>
|
|
||||||
</v-data-table-server>
|
|
||||||
</v-container>
|
|
||||||
</v-main>
|
|
||||||
</template>
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
|
||||||
"exclude": ["src/**/__tests__/*"]
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.node.json" },
|
|
||||||
{ "path": "./tsconfig.app.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@tsconfig/node22/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
|
||||||
},
|
|
||||||
"include": ["vite.config.*"]
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import vuetify from 'vite-plugin-vuetify'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
vuetify({ autoImport: true }),
|
|
||||||
],
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:5000',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue
Block a user