Fix n.n.: Regelmäßige Buchungen – Klasse, editierbarer Termin und Betrag, Bearbeiten per Klick

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-10-01 09:16:44 +02:00
parent ecab07e5da
commit 5ccc5ead0b
8 changed files with 272 additions and 36 deletions

View file

@ -1,5 +1,13 @@
# Änderungen
## Regelmäßige Buchungen: Klick zum Bearbeiten (01.10.2026)
- In „Alle Reihen“ erscheinen alle Felder zunächst als Text und werden erst beim Anklicken zum Eingabefeld (`reihenliste.js`). Ja/Nein-Felder schalten per Klick um, „Speichern“ erscheint erst nach einer Änderung. Konto und Klasse stehen untereinander, die Spalte Name/Buchungstext nimmt den freien Platz.
## Klasse und nächster Termin in „Alle Reihen“ (01.10.2026)
- Unter der Kontoauswahl gibt es eine Klassenauswahl, der „Nächste Termin“ ist ein Datumsfeld und lässt sich von Hand verschieben (`RegelmaessigeBuchung::speichern()`, ungültige Angaben werden ignoriert).
## Buchungstext bei regelmäßigen Buchungen (01.10.2026)
- Das Feld „Buchungstext“ fehlte im Formular für neue regelmäßige Buchungen und war in der Liste nur ein verstecktes Feld. Jetzt beim Anlegen vorhanden und in der Liste unter dem Namen bearbeitbar (`termine/index.php`).

View file

@ -869,6 +869,14 @@ table.liste input[type="number"] {
table.liste input.schmal { width: 5rem; }
/* Regelmäßige Buchungen: Name und Buchungstext nehmen den freien Platz, alle
anderen Spalten bleiben so schmal wie ihr Inhalt. Konto und Klasse stehen
fest untereinander. */
table.liste.reihen th:first-child { width: 100%; }
table.liste.reihen td:first-child input[type="text"] { min-width: 12rem; }
table.liste.reihen td select { display: block; max-width: 14rem; }
table.liste.reihen td select + select { margin-top: .25rem; }
/* ── Budgetplaner ────────────────────────────────────────────────────────────
Zwölf Monatsspalten passen auf keinen Bildschirm: Die Tabelle scrollt in
@ -2391,3 +2399,11 @@ input[type="checkbox"]:not(.alle-waehlen):not([name="buchungen[]"]):not([name="u
input[type="checkbox"],
input[type="checkbox"]::before { transition: none; }
}
/* Regelmäßige Buchungen: Text, der sich per Klick in ein Eingabefeld verwandelt. */
table.liste.reihen .feld + .feld { margin-top: .25rem; }
table.liste.reihen .anzeige { cursor: text; border-radius: 4px; padding: .1rem .2rem; margin: 0 -.2rem; }
table.liste.reihen .anzeige:hover,
table.liste.reihen .anzeige:focus-visible { background: var(--flaeche-gedaempft); outline: none; }
table.liste.reihen .anzeige.leer { color: var(--text-gedaempft); font-style: italic; }
table.liste.reihen [hidden] { display: none; }

View file

@ -0,0 +1,112 @@
/* Regelmäßige Buchungen: Felder erscheinen als Text und werden erst beim
* Anklicken zum Eingabefeld. Die Eingabefelder bleiben im Formular (`hidden`
* verhindert das Absenden nicht), Speichern erscheint erst nach einer Änderung.
*/
(function () {
'use strict';
const tabelle = document.querySelector('table.reihen');
if (!tabelle) {
return;
}
function datum(wert) {
const t = wert.split('-');
return t.length === 3 ? t[2] + '.' + t[1] + '.' + t[0] : '–';
}
function text(feld, steuer) {
if (steuer.type === 'checkbox') {
return steuer.checked ? 'Ja' : 'Nein';
}
if (feld.hasAttribute('data-datum')) {
return steuer.value ? datum(steuer.value) : '–';
}
if (steuer.tagName === 'SELECT') {
return steuer.value ? steuer.selectedOptions[0].textContent.trim() : '';
}
return steuer.value;
}
function zeige(feld) {
const anzeige = feld.querySelector('.anzeige');
const steuer = feld.querySelector('input, select');
const wert = text(feld, steuer);
// Betrag behält seine Formatierung vom Server, bis er geändert wird.
if (steuer.name === 'betrag') {
const zahl = parseFloat(steuer.value.replace(/\./g, '').replace(',', '.'));
if (!isNaN(zahl)) {
anzeige.textContent = zahl.toLocaleString('de-DE', {
minimumFractionDigits: 2, maximumFractionDigits: 2
}) + ' €';
anzeige.classList.toggle('negativ', zahl < 0);
}
} else {
anzeige.textContent = wert;
if (steuer.name === 'name') {
const fett = document.createElement('strong');
fett.textContent = wert;
anzeige.replaceChildren(fett);
}
}
anzeige.classList.toggle('leer', wert === '' && feld.dataset.leer !== undefined);
if (wert === '' && feld.dataset.leer !== undefined) {
anzeige.textContent = feld.dataset.leer;
}
steuer.hidden = true;
anzeige.hidden = false;
}
tabelle.querySelectorAll('.feld').forEach(function (feld) {
const anzeige = feld.querySelector('.anzeige');
const steuer = feld.querySelector('input, select');
if (!anzeige || !steuer) {
return;
}
if (anzeige.textContent.trim() === '' && feld.dataset.leer !== undefined) {
anzeige.textContent = feld.dataset.leer;
anzeige.classList.add('leer');
}
anzeige.tabIndex = 0;
anzeige.title = 'Zum Bearbeiten anklicken';
function bearbeiten() {
if (steuer.type === 'checkbox') {
steuer.checked = !steuer.checked;
steuer.dispatchEvent(new Event('change', { bubbles: true }));
zeige(feld);
return;
}
anzeige.hidden = true;
steuer.hidden = false;
steuer.focus();
}
anzeige.addEventListener('click', bearbeiten);
anzeige.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
bearbeiten();
}
});
steuer.addEventListener('blur', function () { zeige(feld); });
steuer.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
steuer.blur();
}
});
});
function geaendert(e) {
const zeile = e.target.closest('tr');
const knopf = zeile ? zeile.querySelector('[data-speichern]') : null;
if (knopf) {
knopf.hidden = false;
}
}
tabelle.addEventListener('input', geaendert);
tabelle.addEventListener('change', geaendert);
})();

View file

@ -200,6 +200,7 @@ class TermineController extends BaseController
'faktor' => $this->post('faktor', '1'),
'beginn' => $this->post('beginn'),
'ende' => $this->post('ende'),
'naechste_faelligkeit' => $this->post('naechste_faelligkeit'),
'automatisch' => isset($_POST['automatisch']) ? 1 : 0,
'vorlauf_tage' => $this->post('vorlauf_tage', '7'),
'aktiv' => isset($_POST['aktiv']) ? 1 : 0,

View file

@ -125,6 +125,14 @@ class RegelmaessigeBuchung
$naechste = $f['beginn'];
}
// Ein ausdrücklich gesetzter Termin gilt – so lässt sich die Fälligkeit
// von Hand verschieben.
$gewuenscht = (string)($daten['naechste_faelligkeit'] ?? '');
$zeit = \DateTimeImmutable::createFromFormat('!Y-m-d', $gewuenscht);
if ($zeit !== false && $zeit->format('Y-m-d') === $gewuenscht) {
$naechste = $gewuenscht;
}
Database::execute(
'UPDATE regelmaessige_buchungen SET name = ?, konto_id = ?, betrag = ?, empfaenger = ?,
buchungstext = ?, kategorie_id = ?, klasse_id = ?, gegenkonto_id = ?, splits = ?,

View file

@ -87,45 +87,86 @@ include __DIR__ . '/../layout/kopf.php';
data-bestaetigen="Reihe „<?= View::h($reihe['name']) ?>“ löschen? Gebuchte Zahlungen bleiben erhalten."></form>
<?php endforeach; ?>
<table class="liste">
<table class="liste reihen">
<thead>
<tr>
<th>Name</th><th>Konto</th><th class="rechts">Betrag</th><th>Rhythmus</th>
<th>Name und Buchungstext</th><th>Konto und Klasse</th><th class="rechts">Betrag</th><th>Rhythmus</th>
<th>Nächster Termin</th><th>Ende</th><th>Auto</th><th>Aktiv</th><th class="rechts">Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($reihen as $reihe): ?>
<?php $f = 'reihe-' . (int)$reihe['id']; ?>
<?php
$konto = '';
foreach ($konten as $k) {
if ((int)$k['id'] === (int)$reihe['konto_id']) {
$konto = (string)$k['name'];
}
}
$klasseName = '';
foreach ($klassen as $k) {
if ((int)$k['id'] === (int)($reihe['klasse_id'] ?? 0)) {
$klasseName = (string)$k['name'];
}
}
?>
<tr<?= (int)$reihe['aktiv'] === 1 ? '' : ' class="inaktiv"' ?>>
<td>
<input type="hidden" form="<?= $f ?>" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<input type="text" form="<?= $f ?>" name="name" value="<?= View::h($reihe['name']) ?>" required>
<input type="hidden" form="<?= $f ?>" name="empfaenger" value="<?= View::h($reihe['empfaenger']) ?>">
<input type="text" form="<?= $f ?>" name="buchungstext" placeholder="Buchungstext"
value="<?= View::h($reihe['buchungstext'] ?? '') ?>">
<input type="hidden" form="<?= $f ?>" name="kategorie_id" value="<?= View::h((string)$reihe['kategorie_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="klasse_id" value="<?= View::h((string)$reihe['klasse_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="gegenkonto_id" value="<?= View::h((string)$reihe['gegenkonto_id']) ?>">
<input type="hidden" form="<?= $f ?>" name="vorlauf_tage" value="<?= (int)$reihe['vorlauf_tage'] ?>">
<input type="hidden" form="<?= $f ?>" name="faktor" value="<?= (int)$reihe['faktor'] ?>">
<input type="hidden" form="<?= $f ?>" name="beginn" value="<?= View::h((string)$reihe['beginn']) ?>">
<div class="feld" data-leer="–">
<span class="anzeige"><strong><?= View::h($reihe['name']) ?></strong></span>
<input type="text" form="<?= $f ?>" name="name" required hidden
value="<?= View::h($reihe['name']) ?>">
</div>
<div class="feld" data-leer="Buchungstext">
<span class="anzeige"><?= View::h($reihe['buchungstext'] ?? '') ?></span>
<input type="text" form="<?= $f ?>" name="buchungstext" placeholder="Buchungstext" hidden
value="<?= View::h($reihe['buchungstext'] ?? '') ?>">
</div>
</td>
<td>
<select form="<?= $f ?>" name="konto_id">
<?php foreach ($konten as $konto): ?>
<option value="<?= (int)$konto['id'] ?>"
<?= (int)$reihe['konto_id'] === (int)$konto['id'] ? 'selected' : '' ?>>
<?= View::h($konto['name']) ?>
<div class="feld">
<span class="anzeige"><?= View::h($konto) ?></span>
<select form="<?= $f ?>" name="konto_id" hidden>
<?php foreach ($konten as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)$reihe['konto_id'] === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="feld" data-leer="keine Klasse">
<span class="anzeige"><?= View::h($klasseName) ?></span>
<select form="<?= $f ?>" name="klasse_id" hidden>
<option value="">– keine Klasse –</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($reihe['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</td>
<td class="rechts">
<input type="text" form="<?= $f ?>" name="betrag" class="schmal" inputmode="decimal"
<div class="feld">
<span class="anzeige <?= View::betragKlasse($reihe['betrag']) ?>"><?= View::betrag($reihe['betrag']) ?></span>
<input type="text" form="<?= $f ?>" name="betrag" class="schmal" inputmode="decimal" hidden
value="<?= View::h(number_format((float)$reihe['betrag'], 2, ',', '')) ?>">
</div>
</td>
<td>
<select form="<?= $f ?>" name="intervall">
<div class="feld">
<span class="anzeige"><?= View::h($intervalle[$reihe['intervall']] ?? (string)$reihe['intervall']) ?></span>
<select form="<?= $f ?>" name="intervall" hidden>
<?php foreach ($intervalle as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>"
<?= $reihe['intervall'] === $schluessel ? 'selected' : '' ?>>
@ -133,32 +174,40 @@ include __DIR__ . '/../layout/kopf.php';
</option>
<?php endforeach; ?>
</select>
</div>
</td>
<td>
<?= View::h(View::datum($reihe['naechste_faelligkeit'])) ?>
<input type="hidden" form="<?= $f ?>" name="beginn"
value="<?= View::h((string)$reihe['beginn']) ?>">
<div class="feld" data-datum>
<span class="anzeige"><?= View::h(View::datum($reihe['naechste_faelligkeit'])) ?></span>
<input type="date" form="<?= $f ?>" name="naechste_faelligkeit" hidden
value="<?= View::h((string)($reihe['naechste_faelligkeit'] ?? '')) ?>">
</div>
</td>
<td>
<input type="date" form="<?= $f ?>" name="ende"
<div class="feld" data-datum>
<span class="anzeige"><?= View::h(View::datum($reihe['ende'] ?? null)) ?></span>
<input type="date" form="<?= $f ?>" name="ende" hidden
value="<?= View::h((string)($reihe['ende'] ?? '')) ?>">
</div>
</td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" form="<?= $f ?>" name="automatisch" value="1"
<div class="feld" data-schalter>
<span class="anzeige"><?= (int)$reihe['automatisch'] === 1 ? 'Ja' : 'Nein' ?></span>
<input type="checkbox" form="<?= $f ?>" name="automatisch" value="1" hidden
<?= (int)$reihe['automatisch'] === 1 ? 'checked' : '' ?>>
</label>
</div>
</td>
<td>
<label class="ankreuzfeld">
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1"
<div class="feld" data-schalter>
<span class="anzeige"><?= (int)$reihe['aktiv'] === 1 ? 'Ja' : 'Nein' ?></span>
<input type="checkbox" form="<?= $f ?>" name="aktiv" value="1" hidden
<?= (int)$reihe['aktiv'] === 1 ? 'checked' : '' ?>>
</label>
</div>
</td>
<td class="rechts nowrap">
<input type="hidden" form="reihe-loeschen-<?= (int)$reihe['id'] ?>"
name="csrf_token" value="<?= View::h($csrfToken) ?>">
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
<button type="submit" form="<?= $f ?>" class="knopf klein" data-speichern hidden>Speichern</button>
<button type="submit" form="reihe-loeschen-<?= (int)$reihe['id'] ?>"
class="knopf klein gefahr">Löschen</button>
</td>
@ -242,3 +291,5 @@ include __DIR__ . '/../layout/kopf.php';
</form>
<?php include __DIR__ . '/../layout/fuss.php'; ?>
<script src="/static/reihenliste.js" defer></script>

View file

@ -29,6 +29,7 @@ class RegelmaessigeLoeschenTest extends TestCase
$this->beendeteReiheWirdAktiv();
$this->manuelleBuchungBleibtUnberuehrt();
$this->buchungstextWirdUebernommen();
$this->naechsterTerminLaesstSichSetzen();
}
private function reihe(string $name, string $beginn, ?string $ende = null): int
@ -109,4 +110,19 @@ class RegelmaessigeLoeschenTest extends TestCase
$buchung = RegelmaessigeBuchung::buchen($id);
$this->assertEquals('Miete Oktober', (string)Buchung::find($buchung)['buchungstext'], 'Und in die Buchung übernommen');
}
private function naechsterTerminLaesstSichSetzen(): void
{
$id = $this->reihe('Verschiebbar', '2026-10-01');
$daten = RegelmaessigeBuchung::find($id);
RegelmaessigeBuchung::speichern($id, $daten);
$this->assertEquals('2026-10-01', $this->faelligkeit($id), 'Speichern ohne Änderung lässt den Termin stehen');
RegelmaessigeBuchung::speichern($id, ['naechste_faelligkeit' => '2026-12-15'] + $daten);
$this->assertEquals('2026-12-15', $this->faelligkeit($id), 'Ein gesetzter Termin gilt');
RegelmaessigeBuchung::speichern($id, ['naechste_faelligkeit' => 'Unsinn'] + $daten);
$this->assertEquals('2026-12-15', $this->faelligkeit($id), 'Ungültige Angaben werden ignoriert');
}
}

View file

@ -312,6 +312,30 @@ test.describe('Module der Phasen 4 bis 7', () => {
await expect(page.locator('.meldung')).toContainText('gelöscht')
})
test('Regelmäßige Buchungen werden per Klick bearbeitbar', async ({ page }) => {
await page.goto('/termine')
await page.fill('#name', 'E2E-Reihe')
await page.fill('#neu_betrag', '-12,50')
await page.fill('#neu_buchungstext', 'E2E-Text')
await page.click('form[action="/termine/neu"] button[type="submit"]')
const zeile = page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })
await expect(zeile.locator('input[name="betrag"]')).toBeHidden()
await expect(zeile.locator('[data-speichern]')).toBeHidden()
await zeile.locator('.feld:has(input[name="betrag"]) .anzeige').click()
await expect(zeile.locator('input[name="betrag"]')).toBeVisible()
await zeile.locator('input[name="betrag"]').fill('-13,00')
await expect(zeile.locator('[data-speichern]')).toBeVisible()
await zeile.locator('[data-speichern]').click()
await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toContainText('13,00')
page.once('dialog', (dialog) => dialog.accept())
await page.locator('table.reihen tr', { hasText: 'E2E-Reihe' }).locator('button:has-text("Löschen")').click()
await expect(page.locator('table.reihen tr', { hasText: 'E2E-Reihe' })).toHaveCount(0)
})
test('Der Finanzkalender ist erreichbar und lässt sich einstellen', async ({ page }) => {
await page.goto('/termine/kalender')
await expect(page.locator('h1')).toContainText('Finanzkalender')