Compare commits

...

5 commits

Author SHA1 Message Date
1c5227ab9a PR U-12: report primitives + print CSS
Extract ReportHeader, ReportDivider, ReportSectionHeader, and
ReportFooter under Components/Pages/Reports/ and adopt them across
BalanceReport, BalanceSheetReport, ProfitLossReport, and DetailReport.
Row shapes are unchanged; the primitives just fold the divider hr
rows (30+ occurrences), section headers (8), and header/footer (4
each). ProfitLossReport / DetailReport also drop leftover
`m.Class == 3/4` magic numbers for ClassIds. Print CSS gains
`table.report tr { page-break-inside: avoid; }` so section headers
don't land at the bottom of a page alone. Net -88 lines across the
four report razor files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 08:15:00 +02:00
acc8b14c06 Mark U-11 as won't-do after second failed attempt
Second attempt with all three suspected fixes applied
(materialised Items field, static readonly identity delegates,
Autocomplete downgraded from Both to List) still froze the UI on
Home. FluentCombobox in Fluent UI Blazor 4.11.8 is not viable
inside a per-row @foreach on a Blazor Server page independent of
parameter stability. Native <input list> stays. Record both attempts
and the fallback options for anyone who wants to try again later.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-07 08:09:02 +02:00
0432a2db75 Note U-11 revert in the UI plan
Record the reason U-11 was reverted (SignalR flood from per-row
FluentCombobox with fresh Items IEnumerable + inline delegate
parameters on every render) and what a retry needs (materialised
sortedBookingTexts field, static Func<string, string> for OptionText/
OptionValue, browser verification with a real pending-bookings count).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 17:42:34 +02:00
2522274928 Revert "PR U-11: FluentCombobox for booking-text autocomplete"
This reverts commit ceef2c162f.
2026-07-06 17:41:58 +02:00
ceef2c162f PR U-11: FluentCombobox for booking-text autocomplete
Replace the native <input list="texts"> + <datalist> pattern in
PendingBookings and TransactionSplitDialog with FluentCombobox +
Autocomplete=Both so the booking-text autocomplete matches the rest
of the Fluent-driven forms. Sort the suggestions once via a
sortedBookingTexts computed getter per codebehind rather than
sorting inside each row's foreach.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-06 17:01:30 +02:00
10 changed files with 215 additions and 237 deletions

View file

@ -1,4 +1,4 @@
@page "/BalanceReport" @page "/BalanceReport"
@if (balance is null) @if (balance is null)
{ {
@ -6,11 +6,8 @@
return; return;
} }
<h3>@header</h3> <ReportHeader Title="@header"/>
<a href="javascript:window.print()" class="hidePrint">Print</a>
<br />
<br />
<table class="report"> <table class="report">
<thead> <thead>
<tr> <tr>
@ -20,16 +17,11 @@
<th class="right">Passiven</th> <th class="right">Passiven</th>
</tr> </tr>
<tr> <tr>
<th colspan="4"><hr /></th> <th colspan="4"><hr/></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <ReportSectionHeader Title="Aktiven" Colspan="4"/>
<td colspan="4" class="header">Aktiven</td>
</tr>
<tr>
<td colspan="4"><hr /></td>
</tr>
@foreach (var account in balance.ActivaAccountList.OrderBy(m => m.Number)) @foreach (var account in balance.ActivaAccountList.OrderBy(m => m.Number))
{ {
@if (account.BalanceCHF == 0) { continue; } @if (account.BalanceCHF == 0) { continue; }
@ -41,23 +33,14 @@
<td></td> <td></td>
</tr> </tr>
} }
<tr> <ReportDivider Colspan="4"/>
<td colspan="4"><hr /></td>
</tr>
<tr> <tr>
<td colspan="2" class="total">Total Aktiven</td> <td colspan="2" class="total">Total Aktiven</td>
<td class="right total">CHF @balance.TotalActivaCHF.ToFormattedString()</td> <td class="right total">CHF @balance.TotalActivaCHF.ToFormattedString()</td>
<td></td> <td></td>
</tr> </tr>
<tr> <ReportDivider Colspan="4"/>
<td colspan="4"><hr /></td> <ReportSectionHeader Title="Passiven" Colspan="4"/>
</tr>
<tr>
<td colspan="4" class="header">Passiven</td>
</tr>
<tr>
<td colspan="4"><hr /></td>
</tr>
@foreach (var account in balance.PassivaAccountList.OrderBy(m => m.Number)) @foreach (var account in balance.PassivaAccountList.OrderBy(m => m.Number))
{ {
@if (account.BalanceCHF == 0) { continue; } @if (account.BalanceCHF == 0) { continue; }
@ -69,17 +52,13 @@
<td class="right">@Math.Abs(account.BalanceCHF).ToFormattedString()</td> <td class="right">@Math.Abs(account.BalanceCHF).ToFormattedString()</td>
</tr> </tr>
} }
<tr> <ReportDivider Colspan="4"/>
<td colspan="4"><hr /></td>
</tr>
<tr> <tr>
<td colspan="2" class="total">Total Passiven</td> <td colspan="2" class="total">Total Passiven</td>
<td></td> <td></td>
<td class="right total">@balance.TotalPassivaCHF.ToFormattedString()</td> <td class="right total">@balance.TotalPassivaCHF.ToFormattedString()</td>
</tr> </tr>
<tr> <ReportDivider Colspan="4"/>
<td colspan="4"><hr /></td>
</tr>
<tr> <tr>
<td colspan="2" class="total">Total</td> <td colspan="2" class="total">Total</td>
<td class="right">@balance.TotalActivaCHF.ToFormattedString()</td> <td class="right">@balance.TotalActivaCHF.ToFormattedString()</td>
@ -95,11 +74,6 @@
<td class="right total sum">CHF @balance.TotalActivaCHF.ToFormattedString()</td> <td class="right total sum">CHF @balance.TotalActivaCHF.ToFormattedString()</td>
<td class="right total sum">CHF @((balance.TotalPassivaCHF + win).ToFormattedString())</td> <td class="right total sum">CHF @((balance.TotalPassivaCHF + win).ToFormattedString())</td>
</tr> </tr>
<tr> <ReportFooter Footer="@footer" Colspan="4"/>
<td colspan="4" style="height: 30px">&nbsp;</td>
</tr>
<tr>
<td colspan="4" class="footer">@footer</td>
</tr>
</tbody> </tbody>
</table> </table>

View file

@ -1,4 +1,4 @@
@page "/BalanceSheetReport" @page "/BalanceSheetReport"
@if (balanceSheet is null) @if (balanceSheet is null)
{ {
@ -6,18 +6,11 @@
return; return;
} }
<h3>@header</h3> <ReportHeader Title="@header"/>
<a href="javascript:window.print()" class="hidePrint">Print</a>
<br />
<br />
<table class="report"> <table class="report">
<tbody> <tbody>
<tr> <ReportSectionHeader Title="Einnahmen" Colspan="3"/>
<td colspan="3" class="header">Einnahmen</td>
</tr>
<tr>
<td colspan="3"><hr /></td>
</tr>
@foreach (var account in balanceSheet.ActivaAccountList.OrderBy(m => m.Number)) @foreach (var account in balanceSheet.ActivaAccountList.OrderBy(m => m.Number))
{ {
@if (account.StartBalanceCHF == 0) { continue; } @if (account.StartBalanceCHF == 0) { continue; }
@ -38,15 +31,8 @@
<td class="right">@Math.Abs(account.BalanceCHF).ToFormattedString()</td> <td class="right">@Math.Abs(account.BalanceCHF).ToFormattedString()</td>
</tr> </tr>
} }
<tr> <ReportDivider Colspan="3"/>
<td colspan="3"><hr /></td> <ReportSectionHeader Title="Ausgaben" Colspan="3"/>
</tr>
<tr>
<td colspan="3" class="header">Ausgaben</td>
</tr>
<tr>
<td colspan="3"><hr /></td>
</tr>
@foreach (var account in balanceSheet.ExpensesAccountList.OrderBy(m => m.Number)) @foreach (var account in balanceSheet.ExpensesAccountList.OrderBy(m => m.Number))
{ {
@if (account.BalanceCHF == 0) { continue; } @if (account.BalanceCHF == 0) { continue; }
@ -57,9 +43,7 @@
<td class="right">@account.BalanceCHF.ToFormattedString()</td> <td class="right">@account.BalanceCHF.ToFormattedString()</td>
</tr> </tr>
} }
<tr> <ReportDivider Colspan="3"/>
<td colspan="3"><hr /></td>
</tr>
<tr> <tr>
<td colspan="2">Total Einnahmen</td> <td colspan="2">Total Einnahmen</td>
<td class="right">@balanceSheet.ProfitCHF.ToFormattedString()</td> <td class="right">@balanceSheet.ProfitCHF.ToFormattedString()</td>
@ -72,11 +56,6 @@
<td colspan="2" class="total">Übertrag @(balanceSheet.Year + 1)</td> <td colspan="2" class="total">Übertrag @(balanceSheet.Year + 1)</td>
<td class="right total sum">CHF @win.ToFormattedString()</td> <td class="right total sum">CHF @win.ToFormattedString()</td>
</tr> </tr>
<tr> <ReportFooter Footer="@footer"/>
<td colspan="4" style="height: 30px">&nbsp;</td>
</tr>
<tr>
<td colspan="4" class="footer">@footer</td>
</tr>
</tbody> </tbody>
</table> </table>

View file

@ -1,4 +1,4 @@
@page "/DetailReport" @page "/DetailReport"
@if (accounts is null || transactions is null) @if (accounts is null || transactions is null)
{ {
@ -6,11 +6,8 @@
return; return;
} }
<h3>@header</h3> <ReportHeader Title="@header"/>
<a href="javascript:window.print()" class="hidePrint">Print</a>
<br />
<br />
<table class="report"> <table class="report">
<thead> <thead>
<tr> <tr>
@ -19,19 +16,14 @@
<th class="right">Betrag</th> <th class="right">Betrag</th>
</tr> </tr>
<tr> <tr>
<th colspan="3"><hr /></th> <th colspan="3"><hr/></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <ReportSectionHeader Title="Einkünfte" Colspan="3"/>
<td colspan="3" class="header">Einkünfte</td> @foreach (var account in accounts.Where(m => m.Class == ClassIds.Income).OrderBy(m => m.Number))
</tr> {
<tr> @if (account.Balance == 0) { continue; }
<td colspan="3"><hr /></td>
</tr>
@foreach(var account in accounts.Where(m => m.Class == 3).OrderBy(m => m.Number))
{
@if (account.Balance == 0) {continue;}
<tr> <tr>
<td class="header">@account.Number</td> <td class="header">@account.Number</td>
@ -39,7 +31,7 @@
<td class="header right">@Math.Abs(account.Balance).ToFormattedString()</td> <td class="header right">@Math.Abs(account.Balance).ToFormattedString()</td>
</tr> </tr>
@foreach(var trx in transactions.Where(t => t.TargetAccountId == account.Id || t.OriginAccountId == account.Id).OrderBy(t => t.ValueDate)) @foreach (var trx in transactions.Where(t => t.TargetAccountId == account.Id || t.OriginAccountId == account.Id).OrderBy(t => t.ValueDate))
{ {
<tr> <tr>
<td>@trx.ValueDate.ToString("dd.MM.yyyy")</td> <td>@trx.ValueDate.ToString("dd.MM.yyyy")</td>
@ -51,31 +43,23 @@
} }
else else
{ {
<span>-</span>@trx.Value.ToFormattedString(); <span>-</span>
@trx.Value.ToFormattedString();
} }
</td> </td>
</tr> </tr>
} }
<tr> <ReportDivider Colspan="3"/>
<td colspan="3"><hr /></td> }
</tr>
}
<tr> <tr>
<td colspan="2" class="total right">Total Einkünfte</td> <td colspan="2" class="total right">Total Einkünfte</td>
<td class="right total">@profit.ToFormattedString()</td> <td class="right total">@profit.ToFormattedString()</td>
</tr> </tr>
<tr> <ReportDivider Colspan="3"/>
<td colspan="3"><hr /></td> <ReportSectionHeader Title="Ausgaben" Colspan="3"/>
</tr> @foreach (var account in accounts.Where(m => m.Class == ClassIds.Expenses).OrderBy(m => m.Number))
<tr> {
<td colspan="3" class="header">Ausgaben</td> @if (account.Balance == 0) { continue; }
</tr>
<tr>
<td colspan="3"><hr /></td>
</tr>
@foreach(var account in accounts.Where(m => m.Class == 4).OrderBy(m => m.Number))
{
@if (account.Balance == 0) {continue;}
<tr> <tr>
<td class="header">@account.Number</td> <td class="header">@account.Number</td>
@ -83,7 +67,7 @@
<td class="header right">@account.Balance.ToFormattedString()</td> <td class="header right">@account.Balance.ToFormattedString()</td>
</tr> </tr>
@foreach(var trx in transactions.Where(t => t.TargetAccountId == account.Id || t.OriginAccountId == account.Id).OrderBy(t => t.ValueDate)) @foreach (var trx in transactions.Where(t => t.TargetAccountId == account.Id || t.OriginAccountId == account.Id).OrderBy(t => t.ValueDate))
{ {
<tr> <tr>
<td>@trx.ValueDate.ToString("dd.MM.yyyy")</td> <td>@trx.ValueDate.ToString("dd.MM.yyyy")</td>
@ -95,24 +79,18 @@
} }
else else
{ {
<span>-</span>@trx.Value.ToFormattedString(); <span>-</span>
@trx.Value.ToFormattedString();
} }
</td> </td>
</tr> </tr>
} }
<tr> <ReportDivider Colspan="3"/>
<td colspan="3"><hr /></td> }
</tr>
}
<tr> <tr>
<td colspan="2" class="total right">Total Ausgaben</td> <td colspan="2" class="total right">Total Ausgaben</td>
<td class="right total">@loss.ToFormattedString()</td> <td class="right total">@loss.ToFormattedString()</td>
</tr> </tr>
<tr> <ReportFooter Footer="@footer"/>
<td colspan="4" style="height: 30px">&nbsp;</td>
</tr>
<tr>
<td colspan="4" class="footer">@footer</td>
</tr>
</tbody> </tbody>
</table> </table>

View file

@ -1,4 +1,4 @@
@page "/ProfitLossReport" @page "/ProfitLossReport"
@if (accounts is null) @if (accounts is null)
{ {
@ -6,11 +6,8 @@
return; return;
} }
<h3>@header</h3> <ReportHeader Title="@header"/>
<a href="javascript:window.print()" class="hidePrint">Print</a>
<br />
<br />
<table class="report"> <table class="report">
<thead> <thead>
<tr> <tr>
@ -20,19 +17,14 @@
<th class="right">Ertrag</th> <th class="right">Ertrag</th>
</tr> </tr>
<tr> <tr>
<th colspan="4"><hr /></th> <th colspan="4"><hr/></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <ReportSectionHeader Title="Einkünfte" Colspan="4"/>
<td colspan="4" class="header">Einkünfte</td> @foreach (var account in accounts.Where(m => m.Class == ClassIds.Income).OrderBy(m => m.Number))
</tr> {
<tr> @if (account.Balance == 0) { continue; }
<td colspan="4"><hr /></td>
</tr>
@foreach(var account in accounts.Where(m => m.Class == 3).OrderBy(m => m.Number))
{
@if (account.Balance == 0) {continue;}
<tr> <tr>
<td>@account.Number</td> <td>@account.Number</td>
@ -40,27 +32,18 @@
<td></td> <td></td>
<td class="right">@Math.Abs(account.Balance).ToFormattedString()</td> <td class="right">@Math.Abs(account.Balance).ToFormattedString()</td>
</tr> </tr>
} }
<tr> <ReportDivider Colspan="4"/>
<td colspan="4"><hr /></td>
</tr>
<tr> <tr>
<td colspan="2" class="total">Total Einkünfte</td> <td colspan="2" class="total">Total Einkünfte</td>
<td></td> <td></td>
<td class="right total">@profit.ToFormattedString()</td> <td class="right total">@profit.ToFormattedString()</td>
</tr> </tr>
<tr> <ReportDivider Colspan="4"/>
<td colspan="4"><hr /></td> <ReportSectionHeader Title="Ausgaben" Colspan="4"/>
</tr> @foreach (var account in accounts.Where(m => m.Class == ClassIds.Expenses).OrderBy(m => m.Number))
<tr> {
<td colspan="4" class="header">Ausgaben</td> @if (account.Balance == 0) { continue; }
</tr>
<tr>
<td colspan="4"><hr /></td>
</tr>
@foreach(var account in accounts.Where(m => m.Class == 4).OrderBy(m => m.Number))
{
@if (account.Balance == 0) {continue;}
<tr> <tr>
<td>@account.Number</td> <td>@account.Number</td>
@ -68,18 +51,14 @@
<td class="right">@Math.Abs(account.Balance).ToFormattedString()</td> <td class="right">@Math.Abs(account.Balance).ToFormattedString()</td>
<td></td> <td></td>
</tr> </tr>
} }
<tr> <ReportDivider Colspan="4"/>
<td colspan="4"><hr /></td>
</tr>
<tr> <tr>
<td colspan="2" class="total">Total Ausgaben</td> <td colspan="2" class="total">Total Ausgaben</td>
<td class="right total">@loss.ToFormattedString()</td> <td class="right total">@loss.ToFormattedString()</td>
<td></td> <td></td>
</tr> </tr>
<tr> <ReportDivider Colspan="4"/>
<td colspan="4"><hr /></td>
</tr>
<tr> <tr>
<td colspan="2" class="total"></td> <td colspan="2" class="total"></td>
<td class="right total">@loss.ToFormattedString()</td> <td class="right total">@loss.ToFormattedString()</td>
@ -95,11 +74,6 @@
<td class="right total sum">@profit.ToFormattedString()</td> <td class="right total sum">@profit.ToFormattedString()</td>
<td class="right total sum">@profit.ToFormattedString()</td> <td class="right total sum">@profit.ToFormattedString()</td>
</tr> </tr>
<tr> <ReportFooter Footer="@footer" Colspan="4"/>
<td colspan="4" style="height: 30px">&nbsp;</td>
</tr>
<tr>
<td colspan="4" class="footer">@footer</td>
</tr>
</tbody> </tbody>
</table> </table>

View file

@ -0,0 +1,11 @@
@* Full-width table divider — <tr><td colspan="N"><hr/></td></tr>.
Callers pass their table's column count. *@
<tr>
<td colspan="@Colspan"><hr/></td>
</tr>
@code {
[Parameter, EditorRequired]
public int Colspan { get; set; }
}

View file

@ -0,0 +1,19 @@
@* Report tail — spacer row + italic footer line ("Stand: dd.MM.yyyy").
Note: the existing code used colspan="4" here even in reports whose
table only has 3 columns; preserved as-is so the visual is unchanged.
Callers can override via the Colspan parameter. *@
<tr>
<td colspan="@Colspan" style="height: 30px">&nbsp;</td>
</tr>
<tr>
<td colspan="@Colspan" class="footer">@Footer</td>
</tr>
@code {
[Parameter, EditorRequired]
public string Footer { get; set; } = "";
[Parameter]
public int Colspan { get; set; } = 4;
}

View file

@ -0,0 +1,14 @@
@* Shared banner used at the top of every printable report.
H3 + browser print trigger + spacer. The Print link is hidden in the
print stylesheet via .hidePrint. *@
<h3>@Title</h3>
<a href="javascript:window.print()" class="hidePrint">Print</a>
<br/>
<br/>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = "";
}

View file

@ -0,0 +1,18 @@
@* Section header + divider pair — "Aktiven", "Passiven", "Einkünfte",
"Ausgaben", "Einnahmen" etc. Wraps two <tr>s so the caller drops it
into <tbody> as a single element. *@
<tr>
<td colspan="@Colspan" class="header">@Title</td>
</tr>
<tr>
<td colspan="@Colspan"><hr/></td>
</tr>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = "";
[Parameter, EditorRequired]
public int Colspan { get; set; }
}

View file

@ -63,6 +63,13 @@ h1:focus {
.hidePrint { .hidePrint {
display: none !important; display: none !important;
} }
/* Keep report rows intact across page breaks. Without this a row could
be split by the printer between an amount and its label, or between a
section header and its first data row. */
table.report tr {
page-break-inside: avoid;
}
} }
table.report table.report

View file

@ -177,7 +177,11 @@ Split into two PRs on delivery because the header path touches ~15 pages.
### Phase 5 — Home page split ### Phase 5 — Home page split
- **U-10** Extract `<StatementImport />` and `<PendingBookings />` components. Home stacks them. - **U-10** Extract `<StatementImport />` and `<PendingBookings />` components. Home stacks them.
- **U-11** Replace the Home `<input list="texts">` + `<datalist>` with `FluentAutocomplete` so the autocomplete UI is uniform with the split dialog. - **U-11** ~~Replace the Home `<input list="texts">` + `<datalist>` with `FluentAutocomplete`.~~ **Won't do.** Two attempts, both broke the UI:
- Attempt 1 (`ceef2c1`, reverted in `2522274`): `FluentCombobox` with `Autocomplete=Both`, a computed-getter `Items` source (fresh `IEnumerable` per access), and inline `@(t => t)` delegates. Suspected root cause: per-render parameter churn triggering re-init inside every row's Combobox, flooding the SignalR circuit.
- Attempt 2 (branch `refactor/pr-u11b-retry`, never merged): all three suspected causes fixed — `sortedBookingTexts` materialised into a real field populated in `LoadDataAsync`, `OptionText`/`OptionValue` extracted to `static readonly Func<string, string>`, `Autocomplete` downgraded to `List`. Same UI lockup.
Conclusion: `FluentCombobox` in Fluent UI Blazor 4.11.8 is not usable inside a per-row `@foreach` on a Blazor Server page with more than a handful of rows, independent of parameter stability. The native `<input list>` + `<datalist>` stays. If someone wants Fluent styling later, options that avoid the per-row Fluent component are: (a) a lightly-styled `<input>` wrapped in Fluent's CSS classes; (b) rendering the suggestions as a single shared component with a `TextChanged` callback that updates the row's `Text` field.
### Phase 6 — Report componentization ### Phase 6 — Report componentization