Compare commits
10 Commits
387c18e834
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c5a28bd9 | |||
| e21a2fc1c5 | |||
| 68c7a1ca6a | |||
| 4636acf7b0 | |||
| 08185f88cd | |||
| b8b1c74a84 | |||
| 55c2c01418 | |||
| 9807e4d61d | |||
| 9aa1fee49e | |||
| 69181e66b0 |
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
"program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.dll",
|
||||
"program": "${workspaceFolder}/src/Duempelkas.Desktop/bin/Debug/net10.0/Duempelkas.Desktop.exe",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}/src/Duempelkas.Desktop",
|
||||
"stopAtEntry": false,
|
||||
|
||||
@@ -9,8 +9,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public AccountSummaryDto Account { get; set; } = default!;
|
||||
|
||||
private void Navigate() => Navigation.NavigateTo($"/accounts/{Account.Id}");
|
||||
}
|
||||
|
||||
20
src/Duempelkas.App/Components/Accounts/AccountCard.razor.cs
Normal file
20
src/Duempelkas.App/Components/Accounts/AccountCard.razor.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
public partial class AccountCard
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public AccountSummaryDto Account { get; set; } = default!;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private void Navigate() => Navigation.NavigateTo($"/accounts/{Account.Id}");
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -7,6 +7,3 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public List<AccountSummaryDto> Accounts { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
public partial class AccountCardList
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public List<AccountSummaryDto> Accounts { get; set; } = new();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -34,17 +34,3 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@code {
|
||||
[Parameter] public EntryDto Entry { get; set; } = default!;
|
||||
[Parameter] public EventCallback<int> OnDelete { get; set; }
|
||||
[Parameter] public EventCallback<int> OnRestore { get; set; }
|
||||
[Parameter] public EventCallback<int> OnEdit { get; set; }
|
||||
|
||||
private string GetRowClass()
|
||||
{
|
||||
var classes = new List<string>();
|
||||
if (Entry.IsTransfer) classes.Add("entry-row-transfer");
|
||||
if (Entry.IsDeleted) classes.Add("entry-deleted");
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
}
|
||||
|
||||
43
src/Duempelkas.App/Components/Accounts/EntryRow.razor.cs
Normal file
43
src/Duempelkas.App/Components/Accounts/EntryRow.razor.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
public partial class EntryRow
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public EntryDto Entry { get; set; } = default!;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> OnDelete { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> OnRestore { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> OnEdit { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private string GetRowClass()
|
||||
{
|
||||
var classes = new List<string>();
|
||||
if (Entry.IsTransfer)
|
||||
{
|
||||
classes.Add("entry-row-transfer");
|
||||
}
|
||||
|
||||
if (Entry.IsDeleted)
|
||||
{
|
||||
classes.Add("entry-deleted");
|
||||
}
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -23,9 +23,3 @@
|
||||
<div class="text-center py-3 text-muted">Keine Buchungen vorhanden.</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public List<EntryDto> Entries { get; set; } = new();
|
||||
[Parameter] public EventCallback<int> OnDeleteEntry { get; set; }
|
||||
[Parameter] public EventCallback<int> OnRestoreEntry { get; set; }
|
||||
[Parameter] public EventCallback<int> OnEditEntry { get; set; }
|
||||
}
|
||||
|
||||
23
src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs
Normal file
23
src/Duempelkas.App/Components/Accounts/EntryTable.razor.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Accounts;
|
||||
|
||||
public partial class EntryTable
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public List<EntryDto> Entries { get; set; } = new();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> OnDeleteEntry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> OnRestoreEntry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> OnEditEntry { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="dialog-backdrop" @onclick="Cancel">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5 class="mb-3">Neues Konto</h5>
|
||||
<div class="form-container">
|
||||
@@ -13,17 +13,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback<string> OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private string name = string.Empty;
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
await OnSave.InvokeAsync(name.Trim());
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class AddAccountDialog
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private string name = string.Empty;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
await OnSave.InvokeAsync(name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@inject IEntryService EntryService
|
||||
|
||||
<div class="dialog-backdrop" @onclick="Cancel">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5>@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")</h5>
|
||||
|
||||
@@ -34,36 +34,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int AccountId { get; set; }
|
||||
[Parameter] public EntryDto? EditEntry { get; set; }
|
||||
[Parameter] public EventCallback OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private EntryType entryType = EntryType.Income;
|
||||
private DateTime date = DateTime.Today;
|
||||
private string title = string.Empty;
|
||||
private decimal amount;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
{
|
||||
entryType = EditEntry.Type;
|
||||
date = EditEntry.Date;
|
||||
title = EditEntry.Title;
|
||||
amount = EditEntry.Amount;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
||||
else
|
||||
await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount);
|
||||
await OnSave.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class AddEntryDialog
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EntryDto? EditEntry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private EntryType entryType = EntryType.Income;
|
||||
private DateTime date = DateTime.Today;
|
||||
private string title = string.Empty;
|
||||
private decimal amount;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
{
|
||||
entryType = EditEntry.Type;
|
||||
date = EditEntry.Date;
|
||||
title = EditEntry.Title;
|
||||
amount = EditEntry.Amount;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
{
|
||||
await EntryService.UpdateEntryAsync(EditEntry.Id, date, title.Trim(), amount);
|
||||
}
|
||||
else
|
||||
{
|
||||
await EntryService.CreateEntryAsync(AccountId, entryType, date, title.Trim(), amount);
|
||||
}
|
||||
|
||||
await OnSave.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
@inject IEntryService EntryService
|
||||
@inject IAccountService AccountService
|
||||
|
||||
<div class="dialog-backdrop" @onclick="Cancel">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
|
||||
|
||||
@@ -27,48 +27,9 @@
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-success" @onclick="Save"
|
||||
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Umbuchen")</button>
|
||||
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public int SourceAccountId { get; set; }
|
||||
[Parameter] public EntryDto? EditEntry { get; set; }
|
||||
[Parameter] public EventCallback OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private List<AccountSummaryDto> accounts = new();
|
||||
private int targetAccountId;
|
||||
private DateTime date = DateTime.Today;
|
||||
private string title = string.Empty;
|
||||
private decimal amount;
|
||||
|
||||
private bool CanSave => targetAccountId > 0 && !string.IsNullOrWhiteSpace(title) && amount > 0;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!accounts.Any())
|
||||
accounts = await AccountService.GetAllAccountsAsync();
|
||||
|
||||
if (EditEntry != null)
|
||||
{
|
||||
targetAccountId = EditEntry.LinkedAccountId ?? 0;
|
||||
date = EditEntry.Date;
|
||||
title = EditEntry.Title;
|
||||
amount = EditEntry.Amount;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
||||
else
|
||||
await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
|
||||
await OnSave.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class AddTransferDialog
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public int SourceAccountId { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EntryDto? EditEntry { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private List<AccountSummaryDto> accounts = new();
|
||||
private int targetAccountId;
|
||||
private DateTime date = DateTime.Today;
|
||||
private string title = string.Empty;
|
||||
private decimal amount;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
private bool CanSave => targetAccountId > 0 && !string.IsNullOrWhiteSpace(title) && amount > 0;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (!accounts.Any())
|
||||
{
|
||||
accounts = await AccountService.GetAllAccountsAsync();
|
||||
}
|
||||
|
||||
if (EditEntry != null)
|
||||
{
|
||||
targetAccountId = EditEntry.LinkedAccountId ?? 0;
|
||||
date = EditEntry.Date;
|
||||
title = EditEntry.Title;
|
||||
amount = EditEntry.Amount;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (EditEntry != null)
|
||||
{
|
||||
await EntryService.UpdateTransferAsync(EditEntry.Id, targetAccountId, date, title.Trim(), amount);
|
||||
}
|
||||
else
|
||||
{
|
||||
await EntryService.CreateTransferAsync(SourceAccountId, targetAccountId, date, title.Trim(), amount);
|
||||
}
|
||||
|
||||
await OnSave.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="dialog-backdrop" @onclick="Cancel">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5>@Title</h5>
|
||||
<p>@Message</p>
|
||||
@@ -9,16 +9,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Title { get; set; } = "Bestätigung";
|
||||
[Parameter] public string Message { get; set; } = "Sind Sie sicher?";
|
||||
[Parameter] public string ConfirmText { get; set; } = "Ja, bestätigen";
|
||||
[Parameter] public string CancelText { get; set; } = "Nein, abbrechen";
|
||||
[Parameter] public string ConfirmButtonClass { get; set; } = "btn btn-danger";
|
||||
[Parameter] public string ConfirmIconClass { get; set; } = "bi bi-trash";
|
||||
[Parameter] public EventCallback OnConfirm { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private async Task Confirm() => await OnConfirm.InvokeAsync();
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
42
src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs
Normal file
42
src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class ConfirmDialog
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public string Title { get; set; } = "Bestätigung";
|
||||
|
||||
[Parameter]
|
||||
public string Message { get; set; } = "Sind Sie sicher?";
|
||||
|
||||
[Parameter]
|
||||
public string ConfirmText { get; set; } = "Ja, bestätigen";
|
||||
|
||||
[Parameter]
|
||||
public string CancelText { get; set; } = "Nein, abbrechen";
|
||||
|
||||
[Parameter]
|
||||
public string ConfirmButtonClass { get; set; } = "btn btn-danger";
|
||||
|
||||
[Parameter]
|
||||
public string ConfirmIconClass { get; set; } = "bi bi-trash";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnConfirm { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Confirm() => await OnConfirm.InvokeAsync();
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="dialog-backdrop" @onclick="Cancel">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5 class="mb-3">Übertrag bearbeiten</h5>
|
||||
<div class="form-container">
|
||||
@@ -12,16 +12,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public decimal CurrentAmount { get; set; }
|
||||
[Parameter] public EventCallback<decimal> OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private decimal amount;
|
||||
|
||||
protected override void OnParametersSet() => amount = CurrentAmount;
|
||||
|
||||
private async Task Save() => await OnSave.InvokeAsync(amount);
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class EditCarryoverDialog
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public decimal CurrentAmount { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<decimal> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private decimal amount;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override void OnParametersSet() => amount = CurrentAmount;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Save() => await OnSave.InvokeAsync(amount);
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="dialog-backdrop" @onclick="Cancel">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5 class="mb-3">@DialogTitle</h5>
|
||||
<div class="form-container">
|
||||
@@ -12,22 +12,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string CurrentName { get; set; } = string.Empty;
|
||||
[Parameter] public string DialogTitle { get; set; } = "Kontoname bearbeiten";
|
||||
[Parameter] public string NameLabel { get; set; } = "Kontoname";
|
||||
[Parameter] public EventCallback<string> OnSave { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
private string name = string.Empty;
|
||||
|
||||
protected override void OnParametersSet() => name = CurrentName;
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
await OnSave.InvokeAsync(name.Trim());
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Components.Dialogs;
|
||||
|
||||
public partial class EditNameDialog
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public string CurrentName { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string DialogTitle { get; set; } = "Kontoname bearbeiten";
|
||||
|
||||
[Parameter]
|
||||
public string NameLabel { get; set; } = "Kontoname";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> OnSave { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback OnCancel { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private string name = string.Empty;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override void OnParametersSet() => name = CurrentName;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
await OnSave.InvokeAsync(name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Cancel() => await OnCancel.InvokeAsync();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using System.Reflection
|
||||
|
||||
<div class="d-flex flex-column vh-100">
|
||||
<nav class="app-navbar d-flex justify-content-between align-items-center">
|
||||
@@ -19,11 +18,11 @@
|
||||
|
||||
@if (showAboutDialog)
|
||||
{
|
||||
<div class="dialog-backdrop" @onclick="CloseAboutDialog">
|
||||
<div class="dialog-backdrop">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5>Über Dümpelkas · Version @AppVersion</h5>
|
||||
<p class="mb-2">Entwickler: Andre Beging</p>
|
||||
<p class="mb-3">E-Mail: <a href="mailto:mail@beging.de" style="color: white;">mail@beging.de</a></p>
|
||||
<p class="mb-3">E-Mail: <a href="mailto:mail@beging.de" style="color: var(--color-text);">mail@beging.de</a></p>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="CloseAboutDialog">
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
@@ -33,28 +32,3 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool showAboutDialog;
|
||||
|
||||
private void OpenAboutDialog() => showAboutDialog = true;
|
||||
|
||||
private void CloseAboutDialog() => showAboutDialog = false;
|
||||
|
||||
private static string AppVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
var entryAssembly = Assembly.GetEntryAssembly();
|
||||
var infoVersion = entryAssembly?
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(infoVersion))
|
||||
{
|
||||
return infoVersion.Split('+')[0];
|
||||
}
|
||||
|
||||
return entryAssembly?.GetName().Version?.ToString(2) ?? "1.0";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
42
src/Duempelkas.App/Components/Layout/MainLayout.razor.cs
Normal file
42
src/Duempelkas.App/Components/Layout/MainLayout.razor.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace Duempelkas.App.Components.Layout;
|
||||
|
||||
public partial class MainLayout
|
||||
{
|
||||
#region Fields
|
||||
|
||||
private bool showAboutDialog;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
private static string AppVersion
|
||||
{
|
||||
get
|
||||
{
|
||||
var entryAssembly = Assembly.GetEntryAssembly();
|
||||
var infoVersion = entryAssembly?
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(infoVersion))
|
||||
{
|
||||
return infoVersion.Split('+')[0];
|
||||
}
|
||||
|
||||
return entryAssembly?.GetName().Version?.ToString(2) ?? "1.0";
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
private void OpenAboutDialog() => showAboutDialog = true;
|
||||
|
||||
private void CloseAboutDialog() => showAboutDialog = false;
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -39,8 +39,8 @@
|
||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||
</button>
|
||||
|
|
||||
<button class="btn btn-nav @(showCurrentYearOnly ? "btn-primary" : "btn-outline-secondary")" @onclick="ToggleYearFilter">
|
||||
<i class="bi bi-funnel"></i> @(showCurrentYearOnly ? $"Nur {DateTime.Now.Year}" : "Alle Buchungen")
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -56,21 +56,21 @@
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">Einnahmen</small>
|
||||
<div class="amount-positive">@FormatAmount(showCurrentYearOnly ? balance.CurrentYearIncome : balance.TotalIncome)</div>
|
||||
<div class="amount-positive">@FormatAmount(selectedYear.HasValue ? balance.CurrentYearIncome : balance.TotalIncome)</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">Ausgaben</small>
|
||||
<div class="amount-negative">@FormatAmount(showCurrentYearOnly ? balance.CurrentYearExpense : balance.TotalExpense)</div>
|
||||
<div class="amount-negative">@FormatAmount(selectedYear.HasValue ? balance.CurrentYearExpense : balance.TotalExpense)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showCurrentYearOnly)
|
||||
@if (selectedYear.HasValue)
|
||||
{
|
||||
<div class="summary-section">
|
||||
<div class="summary-flex">
|
||||
<div>
|
||||
<small class="text-muted">Übertrag von @(DateTime.Now.Year - 1)</small>
|
||||
<small class="text-muted">Übertrag von @(selectedYear.Value - 1)</small>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="fw-bold">@FormatAmount(balance.CarryoverBalance)</span>
|
||||
<button class="btn-edit-pen" @onclick="() => showEditCarryover = true" title="Übertrag bearbeiten">
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">Umsätze @DateTime.Now.Year</small>
|
||||
<small class="text-muted">Umsätze @selectedYear.Value</small>
|
||||
<div class="fw-bold @(balance.CurrentYearIncome - balance.CurrentYearExpense >= 0 ? "amount-positive" : "amount-negative")">
|
||||
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
|
||||
</div>
|
||||
@@ -179,156 +179,29 @@
|
||||
OnCancel="() => editingTransferEntry = null" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public int AccountId { get; set; }
|
||||
|
||||
private AccountSummaryDto? account;
|
||||
private AccountBalanceDto? balance;
|
||||
private List<EntryDto>? entries;
|
||||
private bool showAddEntry, showAddTransfer;
|
||||
private bool showEditName, showEditCarryover;
|
||||
private bool showCurrentYearOnly = true;
|
||||
|
||||
private int? confirmDeleteEntryId;
|
||||
private string? confirmDeleteEntryTitle;
|
||||
|
||||
private int? confirmRestoreEntryId;
|
||||
private string? confirmRestoreEntryTitle;
|
||||
|
||||
private string? savedPdfPath;
|
||||
|
||||
private EntryDto? editingEntry;
|
||||
private EntryDto? editingTransferEntry;
|
||||
|
||||
private void NavigateBack() => Navigation.NavigateTo("/");
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task LoadAll()
|
||||
{
|
||||
account = await AccountService.GetAccountAsync(AccountId);
|
||||
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId);
|
||||
entries = await EntryService.GetEntriesAsync(AccountId, showCurrentYearOnly);
|
||||
}
|
||||
|
||||
private async Task ToggleYearFilter()
|
||||
{
|
||||
showCurrentYearOnly = !showCurrentYearOnly;
|
||||
entries = await EntryService.GetEntriesAsync(AccountId, showCurrentYearOnly);
|
||||
}
|
||||
|
||||
private async Task HandleSaveName(string newName)
|
||||
{
|
||||
await AccountService.RenameAccountAsync(AccountId, newName);
|
||||
showEditName = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleSaveCarryover(decimal newAmount)
|
||||
{
|
||||
await AccountService.UpdateCarryoverAsync(AccountId, newAmount);
|
||||
showEditCarryover = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleEntryCreated()
|
||||
{
|
||||
showAddEntry = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleTransferCreated()
|
||||
{
|
||||
showAddTransfer = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private void RequestDeleteEntry(int entryId)
|
||||
{
|
||||
confirmDeleteEntryId = entryId;
|
||||
confirmDeleteEntryTitle = entries?.FirstOrDefault(e => e.Id == entryId)?.Title;
|
||||
}
|
||||
|
||||
private void CancelDeleteConfirm()
|
||||
{
|
||||
confirmDeleteEntryId = null;
|
||||
confirmDeleteEntryTitle = null;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmDelete()
|
||||
{
|
||||
if (confirmDeleteEntryId.HasValue)
|
||||
{
|
||||
await EntryService.DeleteEntryAsync(confirmDeleteEntryId.Value);
|
||||
confirmDeleteEntryId = null;
|
||||
confirmDeleteEntryTitle = null;
|
||||
await LoadAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestRestoreEntry(int entryId)
|
||||
{
|
||||
confirmRestoreEntryId = entryId;
|
||||
confirmRestoreEntryTitle = entries?.FirstOrDefault(e => e.Id == entryId)?.Title;
|
||||
}
|
||||
|
||||
private void CancelRestoreConfirm()
|
||||
{
|
||||
confirmRestoreEntryId = null;
|
||||
confirmRestoreEntryTitle = null;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
if (confirmRestoreEntryId.HasValue)
|
||||
{
|
||||
await EntryService.RestoreEntryAsync(confirmRestoreEntryId.Value);
|
||||
confirmRestoreEntryId = null;
|
||||
confirmRestoreEntryTitle = null;
|
||||
await LoadAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestEditEntry(int entryId)
|
||||
{
|
||||
var entry = entries?.FirstOrDefault(e => e.Id == entryId);
|
||||
if (entry?.IsTransfer == true)
|
||||
editingTransferEntry = entry;
|
||||
else
|
||||
editingEntry = entry;
|
||||
}
|
||||
|
||||
private async Task HandleEntryEdited()
|
||||
{
|
||||
editingEntry = null;
|
||||
editingTransferEntry = null;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleExport()
|
||||
{
|
||||
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, showCurrentYearOnly);
|
||||
var suffix = showCurrentYearOnly ? $"_{DateTime.Now.Year}" : "_Gesamt";
|
||||
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
|
||||
}
|
||||
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||
}
|
||||
|
||||
savedPdfPath = null;
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
{
|
||||
savedPdfPath = null;
|
||||
}
|
||||
|
||||
private static string FormatAmount(decimal amount) => $"{amount:N2} €";
|
||||
@if (showYearFilterDialog)
|
||||
{
|
||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5>Filter auswählen</h5>
|
||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||
<div class="filter-options-grid">
|
||||
<button class="btn @(selectedYear.HasValue ? "btn-outline-secondary" : "btn-primary")" @onclick="() => SelectFilterYear(null)">
|
||||
Alle Buchungen
|
||||
</button>
|
||||
@foreach (var year in availableYears)
|
||||
{
|
||||
<button class="btn @(selectedYear == year ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SelectFilterYear(year)">
|
||||
@year
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button class="btn btn-outline-secondary" @onclick="CloseYearFilterDialog">
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
210
src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs
Normal file
210
src/Duempelkas.App/Pages/Accounts/AccountDetail.razor.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace Duempelkas.App.Pages.Accounts;
|
||||
|
||||
public partial class AccountDetail
|
||||
{
|
||||
#region Parameters
|
||||
|
||||
[Parameter]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
|
||||
private AccountSummaryDto? account;
|
||||
private AccountBalanceDto? balance;
|
||||
private List<EntryDto>? entries;
|
||||
private bool showAddEntry;
|
||||
private bool showAddTransfer;
|
||||
private bool showEditName;
|
||||
private bool showEditCarryover;
|
||||
private bool showYearFilterDialog;
|
||||
private int? selectedYear = DateTime.Now.Year;
|
||||
private List<int> availableYears = [];
|
||||
|
||||
private int? confirmDeleteEntryId;
|
||||
private string? confirmDeleteEntryTitle;
|
||||
|
||||
private int? confirmRestoreEntryId;
|
||||
private string? confirmRestoreEntryTitle;
|
||||
|
||||
private string? savedPdfPath;
|
||||
|
||||
private EntryDto? editingEntry;
|
||||
private EntryDto? editingTransferEntry;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Navigation
|
||||
|
||||
private void NavigateBack() => Navigation.NavigateTo("/");
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Loading
|
||||
|
||||
private async Task LoadAll()
|
||||
{
|
||||
account = await AccountService.GetAccountAsync(AccountId);
|
||||
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId, selectedYear);
|
||||
entries = await EntryService.GetEntriesAsync(AccountId, selectedYear);
|
||||
availableYears = await EntryService.GetEntryYearsAsync(AccountId);
|
||||
}
|
||||
|
||||
private void OpenYearFilterDialog()
|
||||
{
|
||||
showYearFilterDialog = true;
|
||||
}
|
||||
|
||||
private void CloseYearFilterDialog()
|
||||
{
|
||||
showYearFilterDialog = false;
|
||||
}
|
||||
|
||||
private async Task SelectFilterYear(int? year)
|
||||
{
|
||||
selectedYear = year;
|
||||
showYearFilterDialog = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task HandleSaveName(string newName)
|
||||
{
|
||||
await AccountService.RenameAccountAsync(AccountId, newName);
|
||||
showEditName = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleSaveCarryover(decimal newAmount)
|
||||
{
|
||||
await AccountService.UpdateCarryoverAsync(AccountId, newAmount, selectedYear);
|
||||
showEditCarryover = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleEntryCreated()
|
||||
{
|
||||
showAddEntry = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleTransferCreated()
|
||||
{
|
||||
showAddTransfer = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private void RequestDeleteEntry(int entryId)
|
||||
{
|
||||
confirmDeleteEntryId = entryId;
|
||||
confirmDeleteEntryTitle = entries?.FirstOrDefault(e => e.Id == entryId)?.Title;
|
||||
}
|
||||
|
||||
private void CancelDeleteConfirm()
|
||||
{
|
||||
confirmDeleteEntryId = null;
|
||||
confirmDeleteEntryTitle = null;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmDelete()
|
||||
{
|
||||
if (confirmDeleteEntryId.HasValue)
|
||||
{
|
||||
await EntryService.DeleteEntryAsync(confirmDeleteEntryId.Value);
|
||||
confirmDeleteEntryId = null;
|
||||
confirmDeleteEntryTitle = null;
|
||||
await LoadAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestRestoreEntry(int entryId)
|
||||
{
|
||||
confirmRestoreEntryId = entryId;
|
||||
confirmRestoreEntryTitle = entries?.FirstOrDefault(e => e.Id == entryId)?.Title;
|
||||
}
|
||||
|
||||
private void CancelRestoreConfirm()
|
||||
{
|
||||
confirmRestoreEntryId = null;
|
||||
confirmRestoreEntryTitle = null;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
if (confirmRestoreEntryId.HasValue)
|
||||
{
|
||||
await EntryService.RestoreEntryAsync(confirmRestoreEntryId.Value);
|
||||
confirmRestoreEntryId = null;
|
||||
confirmRestoreEntryTitle = null;
|
||||
await LoadAll();
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestEditEntry(int entryId)
|
||||
{
|
||||
var entry = entries?.FirstOrDefault(e => e.Id == entryId);
|
||||
if (entry?.IsTransfer == true)
|
||||
{
|
||||
editingTransferEntry = entry;
|
||||
}
|
||||
else
|
||||
{
|
||||
editingEntry = entry;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleEntryEdited()
|
||||
{
|
||||
editingEntry = null;
|
||||
editingTransferEntry = null;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleExport()
|
||||
{
|
||||
var pdf = await PdfStatementService.GenerateStatementAsync(AccountId, selectedYear);
|
||||
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
||||
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
|
||||
}
|
||||
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||
}
|
||||
|
||||
savedPdfPath = null;
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
{
|
||||
savedPdfPath = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static string FormatAmount(decimal amount) => $"{amount:N2} €";
|
||||
|
||||
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
@inject IAccountService AccountService
|
||||
@inject IBackupService BackupService
|
||||
@inject ISettingsService SettingsService
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject IPdfStatementService PdfStatementService
|
||||
@inject IFileSaveService FileSaveService
|
||||
@inject IEntryService EntryService
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<div class="container-fluid">
|
||||
@@ -29,8 +31,29 @@
|
||||
<button class="btn-nav btn-warning" @onclick="HandleRestoreAsync">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||
</button>
|
||||
|
|
||||
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync">
|
||||
<i class="bi bi-file-earmark-pdf"></i> PDF
|
||||
</button>
|
||||
|
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> Ansicht<br>@GetFilterLabel()
|
||||
</button>
|
||||
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
|
||||
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (accounts != null && accounts.Any())
|
||||
{
|
||||
<div class="alert alert-secondary py-2 px-3 mb-3" role="status">
|
||||
<strong>Summe aller Konten:</strong>
|
||||
<span class="ms-2 @(TotalClubBalance >= 0 ? "text-success" : "text-danger")">
|
||||
@FormatCurrency(TotalClubBalance)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(operationMessage))
|
||||
{
|
||||
<div class="alert @operationMessageClass mb-3" role="alert">
|
||||
@@ -68,77 +91,53 @@
|
||||
OnSave="HandleSaveClubName" OnCancel="() => showEditClubName = false" />
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<AccountSummaryDto>? accounts;
|
||||
private bool showAddAccount;
|
||||
private bool showEditClubName;
|
||||
private string clubName = string.Empty;
|
||||
private string? operationMessage;
|
||||
private string operationMessageClass = "alert-info";
|
||||
private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadClubName();
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private async Task LoadAccounts()
|
||||
{
|
||||
accounts = await AccountService.GetAllAccountsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadClubName()
|
||||
{
|
||||
clubName = await SettingsService.GetClubNameAsync() ?? string.Empty;
|
||||
}
|
||||
|
||||
private async Task HandleAccountCreated(string name)
|
||||
{
|
||||
await AccountService.CreateAccountAsync(name);
|
||||
showAddAccount = false;
|
||||
await LoadAccounts();
|
||||
}
|
||||
|
||||
private async Task HandleSaveClubName(string newName)
|
||||
{
|
||||
await SettingsService.SetClubNameAsync(newName);
|
||||
showEditClubName = false;
|
||||
await LoadClubName();
|
||||
}
|
||||
|
||||
private async Task HandleBackupAsync()
|
||||
{
|
||||
var message = await BackupService.CreateBackupAsync();
|
||||
if(message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase)) {
|
||||
SetOperationMessage(message, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task HandleRestoreAsync()
|
||||
{
|
||||
var confirmed = await JsRuntime.InvokeAsync<bool>("confirm",
|
||||
"Restore überschreibt die aktuelle Datenbank. Möchten Sie fortfahren?");
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
var message = await BackupService.RestoreBackupAsync();
|
||||
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isSuccess)
|
||||
{
|
||||
// Reload the Blazor app so all components/services re-query from restored DB.
|
||||
NavigationManager.NavigateTo("/", forceLoad: true);
|
||||
} else {
|
||||
SetOperationMessage(message, isSuccess);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void SetOperationMessage(string message, bool success)
|
||||
{
|
||||
operationMessage = message;
|
||||
operationMessageClass = success ? "alert-success" : "alert-danger";
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
<ConfirmDialog Title="PDF öffnen"
|
||||
Message="Die PDF wurde gespeichert. Möchten Sie sie jetzt öffnen?"
|
||||
ConfirmText="Ja, öffnen"
|
||||
CancelText="Nein"
|
||||
ConfirmButtonClass="btn btn-primary"
|
||||
ConfirmIconClass="bi bi-box-arrow-up-right"
|
||||
OnConfirm="HandleOpenSavedPdf"
|
||||
OnCancel="CancelOpenSavedPdf" />
|
||||
}
|
||||
|
||||
@if (showRestoreConfirm)
|
||||
{
|
||||
<ConfirmDialog Title="Restore bestätigen"
|
||||
Message="Restore überschreibt die aktuelle Datenbank. Möchten Sie fortfahren?"
|
||||
ConfirmText="Ja, wiederherstellen"
|
||||
CancelText="Nein, abbrechen"
|
||||
ConfirmButtonClass="btn btn-warning"
|
||||
ConfirmIconClass="bi bi-arrow-counterclockwise"
|
||||
OnConfirm="HandleConfirmRestore"
|
||||
OnCancel="CancelRestoreConfirm" />
|
||||
}
|
||||
|
||||
@if (showYearFilterDialog)
|
||||
{
|
||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
||||
<h5>Filter auswählen</h5>
|
||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||
<div class="filter-options-grid">
|
||||
<button class="btn @(selectedYear.HasValue ? "btn-outline-secondary" : "btn-primary")" @onclick="() => SelectFilterYear(null)">
|
||||
Alle Buchungen
|
||||
</button>
|
||||
@foreach (var year in availableYears)
|
||||
{
|
||||
<button class="btn @(selectedYear == year ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SelectFilterYear(year)">
|
||||
@year
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button class="btn btn-outline-secondary" @onclick="CloseYearFilterDialog">
|
||||
<i class="bi bi-x-lg"></i> Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
213
src/Duempelkas.App/Pages/Dashboard.razor.cs
Normal file
213
src/Duempelkas.App/Pages/Dashboard.razor.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
using System.Globalization;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Duempelkas.App.Pages;
|
||||
|
||||
public partial class Dashboard
|
||||
{
|
||||
#region Fields
|
||||
|
||||
private List<AccountSummaryDto>? accounts;
|
||||
private bool showAddAccount;
|
||||
private bool showEditClubName;
|
||||
private bool showRestoreConfirm;
|
||||
private bool showYearFilterDialog;
|
||||
private int? selectedYear = DateTime.Now.Year;
|
||||
private List<int> availableYears = [];
|
||||
private string clubName = string.Empty;
|
||||
private string? operationMessage;
|
||||
private string operationMessageClass = "alert-info";
|
||||
private string? savedPdfPath;
|
||||
private string currentTheme = "light";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
|
||||
private string DisplayClubName => string.IsNullOrWhiteSpace(clubName) ? "Mein Verein" : clubName;
|
||||
|
||||
private decimal TotalClubBalance => accounts?.Sum(a => a.TotalBalance) ?? 0m;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadClubName();
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
currentTheme = await JS.InvokeAsync<string>("duempelkasThemeGetCurrentTheme");
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Keep default light mode when JS interop is not ready or unavailable.
|
||||
currentTheme = "light";
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Data Loading
|
||||
|
||||
private async Task LoadAll()
|
||||
{
|
||||
await LoadAccounts();
|
||||
availableYears = await EntryService.GetAllEntryYearsAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAccounts()
|
||||
{
|
||||
accounts = await AccountService.GetAllAccountsAsync(selectedYear);
|
||||
}
|
||||
|
||||
private async Task LoadClubName()
|
||||
{
|
||||
clubName = await SettingsService.GetClubNameAsync() ?? string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task HandleAccountCreated(string name)
|
||||
{
|
||||
await AccountService.CreateAccountAsync(name);
|
||||
showAddAccount = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleSaveClubName(string newName)
|
||||
{
|
||||
await SettingsService.SetClubNameAsync(newName);
|
||||
showEditClubName = false;
|
||||
await LoadClubName();
|
||||
}
|
||||
|
||||
private async Task HandleBackupAsync()
|
||||
{
|
||||
var message = await BackupService.CreateBackupAsync();
|
||||
if (message.Contains("fehlgeschlagen", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetOperationMessage(message, false);
|
||||
}
|
||||
}
|
||||
|
||||
private Task HandleRestoreAsync()
|
||||
{
|
||||
showRestoreConfirm = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void CancelRestoreConfirm()
|
||||
{
|
||||
showRestoreConfirm = false;
|
||||
}
|
||||
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
showRestoreConfirm = false;
|
||||
var message = await BackupService.RestoreBackupAsync();
|
||||
var isSuccess = message.StartsWith("Wiederherstellung erfolgreich", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isSuccess)
|
||||
{
|
||||
NavigationManager.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetOperationMessage(message, isSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleDashboardExportAsync()
|
||||
{
|
||||
var pdf = await PdfStatementService.GenerateDashboardStatementAsync(selectedYear);
|
||||
var suffix = selectedYear.HasValue ? $"_{selectedYear.Value}" : "_Gesamt";
|
||||
savedPdfPath = await FileSaveService.SaveFileAsync(pdf, $"{DisplayClubName}_Übersicht{suffix}.pdf");
|
||||
}
|
||||
|
||||
private void OpenYearFilterDialog()
|
||||
{
|
||||
showYearFilterDialog = true;
|
||||
}
|
||||
|
||||
private void CloseYearFilterDialog()
|
||||
{
|
||||
showYearFilterDialog = false;
|
||||
}
|
||||
|
||||
private async Task SelectFilterYear(int? year)
|
||||
{
|
||||
selectedYear = year;
|
||||
showYearFilterDialog = false;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(savedPdfPath))
|
||||
{
|
||||
await FileSaveService.OpenFileAsync(savedPdfPath);
|
||||
}
|
||||
|
||||
savedPdfPath = null;
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
{
|
||||
savedPdfPath = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void SetOperationMessage(string message, bool success)
|
||||
{
|
||||
operationMessage = message;
|
||||
operationMessageClass = success ? "alert-success" : "alert-danger";
|
||||
}
|
||||
|
||||
private static string FormatCurrency(decimal amount)
|
||||
{
|
||||
return amount.ToString("N2", CultureInfo.GetCultureInfo("de-DE")) + " €";
|
||||
}
|
||||
|
||||
private string GetFilterLabel() => selectedYear?.ToString() ?? "Alle";
|
||||
|
||||
private string ThemeButtonLabel => currentTheme == "dark" ? "Hell" : "Dunkel";
|
||||
|
||||
private string ThemeButtonIconClass => currentTheme == "dark" ? "bi bi-sun" : "bi bi-moon-stars";
|
||||
|
||||
private async Task ToggleThemeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
currentTheme = await JS.InvokeAsync<string>("duempelkasThemeToggleTheme");
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
currentTheme = currentTheme == "dark" ? "light" : "dark";
|
||||
}
|
||||
}
|
||||
|
||||
[Inject]
|
||||
private IJSRuntime JS { get; set; } = default!;
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -29,22 +29,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string clubName = string.Empty;
|
||||
private bool saving;
|
||||
private bool saved;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
clubName = await SettingsService.GetClubNameAsync() ?? string.Empty;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
saving = true;
|
||||
saved = false;
|
||||
await SettingsService.SetClubNameAsync(clubName);
|
||||
saved = true;
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
34
src/Duempelkas.App/Pages/Settings.razor.cs
Normal file
34
src/Duempelkas.App/Pages/Settings.razor.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace Duempelkas.App.Pages;
|
||||
|
||||
public partial class Settings
|
||||
{
|
||||
#region Fields
|
||||
|
||||
private string clubName = string.Empty;
|
||||
private bool saving;
|
||||
private bool saved;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
clubName = await SettingsService.GetClubNameAsync() ?? string.Empty;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
saving = true;
|
||||
saved = false;
|
||||
await SettingsService.SetClubNameAsync(clubName);
|
||||
saved = true;
|
||||
saving = false;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -5,9 +5,11 @@ namespace Duempelkas.App.Services;
|
||||
public interface IAccountService
|
||||
{
|
||||
Task<List<AccountSummaryDto>> GetAllAccountsAsync();
|
||||
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
|
||||
Task<AccountSummaryDto> GetAccountAsync(int accountId);
|
||||
Task<AccountSummaryDto> CreateAccountAsync(string name);
|
||||
Task RenameAccountAsync(int accountId, string newName);
|
||||
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
|
||||
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
|
||||
Task DeleteAccountAsync(int accountId);
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ namespace Duempelkas.App.Services;
|
||||
|
||||
public interface IBalanceQueryService
|
||||
{
|
||||
Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId);
|
||||
Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId, int? year = null);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace Duempelkas.App.Services;
|
||||
|
||||
public interface IEntryService
|
||||
{
|
||||
Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year);
|
||||
Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly);
|
||||
Task<List<int>> GetEntryYearsAsync(int accountId);
|
||||
Task<List<int>> GetAllEntryYearsAsync();
|
||||
Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount);
|
||||
Task CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
|
||||
Task DeleteEntryAsync(int entryId);
|
||||
|
||||
@@ -2,5 +2,8 @@ namespace Duempelkas.App.Services;
|
||||
|
||||
public interface IPdfStatementService
|
||||
{
|
||||
Task<byte[]> GenerateStatementAsync(int accountId, int? year);
|
||||
Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly);
|
||||
Task<byte[]> GenerateDashboardStatementAsync();
|
||||
Task<byte[]> GenerateDashboardStatementAsync(int? year);
|
||||
}
|
||||
|
||||
BIN
src/Duempelkas.Desktop/Assets/app-icon.ico
Normal file
BIN
src/Duempelkas.Desktop/Assets/app-icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
src/Duempelkas.Desktop/Assets/app-icon.png
Normal file
BIN
src/Duempelkas.Desktop/Assets/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -3,6 +3,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<OutputType>Exe</OutputType>
|
||||
<OutputType Condition="$([MSBuild]::IsOSPlatform('Windows'))">WinExe</OutputType>
|
||||
<ApplicationIcon>Assets\app-icon.ico</ApplicationIcon>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>$([System.DateTime]::Now.ToString('yy')).$([System.DateTime]::Now.DayOfYear)</Version>
|
||||
@@ -23,6 +24,9 @@
|
||||
<Content Include="wwwroot\**">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Assets\app-icon.ico">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -3,18 +3,49 @@ using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Photino.Blazor;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Duempelkas.Desktop;
|
||||
|
||||
class Program
|
||||
{
|
||||
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern int SetCurrentProcessExplicitAppUserModelID(string appID);
|
||||
|
||||
[STAThread]
|
||||
static void Main(string[] args)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// Ensure Windows taskbar does not group this process under generic dotnet host identity.
|
||||
SetCurrentProcessExplicitAppUserModelID("Duempelkas.Desktop");
|
||||
}
|
||||
|
||||
var appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args);
|
||||
|
||||
var exeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
||||
var dbPath = Path.Combine(exeDirectory, "duempelkas.db");
|
||||
// Use a per-user writable DB location so debugging via dotnet host works reliably.
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Duempelkas");
|
||||
Directory.CreateDirectory(appDataDir);
|
||||
|
||||
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
|
||||
|
||||
// One-time compatibility: keep existing data if older versions stored DB next to the app executable.
|
||||
var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
||||
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
|
||||
if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Copy(legacyDbPath, dbPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore copy errors and continue with a fresh DB.
|
||||
}
|
||||
}
|
||||
|
||||
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
|
||||
|
||||
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
|
||||
@@ -23,6 +54,13 @@ class Program
|
||||
|
||||
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri;
|
||||
|
||||
// Force the native window icon for title bar + taskbar representation.
|
||||
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "app-icon.ico");
|
||||
if (File.Exists(iconPath))
|
||||
{
|
||||
app.MainWindow.SetIconFile(iconPath);
|
||||
}
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
/* Duempelkas App Styles – Dark Mode */
|
||||
/* Duempelkas App Styles */
|
||||
|
||||
:root {
|
||||
--color-income: #4ade80;
|
||||
--color-expense: #f87171;
|
||||
--color-transfer: #60a5fa;
|
||||
--color-bg: #ddd;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-hover: #f1f5f9;
|
||||
--color-border: #d9e2ef;
|
||||
--color-text: #0f172a;
|
||||
--color-text-muted: #64748b;
|
||||
--color-accent: #4f46e5;
|
||||
}
|
||||
|
||||
html[data-ui-theme="dark"] {
|
||||
--color-bg: #1a1a2e;
|
||||
--color-surface: #16213e;
|
||||
--color-surface-hover: #1e2d4a;
|
||||
@@ -138,6 +148,16 @@ html, body {
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
.filter-options-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-options-grid .btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
.app-navbar {
|
||||
background-color: var(--color-surface);
|
||||
@@ -301,7 +321,7 @@ html, body {
|
||||
.btn-dark {
|
||||
background-color: #334155;
|
||||
border-color: #475569;
|
||||
color: var(--color-text);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.btn-dark:hover {
|
||||
@@ -309,6 +329,12 @@ html, body {
|
||||
border-color: #64748b;
|
||||
}
|
||||
|
||||
html[data-ui-theme="light"] .btn-dark {
|
||||
background-color: #334155;
|
||||
border-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.table {
|
||||
--bs-table-bg: transparent;
|
||||
|
||||
@@ -1,9 +1,113 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-bs-theme="dark">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<script>
|
||||
(function () {
|
||||
function getCookie(name) {
|
||||
var parts = document.cookie ? document.cookie.split(';') : [];
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var part = parts[i].trim();
|
||||
if (part.indexOf(name + '=') === 0) {
|
||||
return decodeURIComponent(part.substring(name.length + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCookie(name, value) {
|
||||
document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; max-age=31536000; samesite=lax';
|
||||
}
|
||||
|
||||
function getStoredTheme() {
|
||||
try {
|
||||
var fromStorage = localStorage.getItem('duempelkas-theme');
|
||||
if (fromStorage === 'light' || fromStorage === 'dark') {
|
||||
return fromStorage;
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore storage access issues and fall back to cookies/system.
|
||||
}
|
||||
|
||||
var fromCookie = getCookie('duempelkas-theme');
|
||||
if (fromCookie === 'light' || fromCookie === 'dark') {
|
||||
return fromCookie;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectSystemTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function normalizeTheme(value) {
|
||||
return value === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(theme, persist) {
|
||||
var resolved = normalizeTheme(theme);
|
||||
document.documentElement.setAttribute('data-ui-theme', resolved);
|
||||
document.documentElement.setAttribute('data-bs-theme', resolved);
|
||||
if (document.body) {
|
||||
document.body.setAttribute('data-ui-theme', resolved);
|
||||
document.body.setAttribute('data-bs-theme', resolved);
|
||||
}
|
||||
|
||||
if (persist) {
|
||||
try {
|
||||
localStorage.setItem('duempelkas-theme', resolved);
|
||||
} catch (_) {
|
||||
// Ignore storage access issues and keep cookie as fallback.
|
||||
}
|
||||
|
||||
setCookie('duempelkas-theme', resolved);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
var initialTheme = getStoredTheme() || detectSystemTheme() || 'light';
|
||||
applyTheme(initialTheme, false);
|
||||
|
||||
var duempelkasThemeGetCurrentTheme = function () {
|
||||
var current = document.documentElement.getAttribute('data-ui-theme');
|
||||
return normalizeTheme(current);
|
||||
};
|
||||
|
||||
var duempelkasThemeSetTheme = function (theme) {
|
||||
return applyTheme(theme, true);
|
||||
};
|
||||
|
||||
var duempelkasThemeToggleTheme = function () {
|
||||
var current = document.documentElement.getAttribute('data-ui-theme');
|
||||
var next = current === 'dark' ? 'light' : 'dark';
|
||||
return applyTheme(next, true);
|
||||
};
|
||||
|
||||
globalThis.duempelkasThemeGetCurrentTheme = duempelkasThemeGetCurrentTheme;
|
||||
globalThis.duempelkasThemeSetTheme = duempelkasThemeSetTheme;
|
||||
globalThis.duempelkasThemeToggleTheme = duempelkasThemeToggleTheme;
|
||||
|
||||
window.duempelkasThemeGetCurrentTheme = duempelkasThemeGetCurrentTheme;
|
||||
window.duempelkasThemeSetTheme = duempelkasThemeSetTheme;
|
||||
window.duempelkasThemeToggleTheme = duempelkasThemeToggleTheme;
|
||||
|
||||
// Keep object-style API for compatibility with older calls.
|
||||
window.duempelkasTheme = {
|
||||
getCurrentTheme: duempelkasThemeGetCurrentTheme,
|
||||
setTheme: duempelkasThemeSetTheme,
|
||||
toggleTheme: duempelkasThemeToggleTheme
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<title>Dümpelkas – Kassenbuch</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="QuestPDF" Version="*" />
|
||||
<PackageReference Include="SkiaSharp" Version="2.88.9" />
|
||||
<PackageReference Include="SkiaSharp.HarfBuzz" Version="2.88.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
169
src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs
generated
Normal file
169
src/Duempelkas.Infrastructure/Migrations/20260403093901_AllowSharedDisplayIdForTransfers.Designer.cs
generated
Normal file
@@ -0,0 +1,169 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Duempelkas.Infrastructure.Migrations
|
||||
{
|
||||
[DbContext(typeof(FinanceDbContext))]
|
||||
[Migration("20260403093901_AllowSharedDisplayIdForTransfers")]
|
||||
partial class AllowSharedDisplayIdForTransfers
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.5");
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Account", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("CarryoverBalance")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Accounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DisplayId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TransferLinkId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DisplayId");
|
||||
|
||||
b.HasIndex("TransferLinkId");
|
||||
|
||||
b.HasIndex("AccountId", "Date");
|
||||
|
||||
b.ToTable("Entries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.TransferLink", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SourceEntryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("TargetEntryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceEntryId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("TargetEntryId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TransferLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Entry", b =>
|
||||
{
|
||||
b.HasOne("Duempelkas.Domain.Entities.Account", "Account")
|
||||
.WithMany("Entries")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Duempelkas.Domain.Entities.TransferLink", "TransferLink")
|
||||
.WithMany()
|
||||
.HasForeignKey("TransferLinkId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("TransferLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.TransferLink", b =>
|
||||
{
|
||||
b.HasOne("Duempelkas.Domain.Entities.Entry", "SourceEntry")
|
||||
.WithMany()
|
||||
.HasForeignKey("SourceEntryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Duempelkas.Domain.Entities.Entry", "TargetEntry")
|
||||
.WithMany()
|
||||
.HasForeignKey("TargetEntryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("SourceEntry");
|
||||
|
||||
b.Navigation("TargetEntry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Duempelkas.Domain.Entities.Account", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Duempelkas.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AllowSharedDisplayIdForTransfers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries",
|
||||
column: "DisplayId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Entries_DisplayId",
|
||||
table: "Entries",
|
||||
column: "DisplayId",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,7 @@ namespace Duempelkas.Infrastructure.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DisplayId")
|
||||
.IsUnique();
|
||||
b.HasIndex("DisplayId");
|
||||
|
||||
b.HasIndex("TransferLinkId");
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ public class EntryConfiguration : IEntityTypeConfiguration<Entry>
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.DisplayId).IsRequired().HasMaxLength(20);
|
||||
builder.HasIndex(e => e.DisplayId).IsUnique();
|
||||
builder.HasIndex(e => e.DisplayId);
|
||||
builder.Property(e => e.Title).IsRequired().HasMaxLength(500);
|
||||
builder.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||
builder.Property(e => e.Type).HasConversion<int>();
|
||||
|
||||
@@ -13,16 +13,60 @@ public class AccountService : IAccountService
|
||||
|
||||
public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
|
||||
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync()
|
||||
public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
|
||||
{
|
||||
return GetAllAccountsAsync(null);
|
||||
}
|
||||
|
||||
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var accounts = await db.Accounts
|
||||
.Include(a => a.Entries)
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync();
|
||||
|
||||
return accounts.Select(MapToSummary).ToList();
|
||||
var entries = db.Entries.Where(e => !e.IsDeleted);
|
||||
|
||||
if (year.HasValue)
|
||||
{
|
||||
var movementBeforeYearByAccountId = await entries
|
||||
.Where(e => e.Date.Year < year.Value)
|
||||
.GroupBy(e => e.AccountId)
|
||||
.ToDictionaryAsync(
|
||||
g => g.Key,
|
||||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||||
|
||||
var movementInYearByAccountId = await entries
|
||||
.Where(e => e.Date.Year == year.Value)
|
||||
.GroupBy(e => e.AccountId)
|
||||
.ToDictionaryAsync(
|
||||
g => g.Key,
|
||||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||||
|
||||
return accounts
|
||||
.Select(account =>
|
||||
{
|
||||
var carryoverForYear = account.CarryoverBalance + movementBeforeYearByAccountId.GetValueOrDefault(account.Id);
|
||||
var totalBalance = carryoverForYear + movementInYearByAccountId.GetValueOrDefault(account.Id);
|
||||
return new AccountSummaryDto(account.Id, account.Name, carryoverForYear, totalBalance, account.CreatedUtc);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var movementAllByAccountId = await entries
|
||||
.GroupBy(e => e.AccountId)
|
||||
.ToDictionaryAsync(
|
||||
g => g.Key,
|
||||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||||
|
||||
return accounts
|
||||
.Select(account =>
|
||||
{
|
||||
var totalBalance = account.CarryoverBalance + movementAllByAccountId.GetValueOrDefault(account.Id);
|
||||
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
|
||||
@@ -34,7 +78,11 @@ public class AccountService : IAccountService
|
||||
.FirstOrDefaultAsync(a => a.Id == accountId)
|
||||
?? throw new InvalidOperationException($"Account {accountId} not found.");
|
||||
|
||||
return MapToSummary(account);
|
||||
var totalIncome = account.Entries.Where(e => !e.IsDeleted && e.Type == EntryType.Income).Sum(e => e.Amount);
|
||||
var totalExpense = account.Entries.Where(e => !e.IsDeleted && e.Type == EntryType.Expense).Sum(e => e.Amount);
|
||||
var totalBalance = account.CarryoverBalance + totalIncome - totalExpense;
|
||||
|
||||
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
|
||||
}
|
||||
|
||||
public async Task<AccountSummaryDto> CreateAccountAsync(string name)
|
||||
@@ -67,6 +115,27 @@ public class AccountService : IAccountService
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year)
|
||||
{
|
||||
if (!year.HasValue)
|
||||
{
|
||||
await UpdateCarryoverAsync(accountId, carryoverBalance);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = await db.Accounts.FindAsync(accountId)
|
||||
?? throw new InvalidOperationException($"Account {accountId} not found.");
|
||||
|
||||
var movementBeforeYear = await db.Entries
|
||||
.Where(e => e.AccountId == accountId && !e.IsDeleted && e.Date.Year < year.Value)
|
||||
.SumAsync(e => e.Type == EntryType.Income ? e.Amount : -e.Amount);
|
||||
|
||||
account.CarryoverBalance = carryoverBalance - movementBeforeYear;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task DeleteAccountAsync(int accountId)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
@@ -94,12 +163,4 @@ public class AccountService : IAccountService
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static AccountSummaryDto MapToSummary(Account account)
|
||||
{
|
||||
var totalIncome = account.Entries.Where(e => e.Type == EntryType.Income).Sum(e => e.Amount);
|
||||
var totalExpense = account.Entries.Where(e => e.Type == EntryType.Expense).Sum(e => e.Amount);
|
||||
var totalBalance = account.CarryoverBalance + totalIncome - totalExpense;
|
||||
|
||||
return new AccountSummaryDto(account.Id, account.Name, account.CarryoverBalance, totalBalance, account.CreatedUtc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public class BalanceQueryService : IBalanceQueryService
|
||||
|
||||
public BalanceQueryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
|
||||
public async Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId)
|
||||
public async Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId, int? year = null)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -21,21 +21,33 @@ public class BalanceQueryService : IBalanceQueryService
|
||||
.FirstOrDefaultAsync(a => a.Id == accountId)
|
||||
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");
|
||||
|
||||
var currentYear = DateTime.Now.Year;
|
||||
var selectedYear = year ?? DateTime.Now.Year;
|
||||
|
||||
var activeEntries = account.Entries.Where(e => !e.IsDeleted).ToList();
|
||||
var totalIncome = activeEntries.Where(e => e.Type == EntryType.Income).Sum(e => e.Amount);
|
||||
var totalExpense = activeEntries.Where(e => e.Type == EntryType.Expense).Sum(e => e.Amount);
|
||||
var currentYearIncome = activeEntries.Where(e => e.Type == EntryType.Income && e.Date.Year == currentYear).Sum(e => e.Amount);
|
||||
var currentYearExpense = activeEntries.Where(e => e.Type == EntryType.Expense && e.Date.Year == currentYear).Sum(e => e.Amount);
|
||||
var totalBalance = account.CarryoverBalance + totalIncome - totalExpense;
|
||||
|
||||
var selectedYearIncome = activeEntries
|
||||
.Where(e => e.Type == EntryType.Income && e.Date.Year == selectedYear)
|
||||
.Sum(e => e.Amount);
|
||||
var selectedYearExpense = activeEntries
|
||||
.Where(e => e.Type == EntryType.Expense && e.Date.Year == selectedYear)
|
||||
.Sum(e => e.Amount);
|
||||
|
||||
var selectedYearCarryover = account.CarryoverBalance + activeEntries
|
||||
.Where(e => e.Date.Year < selectedYear)
|
||||
.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount);
|
||||
|
||||
var totalBalance = year.HasValue
|
||||
? selectedYearCarryover + selectedYearIncome - selectedYearExpense
|
||||
: account.CarryoverBalance + totalIncome - totalExpense;
|
||||
|
||||
return new AccountBalanceDto(
|
||||
account.CarryoverBalance,
|
||||
year.HasValue ? selectedYearCarryover : account.CarryoverBalance,
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
currentYearIncome,
|
||||
currentYearExpense,
|
||||
selectedYearIncome,
|
||||
selectedYearExpense,
|
||||
totalBalance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,20 @@ public class EntryService : IEntryService
|
||||
|
||||
public EntryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
|
||||
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
|
||||
public Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly)
|
||||
{
|
||||
var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
|
||||
return GetEntriesAsync(accountId, year);
|
||||
}
|
||||
|
||||
public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var query = db.Entries.Where(e => e.AccountId == accountId);
|
||||
|
||||
if (currentYearOnly)
|
||||
query = query.Where(e => e.Date.Year == DateTime.Now.Year);
|
||||
if (year.HasValue)
|
||||
query = query.Where(e => e.Date.Year == year.Value);
|
||||
|
||||
var entries = await query
|
||||
.OrderBy(e => e.Date)
|
||||
@@ -59,11 +65,34 @@ public class EntryService : IEntryService
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<int>> GetEntryYearsAsync(int accountId)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
return await db.Entries
|
||||
.Where(e => e.AccountId == accountId)
|
||||
.Select(e => e.Date.Year)
|
||||
.Distinct()
|
||||
.OrderByDescending(y => y)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<int>> GetAllEntryYearsAsync()
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
return await db.Entries
|
||||
.Select(e => e.Date.Year)
|
||||
.Distinct()
|
||||
.OrderByDescending(y => y)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var displayId = await GenerateDisplayIdAsync(db, accountId, date.Year);
|
||||
var displayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
@@ -88,12 +117,12 @@ public class EntryService : IEntryService
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
var sourceDisplayId = await GenerateDisplayIdAsync(db, sourceAccountId, date.Year);
|
||||
var transferDisplayId = await GenerateDisplayIdAsync(db, date.Year);
|
||||
|
||||
var sourceEntry = new Entry
|
||||
{
|
||||
AccountId = sourceAccountId,
|
||||
DisplayId = sourceDisplayId,
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Expense,
|
||||
Date = date,
|
||||
Title = title,
|
||||
@@ -103,12 +132,10 @@ public class EntryService : IEntryService
|
||||
db.Entries.Add(sourceEntry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var targetDisplayId = await GenerateDisplayIdAsync(db, targetAccountId, date.Year);
|
||||
|
||||
var targetEntry = new Entry
|
||||
{
|
||||
AccountId = targetAccountId,
|
||||
DisplayId = targetDisplayId,
|
||||
DisplayId = transferDisplayId,
|
||||
Type = EntryType.Income,
|
||||
Date = date,
|
||||
Title = title,
|
||||
@@ -234,13 +261,12 @@ public class EntryService : IEntryService
|
||||
if (otherEntry.AccountId != newLinkedAccountId)
|
||||
{
|
||||
otherEntry.AccountId = newLinkedAccountId;
|
||||
otherEntry.DisplayId = await GenerateDisplayIdAsync(db, newLinkedAccountId, date.Year);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static async Task<string> GenerateDisplayIdAsync(FinanceDbContext db, int accountId, int year)
|
||||
private static async Task<string> GenerateDisplayIdAsync(FinanceDbContext db, int year)
|
||||
{
|
||||
var prefix = $"{year}-";
|
||||
var maxDisplayId = await db.Entries
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Globalization;
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Duempelkas.Domain.Entities;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -26,19 +27,25 @@ public class PdfStatementService : IPdfStatementService
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public async Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly)
|
||||
public Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly)
|
||||
{
|
||||
var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
|
||||
return GenerateStatementAsync(accountId, year);
|
||||
}
|
||||
|
||||
public async Task<byte[]> GenerateStatementAsync(int accountId, int? year)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var account = await db.Accounts.FindAsync(accountId)
|
||||
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");
|
||||
|
||||
var entries = await _entryService.GetEntriesAsync(accountId, currentYearOnly);
|
||||
var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId);
|
||||
var entries = await _entryService.GetEntriesAsync(accountId, year);
|
||||
var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId, year);
|
||||
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
|
||||
|
||||
var title = currentYearOnly
|
||||
? $"{account.Name} – Auszug {DateTime.Now.Year}"
|
||||
var title = year.HasValue
|
||||
? $"{account.Name} – Auszug {year.Value}"
|
||||
: $"{account.Name} – Gesamtauszug";
|
||||
|
||||
var document = Document.Create(container =>
|
||||
@@ -60,12 +67,6 @@ public class PdfStatementService : IPdfStatementService
|
||||
|
||||
page.Content().PaddingTop(15).Column(col =>
|
||||
{
|
||||
col.Item().PaddingBottom(10).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text("Übertrag:").SemiBold();
|
||||
row.AutoItem().Text(FormatCurrency(balance.CarryoverBalance)).SemiBold();
|
||||
});
|
||||
|
||||
col.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
@@ -113,9 +114,16 @@ public class PdfStatementService : IPdfStatementService
|
||||
col.Item().PaddingTop(20).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
col.Item().PaddingTop(10).Column(summaryCol =>
|
||||
{
|
||||
SummaryRow(summaryCol, "Übertrag:", balance.CarryoverBalance);
|
||||
SummaryRow(summaryCol, "Einnahmen gesamt:", balance.TotalIncome);
|
||||
SummaryRow(summaryCol, "Ausgaben gesamt:", -balance.TotalExpense);
|
||||
var incomeValue = year.HasValue ? balance.CurrentYearIncome : balance.TotalIncome;
|
||||
var expenseValue = year.HasValue ? balance.CurrentYearExpense : balance.TotalExpense;
|
||||
|
||||
if (year.HasValue)
|
||||
{
|
||||
SummaryRow(summaryCol, $"Übertrag von {year.Value - 1}:", balance.CarryoverBalance);
|
||||
}
|
||||
|
||||
SummaryRow(summaryCol, year.HasValue ? "Einnahmen:" : "Einnahmen gesamt:", incomeValue);
|
||||
SummaryRow(summaryCol, year.HasValue ? "Ausgaben:" : "Ausgaben gesamt:", -expenseValue);
|
||||
summaryCol.Item().PaddingTop(5).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Text("Saldo:").Bold().FontSize(12);
|
||||
@@ -138,6 +146,257 @@ public class PdfStatementService : IPdfStatementService
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
public Task<byte[]> GenerateDashboardStatementAsync()
|
||||
{
|
||||
return GenerateDashboardStatementAsync(null);
|
||||
}
|
||||
|
||||
public async Task<byte[]> GenerateDashboardStatementAsync(int? year)
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
var accounts = await db.Accounts
|
||||
.OrderBy(a => a.Name)
|
||||
.ToListAsync();
|
||||
|
||||
var accountById = accounts.ToDictionary(a => a.Id);
|
||||
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
|
||||
|
||||
var transfers = await db.TransferLinks
|
||||
.Include(tl => tl.SourceEntry).ThenInclude(e => e.Account)
|
||||
.Include(tl => tl.TargetEntry).ThenInclude(e => e.Account)
|
||||
.ToListAsync();
|
||||
|
||||
var transferByEntryId = new Dictionary<int, TransferLink>();
|
||||
foreach (var transfer in transfers)
|
||||
{
|
||||
transferByEntryId[transfer.SourceEntryId] = transfer;
|
||||
transferByEntryId[transfer.TargetEntryId] = transfer;
|
||||
}
|
||||
|
||||
var entriesQuery = db.Entries
|
||||
.Include(e => e.Account)
|
||||
.Where(e => !e.IsDeleted);
|
||||
|
||||
if (year.HasValue)
|
||||
{
|
||||
entriesQuery = entriesQuery.Where(e => e.Date.Year == year.Value);
|
||||
}
|
||||
|
||||
var entries = await entriesQuery
|
||||
.OrderBy(e => e.Date)
|
||||
.ThenBy(e => e.CreatedUtc)
|
||||
.ToListAsync();
|
||||
|
||||
Dictionary<int, decimal> balanceByAccountId;
|
||||
Dictionary<int, decimal>? carryoverByAccountId = null;
|
||||
|
||||
if (year.HasValue)
|
||||
{
|
||||
var movementBeforeYearByAccountId = await db.Entries
|
||||
.Where(e => !e.IsDeleted && e.Date.Year < year.Value)
|
||||
.GroupBy(e => e.AccountId)
|
||||
.ToDictionaryAsync(
|
||||
g => g.Key,
|
||||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||||
|
||||
var movementInYearByAccountId = entries
|
||||
.GroupBy(e => e.AccountId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||||
|
||||
carryoverByAccountId = accounts
|
||||
.ToDictionary(
|
||||
a => a.Id,
|
||||
a => a.CarryoverBalance + movementBeforeYearByAccountId.GetValueOrDefault(a.Id));
|
||||
|
||||
balanceByAccountId = accounts
|
||||
.ToDictionary(
|
||||
a => a.Id,
|
||||
a => a.CarryoverBalance
|
||||
+ movementBeforeYearByAccountId.GetValueOrDefault(a.Id)
|
||||
+ movementInYearByAccountId.GetValueOrDefault(a.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
var movementByAccountId = entries
|
||||
.GroupBy(e => e.AccountId)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Sum(e => e.Type == EntryType.Income ? e.Amount : -e.Amount));
|
||||
|
||||
balanceByAccountId = accounts
|
||||
.ToDictionary(
|
||||
a => a.Id,
|
||||
a => a.CarryoverBalance + movementByAccountId.GetValueOrDefault(a.Id));
|
||||
}
|
||||
|
||||
var totalClubBalance = balanceByAccountId.Values.Sum();
|
||||
|
||||
var processedTransferIds = new HashSet<int>();
|
||||
var rows = new List<DashboardStatementRow>();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!transferByEntryId.TryGetValue(entry.Id, out var transfer))
|
||||
{
|
||||
rows.Add(new DashboardStatementRow(
|
||||
entry.DisplayId,
|
||||
entry.Date,
|
||||
entry.Title,
|
||||
new Dictionary<int, decimal>
|
||||
{
|
||||
[entry.AccountId] = entry.Type == EntryType.Income ? entry.Amount : -entry.Amount
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!processedTransferIds.Add(transfer.Id))
|
||||
continue;
|
||||
|
||||
var source = transfer.SourceEntry;
|
||||
var target = transfer.TargetEntry;
|
||||
|
||||
if (source.IsDeleted || target.IsDeleted)
|
||||
continue;
|
||||
|
||||
var transferTitle = $"{source.Title} ({source.Account.Name} -> {target.Account.Name})";
|
||||
|
||||
rows.Add(new DashboardStatementRow(
|
||||
source.DisplayId,
|
||||
source.Date,
|
||||
transferTitle,
|
||||
new Dictionary<int, decimal>
|
||||
{
|
||||
[source.AccountId] = -source.Amount,
|
||||
[target.AccountId] = target.Amount
|
||||
}));
|
||||
}
|
||||
|
||||
rows = rows
|
||||
.OrderBy(r => ParseDisplayIdYear(r.DisplayId))
|
||||
.ThenBy(r => ParseDisplayIdSequence(r.DisplayId))
|
||||
.ThenBy(r => r.Date)
|
||||
.ThenBy(r => r.Title)
|
||||
.ToList();
|
||||
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4.Landscape());
|
||||
page.MarginHorizontal(24);
|
||||
page.MarginVertical(20);
|
||||
page.DefaultTextStyle(x => x.FontSize(9));
|
||||
|
||||
page.Header().Column(col =>
|
||||
{
|
||||
col.Item().Text(clubName).Bold().FontSize(18);
|
||||
var headerTitle = year.HasValue
|
||||
? $"Übersicht aller Konten ({year.Value})"
|
||||
: "Übersicht aller Konten";
|
||||
col.Item().Text(headerTitle).FontSize(13).FontColor(Colors.Grey.Darken1);
|
||||
col.Item().PaddingTop(2).Text($"Gesamtvermögen: {FormatCurrency(totalClubBalance)}")
|
||||
.FontSize(10)
|
||||
.SemiBold()
|
||||
.FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||||
col.Item().PaddingTop(4).Text($"Erstellt am: {DateTime.Now:dd.MM.yyyy}").FontSize(8).FontColor(Colors.Grey.Medium);
|
||||
col.Item().PaddingTop(8).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
|
||||
});
|
||||
|
||||
page.Content().PaddingTop(10).Column(content =>
|
||||
{
|
||||
content.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.ConstantColumn(70);
|
||||
columns.ConstantColumn(70);
|
||||
columns.RelativeColumn(2);
|
||||
foreach (var _ in accounts)
|
||||
columns.RelativeColumn();
|
||||
});
|
||||
|
||||
table.Header(header =>
|
||||
{
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingBottom(4).Text("LFDNR").SemiBold();
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingBottom(4).Text("Datum").SemiBold();
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingBottom(4).Text("Titel").SemiBold();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
header.Cell().BorderBottom(1).BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingBottom(4).AlignRight().Text(account.Name).SemiBold();
|
||||
}
|
||||
});
|
||||
|
||||
if (year.HasValue && carryoverByAccountId is not null)
|
||||
{
|
||||
BodyCell(table, string.Empty);
|
||||
BodyCell(table, string.Empty);
|
||||
BodyCell(table, $"Übertrag von {year.Value - 1}", alignRight: true);
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
BodyAmountCell(table, carryoverByAccountId.GetValueOrDefault(account.Id));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
BodyCell(table, row.DisplayId);
|
||||
BodyCell(table, row.Date.ToString("dd.MM.yyyy"));
|
||||
BodyCell(table, row.Title);
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
if (row.AmountByAccountId.TryGetValue(account.Id, out var amount))
|
||||
{
|
||||
BodyAmountCell(table, amount);
|
||||
}
|
||||
else
|
||||
{
|
||||
BodyCell(table, string.Empty, alignRight: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add one summary line with the final balance for each account column.
|
||||
FooterCell(table, string.Empty);
|
||||
FooterCell(table, string.Empty);
|
||||
FooterCell(table, "Kontostand", alignRight: true);
|
||||
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
FooterAmountCell(table, balanceByAccountId.GetValueOrDefault(account.Id));
|
||||
}
|
||||
});
|
||||
|
||||
content.Item().PaddingTop(8).AlignRight().Text(text =>
|
||||
{
|
||||
text.Span("Gesamtvermögen: ").SemiBold();
|
||||
text.Span(FormatCurrency(totalClubBalance))
|
||||
.SemiBold()
|
||||
.FontColor(totalClubBalance >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.Span("Seite ");
|
||||
text.CurrentPageNumber();
|
||||
text.Span(" von ");
|
||||
text.TotalPages();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
}
|
||||
|
||||
private static void SummaryRow(ColumnDescriptor col, string label, decimal value)
|
||||
{
|
||||
col.Item().PaddingVertical(2).Row(row =>
|
||||
@@ -152,4 +411,65 @@ public class PdfStatementService : IPdfStatementService
|
||||
{
|
||||
return amount.ToString("N2", DeLocale) + " €";
|
||||
}
|
||||
|
||||
private static void BodyCell(TableDescriptor table, string text, bool alignRight = false)
|
||||
{
|
||||
var cell = table.Cell()
|
||||
.BorderBottom(0.5f)
|
||||
.BorderColor(Colors.Grey.Lighten2)
|
||||
.PaddingVertical(3);
|
||||
|
||||
var content = alignRight ? cell.AlignRight() : cell;
|
||||
content.Text(text);
|
||||
}
|
||||
|
||||
private static void BodyAmountCell(TableDescriptor table, decimal amount)
|
||||
{
|
||||
table.Cell()
|
||||
.BorderBottom(0.5f)
|
||||
.BorderColor(Colors.Grey.Lighten2)
|
||||
.PaddingVertical(3)
|
||||
.AlignRight()
|
||||
.Text($"{(amount >= 0 ? "+" : "−")}{FormatCurrency(Math.Abs(amount))}")
|
||||
.FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||||
}
|
||||
|
||||
private static void FooterCell(TableDescriptor table, string text, bool alignRight = false)
|
||||
{
|
||||
var cell = table.Cell()
|
||||
.BorderTop(1)
|
||||
.BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingTop(5)
|
||||
.PaddingBottom(2);
|
||||
|
||||
var content = alignRight ? cell.AlignRight() : cell;
|
||||
content.Text(text).SemiBold();
|
||||
}
|
||||
|
||||
private static void FooterAmountCell(TableDescriptor table, decimal amount)
|
||||
{
|
||||
table.Cell()
|
||||
.BorderTop(1)
|
||||
.BorderColor(Colors.Grey.Darken1)
|
||||
.PaddingTop(5)
|
||||
.PaddingBottom(2)
|
||||
.AlignRight()
|
||||
.Text(FormatCurrency(amount))
|
||||
.SemiBold()
|
||||
.FontColor(amount >= 0 ? Colors.Green.Darken1 : Colors.Red.Darken1);
|
||||
}
|
||||
|
||||
private static int ParseDisplayIdYear(string displayId)
|
||||
{
|
||||
var parts = displayId.Split('-');
|
||||
return parts.Length == 2 && int.TryParse(parts[0], out var year) ? year : int.MaxValue;
|
||||
}
|
||||
|
||||
private static int ParseDisplayIdSequence(string displayId)
|
||||
{
|
||||
var parts = displayId.Split('-');
|
||||
return parts.Length == 2 && int.TryParse(parts[1], out var seq) ? seq : int.MaxValue;
|
||||
}
|
||||
|
||||
private sealed record DashboardStatementRow(string DisplayId, DateTime Date, string Title, Dictionary<int, decimal> AmountByAccountId);
|
||||
}
|
||||
|
||||
132
tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs
Normal file
132
tests/Duempelkas.Tests/DashboardPdfStatementServiceTests.cs
Normal file
@@ -0,0 +1,132 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.Domain.Entities;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class DashboardPdfStatementServiceTests : IDisposable
|
||||
{
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly PdfStatementService _pdfService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-dashboard-pdf-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public DashboardPdfStatementServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
_db.Database.OpenConnection();
|
||||
_db.Database.EnsureCreated();
|
||||
|
||||
var dbFactory = new TestDbContextFactory(options);
|
||||
var entryService = new EntryService(dbFactory);
|
||||
var balanceQueryService = new BalanceQueryService(dbFactory);
|
||||
var settingsService = new FixedSettingsService("Testverein");
|
||||
|
||||
_pdfService = new PdfStatementService(dbFactory, entryService, balanceQueryService, settingsService);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateDashboardStatementAsync_WithBookingsAndTransfer_ReturnsPdf()
|
||||
{
|
||||
var barkasse = new Account { Name = "Barkasse" };
|
||||
var girokonto = new Account { Name = "Girokonto" };
|
||||
_db.Accounts.AddRange(barkasse, girokonto);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_db.Entries.Add(new Entry
|
||||
{
|
||||
AccountId = barkasse.Id,
|
||||
DisplayId = "2026-001",
|
||||
Type = EntryType.Income,
|
||||
Date = new DateTime(2026, 1, 12),
|
||||
Title = "Einkuenfte Sommerfest",
|
||||
Amount = 604.60m
|
||||
});
|
||||
|
||||
var transferExpense = new Entry
|
||||
{
|
||||
AccountId = barkasse.Id,
|
||||
DisplayId = "2026-002",
|
||||
Type = EntryType.Expense,
|
||||
Date = new DateTime(2026, 2, 16),
|
||||
Title = "Einzahlung",
|
||||
Amount = 600.00m
|
||||
};
|
||||
|
||||
var transferIncome = new Entry
|
||||
{
|
||||
AccountId = girokonto.Id,
|
||||
DisplayId = "2026-002",
|
||||
Type = EntryType.Income,
|
||||
Date = new DateTime(2026, 2, 16),
|
||||
Title = "Einzahlung",
|
||||
Amount = 600.00m
|
||||
};
|
||||
|
||||
_db.Entries.AddRange(transferExpense, transferIncome);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var transferLink = new TransferLink
|
||||
{
|
||||
SourceEntryId = transferExpense.Id,
|
||||
TargetEntryId = transferIncome.Id,
|
||||
Note = "Umbuchung"
|
||||
};
|
||||
|
||||
_db.TransferLinks.Add(transferLink);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
transferExpense.TransferLinkId = transferLink.Id;
|
||||
transferIncome.TransferLinkId = transferLink.Id;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var pdf = await _pdfService.GenerateDashboardStatementAsync();
|
||||
|
||||
pdf.Should().NotBeNull();
|
||||
pdf.Length.Should().BeGreaterThan(1000);
|
||||
System.Text.Encoding.ASCII.GetString(pdf.Take(4).ToArray()).Should().Be("%PDF");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
|
||||
{
|
||||
private readonly DbContextOptions<FinanceDbContext> _options;
|
||||
|
||||
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public FinanceDbContext CreateDbContext() => new(_options);
|
||||
|
||||
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new FinanceDbContext(_options));
|
||||
}
|
||||
|
||||
private sealed class FixedSettingsService : ISettingsService
|
||||
{
|
||||
private readonly string? _clubName;
|
||||
|
||||
public FixedSettingsService(string? clubName)
|
||||
{
|
||||
_clubName = clubName;
|
||||
}
|
||||
|
||||
public Task<string?> GetClubNameAsync() => Task.FromResult(_clubName);
|
||||
|
||||
public Task SetClubNameAsync(string? clubName) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
123
tests/Duempelkas.Tests/EntryServiceBookingTests.cs
Normal file
123
tests/Duempelkas.Tests/EntryServiceBookingTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Duempelkas.Domain.Entities;
|
||||
using Duempelkas.Domain.Enums;
|
||||
using Duempelkas.Infrastructure.Persistence;
|
||||
using Duempelkas.Infrastructure.Services;
|
||||
using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class EntryServiceBookingTests : IDisposable
|
||||
{
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-entry-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public EntryServiceBookingTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
_db.Database.OpenConnection();
|
||||
_db.Database.EnsureCreated();
|
||||
|
||||
var dbFactory = new TestDbContextFactory(options);
|
||||
_entryService = new EntryService(dbFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEntry_AssignsSequentialDisplayIds_PerYear()
|
||||
{
|
||||
var accountA = new Account { Name = "Konto A" };
|
||||
var accountB = new Account { Name = "Konto B" };
|
||||
_db.Accounts.AddRange(accountA, accountB);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var first = await _entryService.CreateEntryAsync(accountA.Id, EntryType.Income, new DateTime(2026, 1, 10), "Einnahme A", 100m);
|
||||
var second = await _entryService.CreateEntryAsync(accountB.Id, EntryType.Expense, new DateTime(2026, 1, 11), "Ausgabe B", 20m);
|
||||
var third = await _entryService.CreateEntryAsync(accountA.Id, EntryType.Income, new DateTime(2027, 1, 1), "Neues Jahr", 5m);
|
||||
|
||||
first.DisplayId.Should().Be("2026-001");
|
||||
second.DisplayId.Should().Be("2026-002");
|
||||
third.DisplayId.Should().Be("2027-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateEntry_AfterTransfer_UsesNextDisplayId()
|
||||
{
|
||||
var source = new Account { Name = "Barkasse" };
|
||||
var target = new Account { Name = "Girokonto" };
|
||||
_db.Accounts.AddRange(source, target);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _entryService.CreateTransferAsync(source.Id, target.Id, new DateTime(2026, 3, 15), "Umbuchung", 500m);
|
||||
var booking = await _entryService.CreateEntryAsync(source.Id, EntryType.Income, new DateTime(2026, 3, 16), "Einzahlung", 50m);
|
||||
|
||||
booking.DisplayId.Should().Be("2026-002");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateEntry_UpdatesBookingFields()
|
||||
{
|
||||
var account = new Account { Name = "Konto" };
|
||||
_db.Accounts.Add(account);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var entry = await _entryService.CreateEntryAsync(account.Id, EntryType.Expense, new DateTime(2026, 2, 2), "Alt", 10m);
|
||||
|
||||
await _entryService.UpdateEntryAsync(entry.Id, new DateTime(2026, 2, 3), "Neu", 25m);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var updated = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
|
||||
updated.Date.Should().Be(new DateTime(2026, 2, 3));
|
||||
updated.Title.Should().Be("Neu");
|
||||
updated.Amount.Should().Be(25m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAndRestoreEntry_TogglesSoftDelete_ForBookingOnly()
|
||||
{
|
||||
var account = new Account { Name = "Konto" };
|
||||
_db.Accounts.Add(account);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var entry = await _entryService.CreateEntryAsync(account.Id, EntryType.Expense, new DateTime(2026, 2, 2), "Buchung", 10m);
|
||||
|
||||
await _entryService.DeleteEntryAsync(entry.Id);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var deleted = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
deleted.IsDeleted.Should().BeTrue();
|
||||
|
||||
await _entryService.RestoreEntryAsync(entry.Id);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
var restored = await _db.Entries.SingleAsync(e => e.Id == entry.Id);
|
||||
restored.IsDeleted.Should().BeFalse();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<FinanceDbContext>
|
||||
{
|
||||
private readonly DbContextOptions<FinanceDbContext> _options;
|
||||
|
||||
public TestDbContextFactory(DbContextOptions<FinanceDbContext> options)
|
||||
{
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public FinanceDbContext CreateDbContext() => new(_options);
|
||||
|
||||
public Task<FinanceDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(new FinanceDbContext(_options));
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,12 @@ public class TransferServiceTests : IDisposable
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly BalanceQueryService _balanceQueryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-transfer-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public TransferServiceTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite("Data Source=duempelkas-transfer-tests;Mode=Memory;Cache=Shared")
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
@@ -52,7 +53,8 @@ public class TransferServiceTests : IDisposable
|
||||
var targetEntry = entries.Single(e => e.AccountId == accountB.Id);
|
||||
targetEntry.Type.Should().Be(EntryType.Income);
|
||||
targetEntry.Amount.Should().Be(100.00m);
|
||||
targetEntry.DisplayId.Should().Be("2026-002");
|
||||
targetEntry.DisplayId.Should().Be("2026-001");
|
||||
targetEntry.DisplayId.Should().Be(sourceEntry.DisplayId);
|
||||
|
||||
var links = await _db.TransferLinks.ToListAsync();
|
||||
links.Should().HaveCount(1);
|
||||
@@ -105,6 +107,29 @@ public class TransferServiceTests : IDisposable
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateTransfer_ChangeLinkedAccount_KeepsSharedDisplayId()
|
||||
{
|
||||
var source = new Account { Name = "Barkasse" };
|
||||
var initialTarget = new Account { Name = "Girokonto" };
|
||||
var newTarget = new Account { Name = "Sparkonto" };
|
||||
_db.Accounts.AddRange(source, initialTarget, newTarget);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _entryService.CreateTransferAsync(source.Id, initialTarget.Id, new DateTime(2026, 3, 15), "Umbuchung", 500.00m);
|
||||
var sourceEntry = await _db.Entries.SingleAsync(e => e.AccountId == source.Id);
|
||||
|
||||
await _entryService.UpdateTransferAsync(sourceEntry.Id, newTarget.Id, new DateTime(2026, 3, 16), "Umbuchung angepasst", 550.00m);
|
||||
|
||||
_db.ChangeTracker.Clear();
|
||||
|
||||
var updatedSourceEntry = await _db.Entries.SingleAsync(e => e.AccountId == source.Id);
|
||||
var updatedTargetEntry = await _db.Entries.SingleAsync(e => e.AccountId == newTarget.Id);
|
||||
|
||||
updatedSourceEntry.DisplayId.Should().Be("2026-001");
|
||||
updatedTargetEntry.DisplayId.Should().Be(updatedSourceEntry.DisplayId);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_db.Database.CloseConnection();
|
||||
|
||||
@@ -13,11 +13,12 @@ public class BalanceCalculationTests : IDisposable
|
||||
private readonly FinanceDbContext _db;
|
||||
private readonly BalanceQueryService _balanceQueryService;
|
||||
private readonly EntryService _entryService;
|
||||
private readonly string _connectionString = $"Data Source=duempelkas-balance-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
|
||||
|
||||
public BalanceCalculationTests()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<FinanceDbContext>()
|
||||
.UseSqlite("Data Source=duempelkas-balance-tests;Mode=Memory;Cache=Shared")
|
||||
.UseSqlite(_connectionString)
|
||||
.Options;
|
||||
|
||||
_db = new FinanceDbContext(options);
|
||||
|
||||
Reference in New Issue
Block a user