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:
commit
a1d4009b49
5 changed files with 191 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,25 +976,89 @@ 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) + ' · 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) + ' · 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>
|
||||
|
||||
<?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
75
tests/61_BetragModusToggleTest.php
Normal file
75
tests/61_BetragModusToggleTest.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue