Merge pull request 'Fix #137: Netto/Brutto-Umschalter in der Buchungsmaske' (#145) from fds2610-bot/Steuerkram:main into main

Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/145
This commit is contained in:
fds2610 2026-07-27 08:06:54 +02:00
commit a1d4009b49
5 changed files with 191 additions and 16 deletions

View file

@ -2,6 +2,13 @@
---
## 2026-07-27 – Feature: Netto/Brutto-Umschalter in der Buchungsmaske (Issue #137)
- Beim Erfassen einer Buchung musste bisher immer der Nettobetrag eingegeben werden – lag nur der Bruttobetrag vor (z. B. von einem Kassenbon), führte die manuelle Rückrechnung regelmäßig zu Rundungsdifferenzen.
- **`src/Views/belege/detail.php`:** Das Label „Nettobetrag (€)" wurde durch zwei Umschalt-Buttons „NETTOBETRAG (€)" / „BRUTTOBETRAG (€)" ersetzt (`setBetragModus()`); der jeweils aktive Button ist optisch eingedrückt hervorgehoben und bestimmt, wie der eingegebene Betrag interpretiert wird. Die Live-Anzeige (`updateNettoAnzeige()`) zeigt je nach Modus die jeweils fehlenden Werte (Netto/USt. bzw. USt./Brutto) an. Beim Absenden des Formulars wird ein im Brutto-Modus eingegebener Betrag serverseitig weiterhin als Nettobetrag benötigt – die Umrechnung erfolgt daher unmittelbar vor dem Submit per JavaScript, sodass Backend (`BookingController`, `Models/Booking.php`) unverändert bleibt.
- **`public/static/css/app.css`:** Neue Klasse `.betrag-modus-btn` (inkl. `.active`-Zustand) für den Umschalter.
- **`tests/61_BetragModusToggleTest.php`:** Neuer Test prüft Vorhandensein der Umschalt-Buttons, der zugehörigen JS-Funktionen sowie der Umrechnung vor dem Absenden.
## 2026-07-27 – Feature: Lieferanten-Typeahead in der Belegliste (Issue #138)
- Beim Klick in die Spalte „Lieferant" der Belegliste (Inline-Bearbeitung) gab es bisher nur ein freies Textfeld – Lieferanten konnten dadurch versehentlich mit unterschiedlicher Schreibweise mehrfach angelegt werden.

View file

@ -24,6 +24,7 @@ Vollständige Kontenliste: [kontenrahmen.md](kontenrahmen.md)
EÜR-Buchungsmodell (Einnahme/Ausgabe) mit Kategorie- und Zahlungskonto.
- **Netto-/Brutto-Umschalter**: Der Betrag kann in der Buchungsmaske wahlweise als Netto- oder Bruttobetrag eingegeben werden (Umschalt-Buttons oberhalb des Betragsfelds); der jeweils aktive Button bestimmt die Interpretation. Vermeidet manuelle Rundungsdifferenzen bei bekanntem Bruttobetrag.
- **Steuerarten** für Auslandsumsätze: Inland, steuerfreie ig. Lieferung (Kz 41), steuerfreie Ausfuhr (Kz 44), ig. Erwerb 19 % (Kz 89/91), ig. Erwerb 7 % (Kz 93/94)
- **Bewirtungsausgaben**: automatischer Split 70 %/30 % bei Konten 4650, 4651, 4652
- **Eigenanteil** (30 %) bei Telefon/Internet (Konten 4920/4925): optional zubuchbar

View file

@ -212,6 +212,27 @@ img { max-width: 100%; }
/* ── Forms ───────────────────────────────────────────────────── */
.form-group { margin-bottom: 1rem; }
.betrag-modus-btn {
padding: .35rem .7rem;
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-muted);
border-radius: var(--radius);
cursor: pointer;
transition: background .15s, color .15s, border-color .15s, box-shadow .15s;
}
.betrag-modus-btn:hover { background: var(--color-bg); }
.betrag-modus-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
box-shadow: inset 0 1px 3px rgba(0,0,0,.3);
}
label {
display: block;
font-size: .8rem;

View file

@ -430,7 +430,7 @@ $suppliers = $suppliers ?? [];
<?php endif; ?>
</div>
<?php endif; ?>
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings" id="buchung_form">
<?php
// bank_tx_id und alloc_betrag aus aktiver Session ODER aus POST-Daten beim Re-Render nach Validierungsfehler
$effectiveBankTxId = ($bankTx ?? null) ? (int)$bankTx['id'] : (int)($formData['bank_tx_id'] ?? 0);
@ -555,7 +555,12 @@ $suppliers = $suppliers ?? [];
<div class="form-grid form-grid-2">
<div class="form-group">
<label>Nettobetrag (€) *</label>
<div class="betrag-modus-toggle" role="group" aria-label="Eingabemodus Betrag" style="display:flex;align-items:center;gap:.4rem;min-height:2.1rem;margin-bottom:.4rem">
<button type="button" class="betrag-modus-btn active" id="betrag_modus_netto_btn"
aria-pressed="true" onclick="setBetragModus('netto')">NETTOBETRAG (€)</button>
<button type="button" class="betrag-modus-btn" id="betrag_modus_brutto_btn"
aria-pressed="false" onclick="setBetragModus('brutto')">BRUTTOBETRAG (€)</button>
</div>
<input type="number" name="betrag_netto" id="betrag_netto_input" step="0.01" required
placeholder="0.00"
value="<?= $h($formData['betrag_netto'] ?? '') ?>"
@ -564,7 +569,9 @@ $suppliers = $suppliers ?? [];
<p id="ust_brutto_anzeige" class="text-muted text-sm" style="margin-top:.25rem;line-height:1.6"></p>
</div>
<div class="form-group">
<label>Buchungstext *</label>
<div style="display:flex;align-items:center;min-height:2.1rem;margin-bottom:.4rem">
<label style="margin-bottom:0">Buchungstext *</label>
</div>
<?php
$defaultText = $formData['buchungstext']
?? ($beleg['rechnungs_nr']
@ -625,6 +632,7 @@ $suppliers = $suppliers ?? [];
// Netto aus Brutto bei USt-Wechsel neu berechnen (solange nicht manuell geändert)
function recalcNetto() {
if (manualNetto || !brutto) return;
if (typeof getBetragModus === 'function' && getBetragModus() !== 'netto') return;
const ust = parseFloat(ustSelect.value) / 100;
const netto = Math.round(brutto / (1 + ust) * 100) / 100;
nettoInput.value = netto.toFixed(2);
@ -968,24 +976,88 @@ function updateSteuerartVisibility() {
updateEigenanteilState();
}
// ── Netto/Brutto-Eingabemodus ──────────────────────────────────
// Solange Modus "netto" ist, entspricht der Feldwert dem Nettobetrag (wie bisher).
// Im Modus "brutto" wird der eingegebene Wert als Bruttobetrag interpretiert;
// vor dem Absenden wird er in den tatsächlichen Nettobetrag umgerechnet (siehe Submit-Handler unten).
let _betragModus = 'netto';
function getBetragModus() { return _betragModus; }
function setBetragModus(modus) {
if (modus === _betragModus) return;
const input = document.getElementById('betrag_netto_input');
const ustEl = document.getElementById('ust_satz_select');
const ustSatz = ustEl ? (parseFloat(ustEl.value) || 0) : 0;
const alt = parseFloat(input.value);
if (!isNaN(alt)) {
const neu = (modus === 'brutto')
? Math.round(alt * (1 + ustSatz / 100) * 100) / 100 // Netto → Brutto
: Math.round(alt / (1 + ustSatz / 100) * 100) / 100; // Brutto → Netto
input.value = neu.toFixed(2);
}
_betragModus = modus;
const nettoBtn = document.getElementById('betrag_modus_netto_btn');
const bruttoBtn = document.getElementById('betrag_modus_brutto_btn');
nettoBtn.classList.toggle('active', modus === 'netto');
nettoBtn.setAttribute('aria-pressed', modus === 'netto' ? 'true' : 'false');
bruttoBtn.classList.toggle('active', modus === 'brutto');
bruttoBtn.setAttribute('aria-pressed', modus === 'brutto' ? 'true' : 'false');
updateNettoAnzeige();
}
// Übernimmt einen Netto-Betrag (z.B. aus KI-Vorschlag) und schaltet dafür in den Netto-Modus zurück.
function setBetragNettoWert(wert) {
if (_betragModus !== 'netto') setBetragModus('netto');
const input = document.getElementById('betrag_netto_input');
input.value = wert;
updateNettoAnzeige();
}
function updateNettoAnzeige() {
const nettoEl = document.getElementById('betrag_netto_input');
const eingabeEl = document.getElementById('betrag_netto_input');
const ustEl = document.getElementById('ust_satz_select');
const anzeige = document.getElementById('ust_brutto_anzeige');
if (!nettoEl || !ustEl || !anzeige) return;
if (!eingabeEl || !ustEl || !anzeige) return;
const netto = parseFloat(nettoEl.value);
if (isNaN(netto) || nettoEl.value === '') {
const eingabe = parseFloat(eingabeEl.value);
if (isNaN(eingabe) || eingabeEl.value === '') {
anzeige.textContent = '';
return;
}
const ustSatz = parseFloat(ustEl.value) || 0;
const ust = Math.round(netto * ustSatz / 100 * 100) / 100;
const brutto = Math.round((netto + ust) * 100) / 100;
const fmt = v => v.toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + ' €';
if (_betragModus === 'brutto') {
const netto = Math.round(eingabe / (1 + ustSatz / 100) * 100) / 100;
const ust = Math.round((eingabe - netto) * 100) / 100;
anzeige.innerHTML = 'Netto ' + fmt(netto) + ' &nbsp;·&nbsp; USt. ' + fmt(ust);
} else {
const ust = Math.round(eingabe * ustSatz / 100 * 100) / 100;
const brutto = Math.round((eingabe + ust) * 100) / 100;
anzeige.innerHTML = 'USt. ' + fmt(ust) + ' &nbsp;·&nbsp; Brutto ' + fmt(brutto);
}
}
// Vor dem Absenden: Im Brutto-Modus enthält das Feld einen Bruttobetrag,
// das Backend erwartet aber immer den Nettobetrag (Feldname betrag_netto).
const _buchungFormEl = document.getElementById('buchung_form');
if (_buchungFormEl) {
_buchungFormEl.addEventListener('submit', function () {
if (_betragModus !== 'brutto') return;
const input = document.getElementById('betrag_netto_input');
const ustEl = document.getElementById('ust_satz_select');
const ustSatz = ustEl ? (parseFloat(ustEl.value) || 0) : 0;
const brutto = parseFloat(input.value);
if (!isNaN(brutto)) {
input.value = (Math.round(brutto / (1 + ustSatz / 100) * 100) / 100).toFixed(2);
}
});
}
</script>
@ -1015,8 +1087,7 @@ function _applyKiVorschlag(v) {
const zhlEl2 = document.getElementById('zahlungskonto_id');
_selectById(zhlEl2, v.zahlungskonto_id);
if (zhlEl2) transferKontoNr(zhlEl2);
const netEl = document.querySelector('input[name="betrag_netto"]');
if (netEl && v.betrag_netto) { netEl.value = v.betrag_netto; updateNettoAnzeige(); }
if (v.betrag_netto) setBetragNettoWert(v.betrag_netto);
const txtEl = document.querySelector('input[name="buchungstext"]');
if (txtEl && v.buchungstext) txtEl.value = v.buchungstext;

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/**
* Test 61: Netto/Brutto-Umschalter im Buchungsformular
*
* Prüft, dass im Beleg-Detailview die beiden Umschalt-Buttons für
* Netto- bzw. Bruttobetrag vorhanden sind und die zugehörige JS-Logik
* (Moduswechsel, Umrechnung, Konvertierung vor dem Absenden) existiert.
*/
class BetragModusToggleTest extends TestCase
{
protected string $testName = 'Test 61: Netto/Brutto-Umschalter im Buchungsformular';
public function run(): void
{
$viewFile = __DIR__ . '/../src/Views/belege/detail.php';
$content = file_get_contents($viewFile);
$this->assert(
$content !== false,
'View-Datei belege/detail.php ist lesbar'
);
$this->assert(
str_contains($content, 'id="betrag_modus_netto_btn"')
&& str_contains($content, 'id="betrag_modus_brutto_btn"'),
'Beide Umschalt-Buttons (Netto/Brutto) sind vorhanden'
);
$this->assert(
str_contains($content, "onclick=\"setBetragModus('netto')\"")
&& str_contains($content, "onclick=\"setBetragModus('brutto')\""),
'Buttons rufen setBetragModus() mit dem jeweiligen Modus auf'
);
$this->assert(
str_contains($content, 'function setBetragModus(modus)'),
'JavaScript-Funktion setBetragModus() ist definiert'
);
$this->assert(
str_contains($content, 'function getBetragModus()'),
'JavaScript-Funktion getBetragModus() ist definiert'
);
$this->assert(
str_contains($content, "classList.toggle('active'"),
'Aktiver Button wird optisch (CSS-Klasse) hervorgehoben'
);
// Vor dem Absenden muss im Brutto-Modus der Feldwert in den Nettobetrag
// umgerechnet werden, da das Backend ausschließlich betrag_netto erwartet.
$this->assert(
str_contains($content, "if (_betragModus !== 'brutto') return;")
&& str_contains($content, "addEventListener('submit'"),
'Vor dem Absenden wird im Brutto-Modus auf Nettobetrag umgerechnet'
);
// CSS-Klasse für den Umschalter muss definiert sein
$cssFile = __DIR__ . '/../public/static/css/app.css';
$cssContent = file_get_contents($cssFile);
$this->assert(
$cssContent !== false && str_contains($cssContent, '.betrag-modus-btn'),
'CSS-Klasse .betrag-modus-btn ist definiert'
);
$this->assert(
str_contains($cssContent, '.betrag-modus-btn.active'),
'CSS für aktiven Zustand des Umschalters ist definiert'
);
}
}