Compare commits

...

15 Commits

Author SHA1 Message Date
39c5a28bd9 refactor(app): add SkiaSharp and SkiaSharp.HarfBuzz package references 2026-04-03 15:55:16 +02:00
e21a2fc1c5 refactor(app): add theme toggle functionality and improve theme management 2026-04-03 14:51:45 +02:00
68c7a1ca6a refactor(app): implement year filtering for accounts and enhance dashboard PDF generation 2026-04-03 14:24:46 +02:00
4636acf7b0 refactor(app): implement year filter for account entries and update related services 2026-04-03 14:11:42 +02:00
08185f88cd refactor(app): split dashboard settings and layout into partial classes 2026-04-03 12:59:16 +02:00
b8b1c74a84 refactor(app): extract dialog component logic to code-behind 2026-04-03 12:59:11 +02:00
55c2c01418 refactor(app): move account razor inline code to partial classes 2026-04-03 12:59:06 +02:00
9807e4d61d Refactor dialog components to remove Cancel event from backdrop; enhance confirmation dialog for restore functionality 2026-04-03 12:46:28 +02:00
9aa1fee49e Add unit tests for DashboardPdfStatementService to validate PDF generation with entries and transfers 2026-04-03 12:33:48 +02:00
69181e66b0 Refactor EntryService to allow shared DisplayId for transfers; update related tests and migration files 2026-04-03 12:00:53 +02:00
387c18e834 Implement backup and restore functionality; add IBackupService and BackupService; refactor services to use DbContextFactory 2026-04-03 10:53:53 +02:00
0923c037eb Refactor terminology in dialogs and tables for consistency; update styles for number inputs and footer links 2026-04-03 09:32:14 +02:00
719b33bb71 Refactor AddEntryDialog and AccountDetail for improved UI consistency; remove unused AddYearDialog; enhance Dashboard layout and button styles 2026-04-03 09:16:59 +02:00
a02b0e9436 Refactor dialog components to use form-container for improved layout and styling; update button styles for consistency 2026-04-03 08:57:55 +02:00
c376c70fec Enhance ConfirmDialog with customizable button styles and add PDF opening functionality in AccountDetail 2026-03-31 19:21:14 +02:00
59 changed files with 2746 additions and 540 deletions

2
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build", "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": [], "args": [],
"cwd": "${workspaceFolder}/src/Duempelkas.Desktop", "cwd": "${workspaceFolder}/src/Duempelkas.Desktop",
"stopAtEntry": false, "stopAtEntry": false,

35
.vscode/tasks.json vendored
View File

@@ -22,6 +22,37 @@
} }
} }
}, },
{
"label": "publish",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/src/Duempelkas.Desktop/Duempelkas.Desktop.csproj",
"-c",
"Release",
"-r",
"win-x64",
"--self-contained",
"true",
"/p:PublishSingleFile=true",
"/p:EnableCompressionInSingleFile=true",
"/p:IncludeNativeLibrariesForSelfExtract=true",
"/p:IncludeAllContentForSelfExtract=true",
"/p:DebugType=None",
"/p:DebugSymbols=false",
"-o",
"${workspaceFolder}/Publish"
],
"problemMatcher": "$msCompile",
"options": {
"statusbar": {
"label": "$(package) Publish",
"color": "#22c55e",
"detail": "Publish desktop app into Publish folder"
}
}
},
{ {
"label": "watch", "label": "watch",
"command": "dotnet", "command": "dotnet",
@@ -34,6 +65,10 @@
], ],
"problemMatcher": "$msCompile", "problemMatcher": "$msCompile",
"isBackground": true, "isBackground": true,
"presentation": {
"reveal": "never",
"panel": "dedicated"
},
"options": { "options": {
"statusbar": { "statusbar": {
"label": "$(eye) Watch", "label": "$(eye) Watch",

View File

@@ -9,8 +9,3 @@
</div> </div>
</div> </div>
@code {
[Parameter] public AccountSummaryDto Account { get; set; } = default!;
private void Navigate() => Navigation.NavigateTo($"/accounts/{Account.Id}");
}

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

View File

@@ -7,6 +7,3 @@
} }
</div> </div>
@code {
[Parameter] public List<AccountSummaryDto> Accounts { get; set; } = new();
}

View File

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

View File

@@ -34,17 +34,3 @@
</td> </td>
</tr> </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);
}
}

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

View File

@@ -20,12 +20,6 @@
@if (!Entries.Any()) @if (!Entries.Any())
{ {
<div class="text-center py-3 text-muted">Keine Einträge vorhanden.</div> <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; }
}

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

View File

@@ -1,29 +1,15 @@
<div class="dialog-backdrop" @onclick="Cancel"> <div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true"> <div class="dialog-content" @onclick:stopPropagation="true">
<h5 class="mb-3">Neues Konto</h5> <h5 class="mb-3">Neues Konto</h5>
<div class="mb-3"> <div class="form-container">
<label class="form-label">Kontoname</label> <label class="form-label">Kontoname</label>
<input type="text" class="form-control" @bind="name" @bind:event="oninput" <input type="text" class="form-control" @bind="name" @bind:event="oninput"
placeholder="z.B. Girokonto" /> placeholder="z.B. Girokonto" />
</div> </div>
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Erstellen</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
<button class="btn btn-primary" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Erstellen</button>
</div> </div>
</div> </div>
</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();
}

View File

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

View File

@@ -1,70 +1,36 @@
@inject IEntryService EntryService @inject IEntryService EntryService
<div class="dialog-backdrop" @onclick="Cancel"> <div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true"> <div class="dialog-content" @onclick:stopPropagation="true">
<h5>@(EditEntry != null ? "Eintrag bearbeiten" : "Neuer Eintrag")</h5> <h5>@(EditEntry != null ? "Buchung bearbeiten" : "Neue Buchung")</h5>
<div class="form-container">
@if (EditEntry == null) @if (EditEntry == null)
{ {
<div class="mb-3">
<label class="form-label">Art</label> <label class="form-label">Art</label>
<select class="form-select" @bind="entryType"> <select @bind="entryType">
<option value="@EntryType.Income">Einnahme</option> <option value="@EntryType.Income">Einnahme</option>
<option value="@EntryType.Expense">Ausgabe</option> <option value="@EntryType.Expense">Ausgabe</option>
</select> </select>
</div>
} }
<div class="mb-3">
<label class="form-label">Datum</label> <label class="form-label">Datum</label>
<input type="date" class="form-control" @bind="date" /> <input type="date" @bind="date" />
</div>
<div class="mb-3">
<label class="form-label">Bezeichnung</label> <label class="form-label">Bezeichnung</label>
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung" /> <input type="text" class="form-control" @bind="title" placeholder="Beschreibung" />
</div>
<div class="mb-3">
<label class="form-label">Betrag (€)</label> <label class="form-label">Betrag (€)</label>
<input type="number" class="form-control" @bind="amount" step="0.01" min="0.01" /> <input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
</div> </div>
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button class="btn btn-success" @onclick="Save"
<button class="btn btn-primary" @onclick="Save"
disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0)"> disabled="@(string.IsNullOrWhiteSpace(title) || amount <= 0)">
<i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen") <i class="bi bi-check-lg"></i> @(EditEntry != null ? "Speichern" : "Hinzufügen")
</button> </button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div> </div>
</div> </div>
</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();
}

View File

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

View File

@@ -1,11 +1,11 @@
@inject IEntryService EntryService @inject IEntryService EntryService
@inject IAccountService AccountService @inject IAccountService AccountService
<div class="dialog-backdrop" @onclick="Cancel"> <div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true"> <div class="dialog-content" @onclick:stopPropagation="true">
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5> <h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
<div class="mb-3"> <div class="form-container">
<label class="form-label">Zielkonto</label> <label class="form-label">Zielkonto</label>
<select class="form-select" @bind="targetAccountId"> <select class="form-select" @bind="targetAccountId">
<option value="0">— Auswählen —</option> <option value="0">— Auswählen —</option>
@@ -14,65 +14,22 @@
<option value="@acc.Id">@acc.Name</option> <option value="@acc.Id">@acc.Name</option>
} }
</select> </select>
</div>
<div class="mb-3">
<label class="form-label">Datum</label> <label class="form-label">Datum</label>
<input type="date" class="form-control" @bind="date" /> <input type="date" class="form-control" @bind="date" />
</div>
<div class="mb-3">
<label class="form-label">Bezeichnung</label> <label class="form-label">Bezeichnung</label>
<input type="text" class="form-control" @bind="title" placeholder="Beschreibung der Umbuchung" /> <input type="text" class="form-control" @bind="title" placeholder="Beschreibung der Umbuchung" />
</div>
<div class="mb-3">
<label class="form-label">Betrag (€)</label> <label class="form-label">Betrag (€)</label>
<input type="number" class="form-control" @bind="amount" step="0.01" min="0.01" /> <input type="number" class="form-control no-spinner" @bind="amount" step="0.01" min="0.01" />
</div> </div>
<div class="d-flex justify-content-end gap-2"> <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" : "Hinzufügen")</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
<button class="btn btn-primary" @onclick="Save"
disabled="@(!CanSave)"><i class="bi bi-arrow-left-right"></i> @(EditEntry != null ? "Speichern" : "Umbuchen")</button>
</div> </div>
</div> </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();
}

View File

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

View File

@@ -1 +0,0 @@
@* AddYearDialog is no longer used - year management has been removed from the data model. *@

View File

@@ -1,22 +1,11 @@
<div class="dialog-backdrop" @onclick="Cancel"> <div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true"> <div class="dialog-content" @onclick:stopPropagation="true">
<h5>@Title</h5> <h5>@Title</h5>
<p>@Message</p> <p>@Message</p>
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
<button class="btn btn-danger" @onclick="Confirm"><i class="bi bi-trash"></i> @ConfirmText</button>
</div> </div>
</div> </div>
</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 EventCallback OnConfirm { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
private async Task Confirm() => await OnConfirm.InvokeAsync();
private async Task Cancel() => await OnCancel.InvokeAsync();
}

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

View File

@@ -1,27 +1,14 @@
<div class="dialog-backdrop" @onclick="Cancel"> <div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true"> <div class="dialog-content" @onclick:stopPropagation="true">
<h5 class="mb-3">Übertrag bearbeiten</h5> <h5 class="mb-3">Übertrag bearbeiten</h5>
<div class="mb-3"> <div class="form-container">
<label class="form-label">Übertrag (€)</label> <label class="form-label">Übertrag (€)</label>
<input type="number" class="form-control" step="0.01" @bind="amount" /> <input type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
</div> </div>
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
<button class="btn btn-primary" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button>
</div> </div>
</div> </div>
</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();
}

View File

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

View File

@@ -1,31 +1,14 @@
<div class="dialog-backdrop" @onclick="Cancel"> <div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true"> <div class="dialog-content" @onclick:stopPropagation="true">
<h5 class="mb-3">Kontoname bearbeiten</h5> <h5 class="mb-3">@DialogTitle</h5>
<div class="mb-3"> <div class="form-container">
<label class="form-label">Kontoname</label> <label class="form-label">@NameLabel</label>
<input type="text" class="form-control" @bind="name" @bind:event="oninput" /> <input type="text" class="form-control" @bind="name" @bind:event="oninput" />
</div> </div>
<div class="d-flex justify-content-end gap-2"> <div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button> <button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
<button class="btn btn-primary" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
</div> </div>
</div> </div>
</div> </div>
@code {
[Parameter] public string CurrentName { get; set; } = string.Empty;
[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();
}

View File

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

View File

@@ -11,20 +11,24 @@
@Body @Body
</main> </main>
<footer class="app-footer"> <footer class="app-footer d-flex align-items-center justify-content-center gap-2">
Dümpelkas &copy; @DateTime.Now.Year &middot; Version @AppVersion <span>Dümpelkas &copy; @DateTime.Now.Year &middot; Version @AppVersion</span> &middot;<a class="app-footer-about" @onclick="OpenAboutDialog">Über</a>
</footer> </footer>
</div> </div>
@code { @if (showAboutDialog)
private static string AppVersion {
{ <div class="dialog-backdrop">
get <div class="dialog-content" @onclick:stopPropagation="true">
{ <h5>Über Dümpelkas &middot; Version @AppVersion</h5>
var now = DateTime.Now; <p class="mb-2">Entwickler: Andre Beging</p>
var yearShort = now.Year % 100; <p class="mb-3">E-Mail: <a href="mailto:mail@beging.de" style="color: var(--color-text);">mail@beging.de</a></p>
var dayOfYear = now.DayOfYear; <div class="d-flex justify-content-end">
return $"{yearShort}.{dayOfYear}"; <button type="button" class="btn btn-outline-secondary" @onclick="CloseAboutDialog">
} <i class="bi bi-x-lg"></i> Schließen
} </button>
</div>
</div>
</div>
} }

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

View File

@@ -15,15 +15,35 @@
} }
else else
{ {
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center flex-grow-1" style="min-width: 0;"> <div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
<h2 class="mb-0 mt-0" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> <h1 class="mb-0 mt-0" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@account.Name @account.Name
<button class="btn-edit-pen" @onclick="() => showEditName = true" title="Name bearbeiten"><i class="bi bi-pencil"></i></button> <button class="btn-edit-pen" @onclick="() => showEditName = true" title="Name bearbeiten"><i class="bi bi-pencil"></i></button>
</h2> </h1>
</div> </div>
</div> </div>
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
<button class="btn btn-secondary btn-nav" @onclick="NavigateBack">
<i class="bi bi-arrow-left"></i> Zurück
</button>
|
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
<i class="bi bi-plus-lg"></i> Buchung
</button>
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
<i class="bi bi-arrow-left-right"></i> Umbuchung
</button>
<button class="btn btn-dark btn-nav" @onclick="HandleExport">
<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>
</div>
@if (balance != null) @if (balance != null)
{ {
<div class="summary-section"> <div class="summary-section">
@@ -36,21 +56,21 @@
</div> </div>
<div class="text-end"> <div class="text-end">
<small class="text-muted">Einnahmen</small> <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>
<div class="text-end"> <div class="text-end">
<small class="text-muted">Ausgaben</small> <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> </div>
</div> </div>
@if (showCurrentYearOnly) @if (selectedYear.HasValue)
{ {
<div class="summary-section"> <div class="summary-section">
<div class="summary-flex"> <div class="summary-flex">
<div> <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"> <div class="d-flex align-items-center gap-1">
<span class="fw-bold">@FormatAmount(balance.CarryoverBalance)</span> <span class="fw-bold">@FormatAmount(balance.CarryoverBalance)</span>
<button class="btn-edit-pen" @onclick="() => showEditCarryover = true" title="Übertrag bearbeiten"> <button class="btn-edit-pen" @onclick="() => showEditCarryover = true" title="Übertrag bearbeiten">
@@ -59,7 +79,7 @@
</div> </div>
</div> </div>
<div class="text-end"> <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")"> <div class="fw-bold @(balance.CurrentYearIncome - balance.CurrentYearExpense >= 0 ? "amount-positive" : "amount-negative")">
@FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense) @FormatAmount(balance.CurrentYearIncome - balance.CurrentYearExpense)
</div> </div>
@@ -69,23 +89,6 @@
} }
} }
<div class="d-flex align-items-center gap-2 mb-3 py-3 flex-wrap">
<button class="btn btn-secondary btn-sm" @onclick="NavigateBack">
<i class="bi bi-arrow-left"></i> Zurück
</button>
<button class="btn btn-success btn-sm" @onclick="() => showAddEntry = true">
<i class="bi bi-plus-lg"></i> Eintrag
</button>
<button class="btn btn-info btn-sm" @onclick="() => showAddTransfer = true">
<i class="bi bi-arrow-left-right"></i> Umbuchung
</button>
<button class="btn btn-dark btn-sm" @onclick="HandleExport">
<i class="bi bi-file-earmark-pdf"></i> PDF
</button>
<button class="btn btn-sm @(showCurrentYearOnly ? "btn-primary" : "btn-outline-secondary")" @onclick="ToggleYearFilter">
<i class="bi bi-funnel"></i> @(showCurrentYearOnly ? $"Nur {DateTime.Now.Year}" : "Alle Einträge")
</button>
</div>
@if (entries != null) @if (entries != null)
{ {
@@ -126,24 +129,40 @@
@if (confirmDeleteEntryId.HasValue) @if (confirmDeleteEntryId.HasValue)
{ {
<ConfirmDialog Title="Eintrag löschen" <ConfirmDialog Title="Buchung löschen"
Message="@($"Soll \"{confirmDeleteEntryTitle}\" wirklich gelöscht werden?")" Message="@($"Soll \"{confirmDeleteEntryTitle}\" wirklich gelöscht werden?")"
ConfirmText="Ja, löschen" ConfirmText="Ja, löschen"
CancelText="Nein, abbrechen" CancelText="Nein, abbrechen"
ConfirmButtonClass="btn btn-danger"
ConfirmIconClass="bi bi-trash"
OnConfirm="HandleConfirmDelete" OnConfirm="HandleConfirmDelete"
OnCancel="CancelDeleteConfirm" /> OnCancel="CancelDeleteConfirm" />
} }
@if (confirmRestoreEntryId.HasValue) @if (confirmRestoreEntryId.HasValue)
{ {
<ConfirmDialog Title="Eintrag wiederherstellen" <ConfirmDialog Title="Buchung wiederherstellen"
Message="@($"Soll \"{confirmRestoreEntryTitle}\" wiederhergestellt werden?")" Message="@($"Soll \"{confirmRestoreEntryTitle}\" wiederhergestellt werden?")"
ConfirmText="Ja, wiederherstellen" ConfirmText="Ja, wiederherstellen"
CancelText="Nein, abbrechen" CancelText="Nein, abbrechen"
ConfirmButtonClass="btn btn-warning"
ConfirmIconClass="bi bi-arrow-clockwise"
OnConfirm="HandleConfirmRestore" OnConfirm="HandleConfirmRestore"
OnCancel="CancelRestoreConfirm" /> OnCancel="CancelRestoreConfirm" />
} }
@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 (editingEntry != null) @if (editingEntry != null)
{ {
<AddEntryDialog AccountId="AccountId" <AddEntryDialog AccountId="AccountId"
@@ -160,139 +179,29 @@
OnCancel="() => editingTransferEntry = null" /> OnCancel="() => editingTransferEntry = null" />
} }
@code { @if (showYearFilterDialog)
[Parameter] public int AccountId { get; set; } {
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
private AccountSummaryDto? account; <div class="dialog-content" @onclick:stopPropagation="true">
private AccountBalanceDto? balance; <h5>Filter auswählen</h5>
private List<EntryDto>? entries; <p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
private bool showAddEntry, showAddTransfer; <div class="filter-options-grid">
private bool showEditName, showEditCarryover; <button class="btn @(selectedYear.HasValue ? "btn-outline-secondary" : "btn-primary")" @onclick="() => SelectFilterYear(null)">
private bool showCurrentYearOnly = true; Alle Buchungen
</button>
private int? confirmDeleteEntryId; @foreach (var year in availableYears)
private string? confirmDeleteEntryTitle;
private int? confirmRestoreEntryId;
private string? confirmRestoreEntryTitle;
private EntryDto? editingEntry;
private EntryDto? editingTransferEntry;
private void NavigateBack() => Navigation.NavigateTo("/");
protected override async Task OnParametersSetAsync()
{ {
await LoadAll(); <button class="btn @(selectedYear == year ? "btn-primary" : "btn-outline-secondary")" @onclick="() => SelectFilterYear(year)">
@year
</button>
} }
</div>
private async Task LoadAll() <div class="d-flex justify-content-end mt-3">
{ <button class="btn btn-outline-secondary" @onclick="CloseYearFilterDialog">
account = await AccountService.GetAccountAsync(AccountId); <i class="bi bi-x-lg"></i> Schließen
balance = await BalanceQueryService.GetAccountBalanceAsync(AccountId); </button>
entries = await EntryService.GetEntriesAsync(AccountId, showCurrentYearOnly); </div>
} </div>
</div>
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";
await FileSaveService.SaveFileAsync(pdf, $"{account?.Name}{suffix}.pdf");
}
private static string FormatAmount(decimal amount) => $"{amount:N2} €";
} }

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

View File

@@ -1,18 +1,65 @@
@page "/" @page "/"
@inject IAccountService AccountService @inject IAccountService AccountService
@inject IBackupService BackupService
@inject ISettingsService SettingsService
@inject IPdfStatementService PdfStatementService
@inject IFileSaveService FileSaveService
@inject IEntryService EntryService
@inject NavigationManager NavigationManager
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Übersicht</h2> <div class="d-flex justify-content-between align-items-center">
<div class="d-flex gap-2"> <div class="d-flex align-items-center flex-grow-1" style="min-width: 0;">
<button class="btn btn-primary" @onclick="() => showAddAccount = true"> <h1 class="mb-0 mt-0" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
@DisplayClubName
<button class="btn-edit-pen" @onclick="() => showEditClubName = true" title="Vereinsname bearbeiten">
<i class="bi bi-pencil"></i>
</button>
</h1>
</div>
</div>
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
<button class="btn-nav btn-primary" @onclick="() => showAddAccount = true">
<i class="bi bi-plus-lg"></i> Neues Konto <i class="bi bi-plus-lg"></i> Neues Konto
</button> </button>
<a class="btn btn-outline-secondary" href="/settings"> |
<i class="bi bi-gear"></i> Einstellungen <button class="btn-nav btn-success" @onclick="HandleBackupAsync">
</a> <i class="bi bi-save"></i> Backup
</button>
<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> </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> </div>
}
@if (!string.IsNullOrWhiteSpace(operationMessage))
{
<div class="alert @operationMessageClass mb-3" role="alert">
@operationMessage
</div>
}
@if (accounts == null) @if (accounts == null)
{ {
@@ -38,24 +85,59 @@
<AddAccountDialog OnSave="HandleAccountCreated" OnCancel="() => showAddAccount = false" /> <AddAccountDialog OnSave="HandleAccountCreated" OnCancel="() => showAddAccount = false" />
} }
@code { @if (showEditClubName)
private List<AccountSummaryDto>? accounts; {
private bool showAddAccount; <EditNameDialog CurrentName="@DisplayClubName" DialogTitle="Vereinsname bearbeiten" NameLabel="Vereinsname"
OnSave="HandleSaveClubName" OnCancel="() => showEditClubName = false" />
protected override async Task OnInitializedAsync()
{
await LoadAccounts();
}
private async Task LoadAccounts()
{
accounts = await AccountService.GetAllAccountsAsync();
}
private async Task HandleAccountCreated(string name)
{
await AccountService.CreateAccountAsync(name);
showAddAccount = false;
await LoadAccounts();
}
} }
@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>
}

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

View File

@@ -29,22 +29,3 @@
</div> </div>
</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;
}
}

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

View File

@@ -5,9 +5,11 @@ namespace Duempelkas.App.Services;
public interface IAccountService public interface IAccountService
{ {
Task<List<AccountSummaryDto>> GetAllAccountsAsync(); Task<List<AccountSummaryDto>> GetAllAccountsAsync();
Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year);
Task<AccountSummaryDto> GetAccountAsync(int accountId); Task<AccountSummaryDto> GetAccountAsync(int accountId);
Task<AccountSummaryDto> CreateAccountAsync(string name); Task<AccountSummaryDto> CreateAccountAsync(string name);
Task RenameAccountAsync(int accountId, string newName); Task RenameAccountAsync(int accountId, string newName);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance); Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance);
Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance, int? year);
Task DeleteAccountAsync(int accountId); Task DeleteAccountAsync(int accountId);
} }

View File

@@ -0,0 +1,7 @@
namespace Duempelkas.App.Services;
public interface IBackupService
{
Task<string> CreateBackupAsync();
Task<string> RestoreBackupAsync();
}

View File

@@ -4,5 +4,5 @@ namespace Duempelkas.App.Services;
public interface IBalanceQueryService public interface IBalanceQueryService
{ {
Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId); Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId, int? year = null);
} }

View File

@@ -5,7 +5,10 @@ namespace Duempelkas.App.Services;
public interface IEntryService public interface IEntryService
{ {
Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year);
Task<List<EntryDto>> GetEntriesAsync(int accountId, bool currentYearOnly); 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<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 CreateTransferAsync(int sourceAccountId, int targetAccountId, DateTime date, string title, decimal amount);
Task DeleteEntryAsync(int entryId); Task DeleteEntryAsync(int entryId);

View File

@@ -3,4 +3,5 @@ namespace Duempelkas.App.Services;
public interface IFileSaveService public interface IFileSaveService
{ {
Task<string?> SaveFileAsync(byte[] content, string suggestedFileName); Task<string?> SaveFileAsync(byte[] content, string suggestedFileName);
Task OpenFileAsync(string filePath);
} }

View File

@@ -2,5 +2,8 @@ namespace Duempelkas.App.Services;
public interface IPdfStatementService public interface IPdfStatementService
{ {
Task<byte[]> GenerateStatementAsync(int accountId, int? year);
Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly); Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly);
Task<byte[]> GenerateDashboardStatementAsync();
Task<byte[]> GenerateDashboardStatementAsync(int? year);
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -2,14 +2,31 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<OutputType Condition="$([MSBuild]::IsOSPlatform('Windows'))">WinExe</OutputType>
<ApplicationIcon>Assets\app-icon.ico</ApplicationIcon>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<Version>$([System.DateTime]::Now.ToString('yy')).$([System.DateTime]::Now.DayOfYear)</Version>
<PublishedExeFileName>Duempelkas-v$(Version).exe</PublishedExeFileName>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<DebugType>None</DebugType>
<DebugSymbols>false</DebugSymbols>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Include="wwwroot\**"> <Content Include="wwwroot\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<Content Include="Assets\app-icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -24,4 +41,11 @@
<ProjectReference Include="..\Duempelkas.App\Duempelkas.App.csproj" /> <ProjectReference Include="..\Duempelkas.App\Duempelkas.App.csproj" />
<ProjectReference Include="..\Duempelkas.Infrastructure\Duempelkas.Infrastructure.csproj" /> <ProjectReference Include="..\Duempelkas.Infrastructure\Duempelkas.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<Target Name="FinalizePublishOutput" AfterTargets="Publish">
<Move SourceFiles="$(PublishDir)$(AssemblyName).exe"
DestinationFiles="$(PublishDir)$(PublishedExeFileName)"
Condition="Exists('$(PublishDir)$(AssemblyName).exe') and '$(AssemblyName).exe' != '$(PublishedExeFileName)'" />
<Touch Files="$(PublishDir)duempelkas.db" AlwaysCreate="true" Condition="!Exists('$(PublishDir)duempelkas.db')" />
</Target>
</Project> </Project>

View File

@@ -3,17 +3,49 @@ using Duempelkas.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Photino.Blazor; using Photino.Blazor;
using System.Runtime.InteropServices;
namespace Duempelkas.Desktop; namespace Duempelkas.Desktop;
class Program class Program
{ {
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SetCurrentProcessExplicitAppUserModelID(string appID);
[STAThread] [STAThread]
static void Main(string[] args) 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 appBuilder = PhotinoBlazorAppBuilder.CreateDefault(args);
var dbPath = Path.Combine(AppContext.BaseDirectory, "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.Services.AddInfrastructure($"Data Source={dbPath}");
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app"); appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
@@ -22,6 +54,13 @@ class Program
app.MainWindow.StartUrl = PhotinoWebViewManager.AppBaseUri; 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()) using (var scope = app.Services.CreateScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>(); var db = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();

View File

@@ -1,9 +1,19 @@
/* Duempelkas App Styles Dark Mode */ /* Duempelkas App Styles */
:root { :root {
--color-income: #4ade80; --color-income: #4ade80;
--color-expense: #f87171; --color-expense: #f87171;
--color-transfer: #60a5fa; --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-bg: #1a1a2e;
--color-surface: #16213e; --color-surface: #16213e;
--color-surface-hover: #1e2d4a; --color-surface-hover: #1e2d4a;
@@ -138,6 +148,16 @@ html, body {
margin-bottom: 0.5rem !important; 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 */ /* Navbar */
.app-navbar { .app-navbar {
background-color: var(--color-surface); background-color: var(--color-surface);
@@ -170,10 +190,20 @@ html, body {
text-align: center; text-align: center;
} }
.app-footer-about {
color: var(--color-text-muted);
text-decoration: underline;
padding: 0;
}
.app-footer-about:hover,
.app-footer-about:focus {
color: var(--color-text);
cursor: pointer;
}
/* Buttons modern style */ /* Buttons modern style */
.btn { .btn {
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.85rem; font-size: 0.85rem;
padding: 0.35rem 0.85rem; padding: 0.35rem 0.85rem;
display: inline-flex; display: inline-flex;
@@ -186,6 +216,20 @@ html, body {
font-size: 0.9rem; font-size: 0.9rem;
} }
.btn-nav {
width: 75px;
height: 75px;
display: flex;
flex-direction: column;
gap: 0;
justify-content: center;
font-size: 0.8rem !important;
}
.btn-nav i {
font-size: 2rem;
}
.btn-primary { .btn-primary {
background-color: var(--color-accent); background-color: var(--color-accent);
border-color: var(--color-accent); border-color: var(--color-accent);
@@ -208,6 +252,28 @@ html, body {
border-color: #16a34a; border-color: #16a34a;
} }
.btn-danger {
background-color: #ef4444;
border-color: #ef4444;
color: #fff;
}
.btn-danger:hover {
background-color: #dc2626;
border-color: #dc2626;
}
.btn-warning {
background-color: #facc15;
border-color: #facc15;
color: #000;
}
.btn-warning:hover {
background-color: #eab308;
border-color: #eab308;
}
.btn-info { .btn-info {
background-color: #3b82f6; background-color: #3b82f6;
border-color: #3b82f6; border-color: #3b82f6;
@@ -255,7 +321,7 @@ html, body {
.btn-dark { .btn-dark {
background-color: #334155; background-color: #334155;
border-color: #475569; border-color: #475569;
color: var(--color-text); color: #f8fafc;
} }
.btn-dark:hover { .btn-dark:hover {
@@ -263,6 +329,12 @@ html, body {
border-color: #64748b; border-color: #64748b;
} }
html[data-ui-theme="light"] .btn-dark {
background-color: #334155;
border-color: #334155;
color: #f8fafc;
}
/* Table */ /* Table */
.table { .table {
--bs-table-bg: transparent; --bs-table-bg: transparent;
@@ -284,6 +356,32 @@ html, body {
border-bottom-color: var(--color-border); border-bottom-color: var(--color-border);
} }
/* Form container */
.form-container {
display: grid;
/* Col 1: fits largest label | Col 2: takes the rest */
grid-template-columns: max-content 1fr;
gap: 1rem;
align-items: center;
max-width: 500px;
padding-bottom: 1rem;
}
/* Ensure the label and input stay on the same row */
.form-container label {
grid-column: 1;
font-weight: 600;
}
.form-container input,
.form-container select,
.form-container textarea {
grid-column: 2;
padding: 0.5rem;
width: 100%; /* Fill the available space in col 2 */
box-sizing: border-box;
}
/* Form controls in dark */ /* Form controls in dark */
.form-control, .form-select { .form-control, .form-select {
background-color: var(--color-bg); background-color: var(--color-bg);
@@ -465,6 +563,17 @@ html, body {
text-align: right; text-align: right;
} }
.no-spinner::-webkit-outer-spin-button,
.no-spinner::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"].no-spinner {
-moz-appearance: textfield;
appearance: textfield;
}
.form-control, .form-select { .form-control, .form-select {
border-radius: 0.5rem; border-radius: 0.5rem;
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s;

View File

@@ -1,9 +1,113 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de" data-bs-theme="dark"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" /> <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> <title>Dümpelkas Kassenbuch</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet" rel="stylesheet"

View File

@@ -12,12 +12,15 @@ public static class DependencyInjection
{ {
services.AddDbContext<FinanceDbContext>(options => services.AddDbContext<FinanceDbContext>(options =>
options.UseSqlite(connectionString)); options.UseSqlite(connectionString));
services.AddDbContextFactory<FinanceDbContext>(options =>
options.UseSqlite(connectionString));
services.AddScoped<IAccountService, AccountService>(); services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IEntryService, EntryService>(); services.AddScoped<IEntryService, EntryService>();
services.AddScoped<IBalanceQueryService, BalanceQueryService>(); services.AddScoped<IBalanceQueryService, BalanceQueryService>();
services.AddScoped<IPdfStatementService, PdfStatementService>(); services.AddScoped<IPdfStatementService, PdfStatementService>();
services.AddScoped<IFileSaveService, FileSaveService>(); services.AddScoped<IFileSaveService, FileSaveService>();
services.AddSingleton<IBackupService, BackupService>();
services.AddSingleton<ISettingsService, SettingsService>(); services.AddSingleton<ISettingsService, SettingsService>();
return services; return services;

View File

@@ -12,6 +12,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="QuestPDF" Version="*" /> <PackageReference Include="QuestPDF" Version="*" />
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.HarfBuzz" Version="2.88.9" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

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

View File

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

View File

@@ -80,8 +80,7 @@ namespace Duempelkas.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("DisplayId") b.HasIndex("DisplayId");
.IsUnique();
b.HasIndex("TransferLinkId"); b.HasIndex("TransferLinkId");

View File

@@ -10,7 +10,7 @@ public class EntryConfiguration : IEntityTypeConfiguration<Entry>
{ {
builder.HasKey(e => e.Id); builder.HasKey(e => e.Id);
builder.Property(e => e.DisplayId).IsRequired().HasMaxLength(20); 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.Title).IsRequired().HasMaxLength(500);
builder.Property(e => e.Amount).HasColumnType("decimal(18,2)"); builder.Property(e => e.Amount).HasColumnType("decimal(18,2)");
builder.Property(e => e.Type).HasConversion<int>(); builder.Property(e => e.Type).HasConversion<int>();

View File

@@ -9,85 +9,158 @@ namespace Duempelkas.Infrastructure.Services;
public class AccountService : IAccountService public class AccountService : IAccountService
{ {
private readonly FinanceDbContext _db; private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public AccountService(FinanceDbContext db) => _db = db; public AccountService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync() public Task<List<AccountSummaryDto>> GetAllAccountsAsync()
{ {
var accounts = await _db.Accounts return GetAllAccountsAsync(null);
.Include(a => a.Entries) }
public async Task<List<AccountSummaryDto>> GetAllAccountsAsync(int? year)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var accounts = await db.Accounts
.OrderBy(a => a.Name) .OrderBy(a => a.Name)
.ToListAsync(); .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) public async Task<AccountSummaryDto> GetAccountAsync(int accountId)
{ {
var account = await _db.Accounts await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
.Include(a => a.Entries) .Include(a => a.Entries)
.FirstOrDefaultAsync(a => a.Id == accountId) .FirstOrDefaultAsync(a => a.Id == accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? 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) public async Task<AccountSummaryDto> CreateAccountAsync(string name)
{ {
await using var db = await _dbFactory.CreateDbContextAsync();
var account = new Account { Name = name }; var account = new Account { Name = name };
_db.Accounts.Add(account); db.Accounts.Add(account);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc); return new AccountSummaryDto(account.Id, account.Name, 0m, 0m, account.CreatedUtc);
} }
public async Task RenameAccountAsync(int accountId, string newName) public async Task RenameAccountAsync(int accountId, string newName)
{ {
var account = await _db.Accounts.FindAsync(accountId) await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? throw new InvalidOperationException($"Account {accountId} not found.");
account.Name = newName; account.Name = newName;
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance) public async Task UpdateCarryoverAsync(int accountId, decimal carryoverBalance)
{ {
var account = await _db.Accounts.FindAsync(accountId) await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts.FindAsync(accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? throw new InvalidOperationException($"Account {accountId} not found.");
account.CarryoverBalance = carryoverBalance; account.CarryoverBalance = carryoverBalance;
await _db.SaveChangesAsync(); 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) public async Task DeleteAccountAsync(int accountId)
{ {
var account = await _db.Accounts await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
.Include(a => a.Entries) .Include(a => a.Entries)
.FirstOrDefaultAsync(a => a.Id == accountId) .FirstOrDefaultAsync(a => a.Id == accountId)
?? throw new InvalidOperationException($"Account {accountId} not found."); ?? throw new InvalidOperationException($"Account {accountId} not found.");
var entryIds = account.Entries.Select(e => e.Id).ToList(); var entryIds = account.Entries.Select(e => e.Id).ToList();
var transferLinks = await _db.TransferLinks var transferLinks = await db.TransferLinks
.Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId)) .Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId))
.ToListAsync(); .ToListAsync();
foreach (var link in transferLinks) foreach (var link in transferLinks)
{ {
var otherEntryId = entryIds.Contains(link.SourceEntryId) ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = entryIds.Contains(link.SourceEntryId) ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId); var otherEntry = await db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) if (otherEntry != null)
_db.Entries.Remove(otherEntry); db.Entries.Remove(otherEntry);
} }
_db.TransferLinks.RemoveRange(transferLinks); db.TransferLinks.RemoveRange(transferLinks);
_db.Accounts.Remove(account); db.Accounts.Remove(account);
await _db.SaveChangesAsync(); 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);
}
} }

View File

@@ -0,0 +1,146 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Duempelkas.App.Services;
using Microsoft.Data.Sqlite;
namespace Duempelkas.Infrastructure.Services;
public class BackupService : IBackupService
{
private static readonly string DbPath = Path.Combine(
Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory,
"duempelkas.db");
public async Task<string> CreateBackupAsync()
{
if (!File.Exists(DbPath))
{
return "Keine Datenbankdatei gefunden.";
}
var suggestedFileName = $"duempelkas-backup-{DateTime.Now:yyMMdd-HHmm}.bak";
var backupPath = await ShowSaveDialogAsync(suggestedFileName);
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Backup wurde abgebrochen.";
}
backupPath = Path.ChangeExtension(backupPath, ".bak");
try
{
File.Copy(DbPath, backupPath, overwrite: true);
return $"Backup gespeichert: {backupPath}";
}
catch (Exception ex)
{
return $"Backup fehlgeschlagen: {ex.Message}";
}
}
public async Task<string> RestoreBackupAsync()
{
var backupPath = await ShowOpenDialogAsync();
if (string.IsNullOrWhiteSpace(backupPath))
{
return "Wiederherstellung wurde abgebrochen.";
}
if (!File.Exists(backupPath))
{
return "Die gewählte Backup-Datei wurde nicht gefunden.";
}
try
{
// Ensure no pooled connection keeps stale handles to the previous DB file.
SqliteConnection.ClearAllPools();
var targetDirectory = Path.GetDirectoryName(DbPath);
if (!string.IsNullOrWhiteSpace(targetDirectory))
{
Directory.CreateDirectory(targetDirectory);
}
File.Copy(backupPath, DbPath, overwrite: true);
SqliteConnection.ClearAllPools();
return "Wiederherstellung erfolgreich. Die Datenbank wurde neu geladen.";
}
catch (Exception ex)
{
return $"Wiederherstellung fehlgeschlagen: {ex.Message}";
}
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
{
return Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
var documentsPath = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
return Path.Combine(documentsPath, suggestedFileName);
}
var script = $@"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.SaveFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.DefaultExt = 'bak'
$dialog.AddExtension = $true
$dialog.OverwritePrompt = $true
$dialog.FileName = '{suggestedFileName.Replace("'", "''")}'
$dialog.Title = 'Backup speichern'
if ($dialog.ShowDialog() -eq 'OK') {{ $dialog.FileName }} else {{ '' }}
";
return RunPowerShellDialog(script);
});
}
private static Task<string?> ShowOpenDialogAsync()
{
return Task.Run(() =>
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return null;
}
var script = @"
Add-Type -AssemblyName System.Windows.Forms
$dialog = New-Object System.Windows.Forms.OpenFileDialog
$dialog.Filter = 'Backup-Dateien (*.bak)|*.bak'
$dialog.Multiselect = $false
$dialog.Title = 'Backup auswählen'
if ($dialog.ShowDialog() -eq 'OK') { $dialog.FileName } else { '' }
";
return RunPowerShellDialog(script);
});
}
private static string? RunPowerShellDialog(string script)
{
var psi = new ProcessStartInfo
{
FileName = "powershell",
Arguments = $"-NoProfile -Command \"{script.Replace("\"", "\\\"") }\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(psi);
if (process == null)
{
return null;
}
var result = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
return string.IsNullOrWhiteSpace(result) ? null : result;
}
}

View File

@@ -8,32 +8,46 @@ namespace Duempelkas.Infrastructure.Services;
public class BalanceQueryService : IBalanceQueryService public class BalanceQueryService : IBalanceQueryService
{ {
private readonly FinanceDbContext _db; private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public BalanceQueryService(FinanceDbContext db) => _db = db; public BalanceQueryService(IDbContextFactory<FinanceDbContext> dbFactory) => _dbFactory = dbFactory;
public async Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId) public async Task<AccountBalanceDto> GetAccountBalanceAsync(int accountId, int? year = null)
{ {
var account = await _db.Accounts await using var db = await _dbFactory.CreateDbContextAsync();
var account = await db.Accounts
.Include(a => a.Entries) .Include(a => a.Entries)
.FirstOrDefaultAsync(a => a.Id == accountId) .FirstOrDefaultAsync(a => a.Id == accountId)
?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden."); ?? 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 activeEntries = account.Entries.Where(e => !e.IsDeleted).ToList();
var totalIncome = activeEntries.Where(e => e.Type == EntryType.Income).Sum(e => e.Amount); 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 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 selectedYearIncome = activeEntries
var totalBalance = account.CarryoverBalance + totalIncome - totalExpense; .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( return new AccountBalanceDto(
account.CarryoverBalance, year.HasValue ? selectedYearCarryover : account.CarryoverBalance,
totalIncome, totalIncome,
totalExpense, totalExpense,
currentYearIncome, selectedYearIncome,
currentYearExpense, selectedYearExpense,
totalBalance); totalBalance);
} }
} }

View File

@@ -9,16 +9,24 @@ namespace Duempelkas.Infrastructure.Services;
public class EntryService : IEntryService public class EntryService : IEntryService
{ {
private readonly FinanceDbContext _db; private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
public EntryService(FinanceDbContext db) => _db = db; 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 query = _db.Entries.Where(e => e.AccountId == accountId); var year = currentYearOnly ? DateTime.Now.Year : (int?)null;
return GetEntriesAsync(accountId, year);
}
if (currentYearOnly) public async Task<List<EntryDto>> GetEntriesAsync(int accountId, int? year)
query = query.Where(e => e.Date.Year == DateTime.Now.Year); {
await using var db = await _dbFactory.CreateDbContextAsync();
var query = db.Entries.Where(e => e.AccountId == accountId);
if (year.HasValue)
query = query.Where(e => e.Date.Year == year.Value);
var entries = await query var entries = await query
.OrderBy(e => e.Date) .OrderBy(e => e.Date)
@@ -27,7 +35,7 @@ public class EntryService : IEntryService
var entryIds = entries.Select(e => e.Id).ToList(); var entryIds = entries.Select(e => e.Id).ToList();
var transferLinks = await _db.TransferLinks var transferLinks = await db.TransferLinks
.Include(tl => tl.SourceEntry).ThenInclude(e => e.Account) .Include(tl => tl.SourceEntry).ThenInclude(e => e.Account)
.Include(tl => tl.TargetEntry).ThenInclude(e => e.Account) .Include(tl => tl.TargetEntry).ThenInclude(e => e.Account)
.Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId)) .Where(tl => entryIds.Contains(tl.SourceEntryId) || entryIds.Contains(tl.TargetEntryId))
@@ -57,9 +65,34 @@ public class EntryService : IEntryService
}).ToList(); }).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) public async Task<EntryDto> CreateEntryAsync(int accountId, EntryType type, DateTime date, string title, decimal amount)
{ {
var displayId = await GenerateDisplayIdAsync(accountId, date.Year); await using var db = await _dbFactory.CreateDbContextAsync();
var displayId = await GenerateDisplayIdAsync(db, date.Year);
var entry = new Entry var entry = new Entry
{ {
@@ -70,8 +103,8 @@ public class EntryService : IEntryService
Title = title, Title = title,
Amount = amount Amount = amount
}; };
_db.Entries.Add(entry); db.Entries.Add(entry);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null); return new EntryDto(entry.Id, entry.AccountId, entry.DisplayId, entry.Type, entry.Date, entry.Title, entry.Amount, false, false, null, null, null);
} }
@@ -81,37 +114,36 @@ public class EntryService : IEntryService
if (sourceAccountId == targetAccountId) if (sourceAccountId == targetAccountId)
throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich."); throw new InvalidOperationException("Umbuchung innerhalb desselben Kontos ist nicht möglich.");
await using var transaction = await _db.Database.BeginTransactionAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
var sourceDisplayId = await GenerateDisplayIdAsync(sourceAccountId, date.Year); var transferDisplayId = await GenerateDisplayIdAsync(db, date.Year);
var sourceEntry = new Entry var sourceEntry = new Entry
{ {
AccountId = sourceAccountId, AccountId = sourceAccountId,
DisplayId = sourceDisplayId, DisplayId = transferDisplayId,
Type = EntryType.Expense, Type = EntryType.Expense,
Date = date, Date = date,
Title = title, Title = title,
Amount = amount Amount = amount
}; };
_db.Entries.Add(sourceEntry); db.Entries.Add(sourceEntry);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
var targetDisplayId = await GenerateDisplayIdAsync(targetAccountId, date.Year);
var targetEntry = new Entry var targetEntry = new Entry
{ {
AccountId = targetAccountId, AccountId = targetAccountId,
DisplayId = targetDisplayId, DisplayId = transferDisplayId,
Type = EntryType.Income, Type = EntryType.Income,
Date = date, Date = date,
Title = title, Title = title,
Amount = amount Amount = amount
}; };
_db.Entries.Add(targetEntry); db.Entries.Add(targetEntry);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
var link = new TransferLink var link = new TransferLink
{ {
@@ -119,62 +151,68 @@ public class EntryService : IEntryService
TargetEntryId = targetEntry.Id, TargetEntryId = targetEntry.Id,
Note = $"Umbuchung: {title}" Note = $"Umbuchung: {title}"
}; };
_db.TransferLinks.Add(link); db.TransferLinks.Add(link);
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
sourceEntry.TransferLinkId = link.Id; sourceEntry.TransferLinkId = link.Id;
targetEntry.TransferLinkId = link.Id; targetEntry.TransferLinkId = link.Id;
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
await transaction.CommitAsync(); await transaction.CommitAsync();
} }
public async Task DeleteEntryAsync(int entryId) public async Task DeleteEntryAsync(int entryId)
{ {
var entry = await _db.Entries.FindAsync(entryId) await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
entry.IsDeleted = true; entry.IsDeleted = true;
var link = await _db.TransferLinks var link = await db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
if (link != null) if (link != null)
{ {
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId); var otherEntry = await db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) otherEntry.IsDeleted = true; if (otherEntry != null) otherEntry.IsDeleted = true;
} }
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task RestoreEntryAsync(int entryId) public async Task RestoreEntryAsync(int entryId)
{ {
var entry = await _db.Entries.FindAsync(entryId) await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
entry.IsDeleted = false; entry.IsDeleted = false;
var link = await _db.TransferLinks var link = await db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
if (link != null) if (link != null)
{ {
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId); var otherEntry = await db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) otherEntry.IsDeleted = false; if (otherEntry != null) otherEntry.IsDeleted = false;
} }
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount) public async Task UpdateEntryAsync(int entryId, DateTime date, string title, decimal amount)
{ {
var entry = await _db.Entries.FindAsync(entryId) await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
var link = await _db.TransferLinks var link = await db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId); .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId);
entry.Date = date; entry.Date = date;
@@ -184,7 +222,7 @@ public class EntryService : IEntryService
if (link != null) if (link != null)
{ {
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId); var otherEntry = await db.Entries.FindAsync(otherEntryId);
if (otherEntry != null) if (otherEntry != null)
{ {
otherEntry.Date = date; otherEntry.Date = date;
@@ -193,20 +231,22 @@ public class EntryService : IEntryService
} }
} }
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount) public async Task UpdateTransferAsync(int entryId, int newLinkedAccountId, DateTime date, string title, decimal amount)
{ {
var entry = await _db.Entries.FindAsync(entryId) await using var db = await _dbFactory.CreateDbContextAsync();
var entry = await db.Entries.FindAsync(entryId)
?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden."); ?? throw new InvalidOperationException($"Eintrag {entryId} nicht gefunden.");
var link = await _db.TransferLinks var link = await db.TransferLinks
.FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId) .FirstOrDefaultAsync(tl => tl.SourceEntryId == entryId || tl.TargetEntryId == entryId)
?? throw new InvalidOperationException($"Kein Transfer-Link für Eintrag {entryId} gefunden."); ?? throw new InvalidOperationException($"Kein Transfer-Link für Eintrag {entryId} gefunden.");
var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId; var otherEntryId = link.SourceEntryId == entryId ? link.TargetEntryId : link.SourceEntryId;
var otherEntry = await _db.Entries.FindAsync(otherEntryId) var otherEntry = await db.Entries.FindAsync(otherEntryId)
?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden."); ?? throw new InvalidOperationException($"Gegeneintrag {otherEntryId} nicht gefunden.");
// Update date, title, amount on both sides // Update date, title, amount on both sides
@@ -221,16 +261,15 @@ public class EntryService : IEntryService
if (otherEntry.AccountId != newLinkedAccountId) if (otherEntry.AccountId != newLinkedAccountId)
{ {
otherEntry.AccountId = newLinkedAccountId; otherEntry.AccountId = newLinkedAccountId;
otherEntry.DisplayId = await GenerateDisplayIdAsync(newLinkedAccountId, date.Year);
} }
await _db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
private async Task<string> GenerateDisplayIdAsync(int accountId, int year) private static async Task<string> GenerateDisplayIdAsync(FinanceDbContext db, int year)
{ {
var prefix = $"{year}-"; var prefix = $"{year}-";
var maxDisplayId = await _db.Entries var maxDisplayId = await db.Entries
.Where(e => e.DisplayId.StartsWith(prefix)) .Where(e => e.DisplayId.StartsWith(prefix))
.Select(e => e.DisplayId) .Select(e => e.DisplayId)
.MaxAsync(id => (string?)id); .MaxAsync(id => (string?)id);

View File

@@ -15,6 +15,36 @@ public class FileSaveService : IFileSaveService
return filePath; return filePath;
} }
public Task OpenFileAsync(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath))
{
return Task.CompletedTask;
}
return Task.Run(() =>
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
Process.Start(new ProcessStartInfo
{
FileName = filePath,
UseShellExecute = true
});
return;
}
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
Process.Start("open", filePath);
return;
}
Process.Start("xdg-open", filePath);
});
}
private static Task<string?> ShowSaveDialogAsync(string suggestedFileName) private static Task<string?> ShowSaveDialogAsync(string suggestedFileName)
{ {
return Task.Run(() => return Task.Run(() =>

View File

@@ -1,6 +1,7 @@
using System.Globalization; using System.Globalization;
using Duempelkas.App.Services; using Duempelkas.App.Services;
using Duempelkas.App.Services.Models; using Duempelkas.App.Services.Models;
using Duempelkas.Domain.Entities;
using Duempelkas.Domain.Enums; using Duempelkas.Domain.Enums;
using Duempelkas.Infrastructure.Persistence; using Duempelkas.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -12,31 +13,39 @@ namespace Duempelkas.Infrastructure.Services;
public class PdfStatementService : IPdfStatementService public class PdfStatementService : IPdfStatementService
{ {
private readonly FinanceDbContext _db; private readonly IDbContextFactory<FinanceDbContext> _dbFactory;
private readonly IEntryService _entryService; private readonly IEntryService _entryService;
private readonly IBalanceQueryService _balanceQueryService; private readonly IBalanceQueryService _balanceQueryService;
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private static readonly CultureInfo DeLocale = new("de-DE"); private static readonly CultureInfo DeLocale = new("de-DE");
public PdfStatementService(FinanceDbContext db, IEntryService entryService, IBalanceQueryService balanceQueryService, ISettingsService settingsService) public PdfStatementService(IDbContextFactory<FinanceDbContext> dbFactory, IEntryService entryService, IBalanceQueryService balanceQueryService, ISettingsService settingsService)
{ {
_db = db; _dbFactory = dbFactory;
_entryService = entryService; _entryService = entryService;
_balanceQueryService = balanceQueryService; _balanceQueryService = balanceQueryService;
_settingsService = settingsService; _settingsService = settingsService;
} }
public async Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly) public Task<byte[]> GenerateStatementAsync(int accountId, bool currentYearOnly)
{ {
var account = await _db.Accounts.FindAsync(accountId) 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."); ?? throw new InvalidOperationException($"Konto {accountId} nicht gefunden.");
var entries = await _entryService.GetEntriesAsync(accountId, currentYearOnly); var entries = await _entryService.GetEntriesAsync(accountId, year);
var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId); var balance = await _balanceQueryService.GetAccountBalanceAsync(accountId, year);
var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein"; var clubName = await _settingsService.GetClubNameAsync() ?? "Mein Verein";
var title = currentYearOnly var title = year.HasValue
? $"{account.Name} Auszug {DateTime.Now.Year}" ? $"{account.Name} Auszug {year.Value}"
: $"{account.Name} Gesamtauszug"; : $"{account.Name} Gesamtauszug";
var document = Document.Create(container => var document = Document.Create(container =>
@@ -58,12 +67,6 @@ public class PdfStatementService : IPdfStatementService
page.Content().PaddingTop(15).Column(col => 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 => col.Item().Table(table =>
{ {
table.ColumnsDefinition(columns => table.ColumnsDefinition(columns =>
@@ -111,9 +114,16 @@ public class PdfStatementService : IPdfStatementService
col.Item().PaddingTop(20).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); col.Item().PaddingTop(20).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
col.Item().PaddingTop(10).Column(summaryCol => col.Item().PaddingTop(10).Column(summaryCol =>
{ {
SummaryRow(summaryCol, "Übertrag:", balance.CarryoverBalance); var incomeValue = year.HasValue ? balance.CurrentYearIncome : balance.TotalIncome;
SummaryRow(summaryCol, "Einnahmen gesamt:", balance.TotalIncome); var expenseValue = year.HasValue ? balance.CurrentYearExpense : balance.TotalExpense;
SummaryRow(summaryCol, "Ausgaben gesamt:", -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 => summaryCol.Item().PaddingTop(5).Row(row =>
{ {
row.RelativeItem().Text("Saldo:").Bold().FontSize(12); row.RelativeItem().Text("Saldo:").Bold().FontSize(12);
@@ -136,6 +146,257 @@ public class PdfStatementService : IPdfStatementService
return document.GeneratePdf(); 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) private static void SummaryRow(ColumnDescriptor col, string label, decimal value)
{ {
col.Item().PaddingVertical(2).Row(row => col.Item().PaddingVertical(2).Row(row =>
@@ -150,4 +411,65 @@ public class PdfStatementService : IPdfStatementService
{ {
return amount.ToString("N2", DeLocale) + " €"; 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);
} }

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

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

View File

@@ -13,19 +13,22 @@ public class TransferServiceTests : IDisposable
private readonly FinanceDbContext _db; private readonly FinanceDbContext _db;
private readonly EntryService _entryService; private readonly EntryService _entryService;
private readonly BalanceQueryService _balanceQueryService; private readonly BalanceQueryService _balanceQueryService;
private readonly string _connectionString = $"Data Source=duempelkas-transfer-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
public TransferServiceTests() public TransferServiceTests()
{ {
var options = new DbContextOptionsBuilder<FinanceDbContext>() var options = new DbContextOptionsBuilder<FinanceDbContext>()
.UseSqlite("Data Source=:memory:") .UseSqlite(_connectionString)
.Options; .Options;
_db = new FinanceDbContext(options); _db = new FinanceDbContext(options);
_db.Database.OpenConnection(); _db.Database.OpenConnection();
_db.Database.EnsureCreated(); _db.Database.EnsureCreated();
_entryService = new EntryService(_db); var dbFactory = new TestDbContextFactory(options);
_balanceQueryService = new BalanceQueryService(_db);
_entryService = new EntryService(dbFactory);
_balanceQueryService = new BalanceQueryService(dbFactory);
} }
[Fact] [Fact]
@@ -50,7 +53,8 @@ public class TransferServiceTests : IDisposable
var targetEntry = entries.Single(e => e.AccountId == accountB.Id); var targetEntry = entries.Single(e => e.AccountId == accountB.Id);
targetEntry.Type.Should().Be(EntryType.Income); targetEntry.Type.Should().Be(EntryType.Income);
targetEntry.Amount.Should().Be(100.00m); 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(); var links = await _db.TransferLinks.ToListAsync();
links.Should().HaveCount(1); links.Should().HaveCount(1);
@@ -77,6 +81,7 @@ public class TransferServiceTests : IDisposable
await _entryService.DeleteEntryAsync(sourceEntry.Id); await _entryService.DeleteEntryAsync(sourceEntry.Id);
_db.ChangeTracker.Clear();
var entries = await _db.Entries.ToListAsync(); var entries = await _db.Entries.ToListAsync();
entries.Should().HaveCount(2); entries.Should().HaveCount(2);
entries.Should().AllSatisfy(e => e.IsDeleted.Should().BeTrue()); entries.Should().AllSatisfy(e => e.IsDeleted.Should().BeTrue());
@@ -102,9 +107,47 @@ public class TransferServiceTests : IDisposable
await act.Should().ThrowAsync<InvalidOperationException>(); 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() public void Dispose()
{ {
_db.Database.CloseConnection(); _db.Database.CloseConnection();
_db.Dispose(); _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));
}
} }

View File

@@ -13,19 +13,22 @@ public class BalanceCalculationTests : IDisposable
private readonly FinanceDbContext _db; private readonly FinanceDbContext _db;
private readonly BalanceQueryService _balanceQueryService; private readonly BalanceQueryService _balanceQueryService;
private readonly EntryService _entryService; private readonly EntryService _entryService;
private readonly string _connectionString = $"Data Source=duempelkas-balance-tests-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
public BalanceCalculationTests() public BalanceCalculationTests()
{ {
var options = new DbContextOptionsBuilder<FinanceDbContext>() var options = new DbContextOptionsBuilder<FinanceDbContext>()
.UseSqlite("Data Source=:memory:") .UseSqlite(_connectionString)
.Options; .Options;
_db = new FinanceDbContext(options); _db = new FinanceDbContext(options);
_db.Database.OpenConnection(); _db.Database.OpenConnection();
_db.Database.EnsureCreated(); _db.Database.EnsureCreated();
_balanceQueryService = new BalanceQueryService(_db); var dbFactory = new TestDbContextFactory(options);
_entryService = new EntryService(_db);
_balanceQueryService = new BalanceQueryService(dbFactory);
_entryService = new EntryService(dbFactory);
} }
[Fact] [Fact]
@@ -106,4 +109,19 @@ public class BalanceCalculationTests : IDisposable
_db.Database.CloseConnection(); _db.Database.CloseConnection();
_db.Dispose(); _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));
}
} }