Buchungsmaske nennt das Konto und passt auf einen Bildschirm

Neue Buchung und Buchung bearbeiten zeigen oben das Konto, in dem die
Buchung liegt. Die Maske steht zweispaltig, die Beschriftungen links
neben ihren Eingaben und die Erklärungen in deren Tooltip – damit endet
sie samt Splitzeilen und Knopfleiste oberhalb der Fensterhöhe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-17 23:36:47 +02:00
parent a22c33bb12
commit 4e3230c888
4 changed files with 192 additions and 60 deletions

View file

@ -1,5 +1,31 @@
# Änderungen
## Buchungsmaske nennt das Konto und passt auf einen Bildschirm (17.08.2026)
*Neue Buchung* und *Buchung bearbeiten* zeigen oben, in welchem Konto die Buchung liegt
bzw. gespeichert wird; bisher stand das Konto nur klein in der Kopfzeile über dem
Formular. Die Maske steht zweispaltig, die Beschriftungen links neben ihren Eingaben und
die Erklärungen in deren Tooltip – damit endet sie samt Splitzeilen und Knopfleiste
oberhalb der Fensterhöhe, wo sie vorher über 1190 px lang war.
- Kontoangabe als erstes Feld über beide Spalten: Name des Kontos, dahinter die IBAN –
oder, wenn keine hinterlegt ist, Bank und Kontonummer.
- Feldreihenfolge zeilenweise: Art/Datum, Betrag/Empfänger, Kategorie-Gegenkonto/Klasse,
Buchungstext/Nummer, darunter *Abgeglichen*. Namen, Werte und Feld-IDs bleiben
unverändert; der Bereich *Splitbuchung* läuft weiter über die volle Breite.
- Beschriftungen stehen links neben ihrer Eingabe, nicht darüber (neue Variante
`.formular.beschriftung-links`). Das halbiert die Höhe je Feld; Hinweise laufen unter
Beschriftung und Eingabe hinweg, unter 560 px Fensterbreite rutscht die Beschriftung
wieder nach oben.
- Die erklärenden Texte stehen nicht mehr als eigene Zeile zwischen den Feldern, sondern
im `title` ihrer Beschriftung: Konto, Betrag, *Kategorie / Gegenkonto* und
*Splitbuchung*. Eine gepunktete Unterstreichung zeigt, wo ein Tooltip hängt.
- `.formular.zweispaltig` (`public/static/app.css`) stellt jetzt auch Tabellen und
`.feldwert` richtig: Tabellen laufen über beide Spalten, ohne eigenen Rand.
- E2E-Test in `tests/e2e/specs/buchungen.spec.js`: Die Maske nennt das Konto, Datum steht
rechts neben der Art, die Beschriftung links neben ihrer Eingabe, die Erklärungen hängen
in deren Tooltip, und das ganze Formular endet oberhalb der Fensterhöhe.
## Kontoformular zweispaltig (17.08.2026)
*Konto bearbeiten* und *Neues Konto* zeigen ihre Felder in zwei Spalten. Alle Eingaben

View file

@ -1231,6 +1231,16 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
margin-top: .6rem;
}
/* Erklärungen zu einem Feld stehen im Tooltip seiner Beschriftung statt als
eigene Zeile darunter – das spart in langen Masken sichtbar Höhe. Die
gepunktete Linie zeigt, dass dort etwas zu holen ist. */
.formular label[title],
.formular h3 span[title] {
text-decoration: underline dotted;
text-underline-offset: .25em;
cursor: help;
}
/* Zweispaltiges Formular: je Feld ein .feld mit Beschriftung, Eingabe und
Hinweis, zwei davon nebeneinander. Lange Formulare – das Kontoformular etwa –
bleiben so ohne vertikales Scrollen einsehbar. */
@ -1253,23 +1263,55 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
.formular.zweispaltig .feld .feldhinweis { margin-top: 0; }
/* Über beide Spalten: verstecktes CSRF-Feld, Überschriften, Knopfleiste und
Felder, die für eine halbe Zeile zu breit sind. */
.formular.zweispaltig .feld .feldwert { margin: 0; }
.formular.zweispaltig .feld label.ankreuzfeld { margin-top: 0; }
/* Über beide Spalten: verstecktes CSRF-Feld, Überschriften, Knopfleiste,
Tabellen – die Splitzeilen der Buchungsmaske etwa – und Felder, die für eine
halbe Zeile zu breit sind. */
.formular.zweispaltig > input[type="hidden"],
.formular.zweispaltig > h3,
.formular.zweispaltig > p,
.formular.zweispaltig > table,
.formular.zweispaltig > .knopfleiste,
.formular.zweispaltig .feld.ganz { grid-column: 1 / -1; }
/* Die Abstände kommen im Grid aus dem gap – eigene Ränder addierten sich dazu. */
.formular.zweispaltig > h3 { margin: 0; }
.formular.zweispaltig > p { margin: 0; }
.formular.zweispaltig > table { margin: 0; }
.formular.zweispaltig > .knopfleiste { margin-top: .5rem; }
@media (max-width: 720px) {
.formular.zweispaltig { grid-template-columns: 1fr; }
}
/* Beschriftung links neben der Eingabe statt darüber. Das halbiert die Höhe je
Feld – die Buchungsmaske passt damit samt Splitzeilen auf einen Bildschirm.
Hinweise stehen unter der Eingabe, also in derselben Spalte wie diese. */
.formular.beschriftung-links .feld {
display: grid;
grid-template-columns: minmax(0, 10rem) minmax(0, 1fr);
align-items: center;
gap: .25rem .7rem;
}
.formular.beschriftung-links .feld > label { grid-column: 1; }
.formular.beschriftung-links .feld > :not(label) { grid-column: 2; }
/* Das Ankreuzfeld trägt seine Beschriftung selbst und braucht keine Spalte.
Hinweise laufen über beide Spalten: in der schmalen Eingabespalte allein
würden sie zu einem hohen Textblock, der die Zeile auseinanderzieht. */
.formular.beschriftung-links .feld > label.ankreuzfeld,
.formular.beschriftung-links .feld > .feldhinweis { grid-column: 1 / -1; }
@media (max-width: 560px) {
.formular.beschriftung-links .feld { grid-template-columns: 1fr; }
.formular.beschriftung-links .feld > label,
.formular.beschriftung-links .feld > :not(label) { grid-column: 1; }
}
.formular label.ankreuzfeld {
display: flex;
align-items: center;

View file

@ -33,6 +33,15 @@ $eigenesKonto = (int)$konto['id'];
$mitSplit = false;
$mitDepot = true;
// Zusatz hinter dem Kontonamen: was das Konto eindeutig macht, ohne die Zeile
// zu überladen. IBAN sticht, sonst Bank und Kontonummer.
$kontozusatz = trim((string)($konto['iban'] ?? '')) !== ''
? (string)$konto['iban']
: trim(implode(' · ', array_filter([
trim((string)($konto['bankname'] ?? '')),
trim((string)($konto['kontonummer'] ?? '')),
])));
// Immer ein paar leere Splitzeilen anbieten; weitere fügt der Knopf hinzu.
$splitZeilen = $splits;
for ($i = count($splitZeilen); $i < 4; $i++) {
@ -48,75 +57,95 @@ include __DIR__ . '/../layout/kopf.php';
<span class="saldoanzeige">Konto <strong><?= View::h($konto['name']) ?></strong></span>
</div>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit buchungsmaske">
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit zweispaltig beschriftung-links buchungsmaske">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="art">Art</label>
<select id="art" name="art">
<?php foreach ([
'ausgabe' => 'Ausgabe',
'einnahme' => 'Einnahme',
] as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>" <?= $art === $schluessel ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
</option>
<?php endforeach; ?>
</select>
<div class="feld ganz">
<label title="<?= View::h($neu
? 'In diesem Konto wird die Buchung gespeichert.'
: 'In diesem Konto ist die Buchung gespeichert.') ?>">Konto</label>
<p class="feldwert">
<strong><?= View::h($konto['name']) ?></strong>
<?php if ($kontozusatz !== ''): ?>
<span class="optional"><?= View::h($kontozusatz) ?></span>
<?php endif; ?>
</p>
</div>
<label for="datum">Datum</label>
<input type="date" id="datum" name="datum" required
value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>">
<div class="feld">
<label for="art">Art</label>
<select id="art" name="art">
<?php foreach ([
'ausgabe' => 'Ausgabe',
'einnahme' => 'Einnahme',
] as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>" <?= $art === $schluessel ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<label for="betrag">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" required
value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>">
<p class="feldhinweis">Immer positiv eingeben – die Richtung bestimmt die Art oben.</p>
<div class="feld">
<label for="datum">Datum</label>
<input type="date" id="datum" name="datum" required
value="<?= View::h($neu ? date('Y-m-d') : substr((string)$buchung['datum'], 0, 10)) ?>">
</div>
<label for="empfaenger">Empfänger</label>
<input type="text" id="empfaenger" name="empfaenger"
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
<div class="feld">
<label for="betrag" title="Immer positiv eingeben – die Richtung bestimmt die Art.">Betrag</label>
<input type="text" id="betrag" name="betrag" inputmode="decimal" required
value="<?= View::h($neu ? '' : View::betrag(abs($betrag), false)) ?>">
</div>
<label for="zuordnung">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = $zuordnungswert($buchung); ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
<p class="feldhinweis">
Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon
macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf
beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.
</p>
<div class="feld">
<label for="empfaenger">Empfänger</label>
<input type="text" id="empfaenger" name="empfaenger"
value="<?= View::h((string)($buchung['empfaenger'] ?? '')) ?>">
</div>
<label for="klasse_id">Klasse</label>
<select id="klasse_id" name="klasse_id">
<option value="">— keine —</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($buchung['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="feld">
<label for="zuordnung" title="Unter den Kategorien stehen die eigenen Konten in eckigen Klammern. Eines davon macht die Buchung zur Umbuchung: Sie wird einmal gespeichert und erscheint auf beiden Kontoblättern – auf dem Gegenkonto mit umgekehrtem Vorzeichen.">Kategorie / Gegenkonto</label>
<select id="zuordnung" name="zuordnung">
<?php $gewaehlt = $zuordnungswert($buchung); ?>
<?php include __DIR__ . '/zuordnungsliste.php'; ?>
</select>
</div>
<label for="buchungstext">Buchungstext</label>
<input type="text" id="buchungstext" name="buchungstext"
value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>">
<div class="feld">
<label for="klasse_id">Klasse</label>
<select id="klasse_id" name="klasse_id">
<option value="">— keine —</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"
<?= (int)($buchung['klasse_id'] ?? 0) === (int)$k['id'] ? 'selected' : '' ?>>
<?= View::h($k['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<label for="nummer">Nummer <span class="optional">(optional)</span></label>
<input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>">
<div class="feld">
<label for="buchungstext">Buchungstext</label>
<input type="text" id="buchungstext" name="buchungstext"
value="<?= View::h((string)($buchung['buchungstext'] ?? '')) ?>">
</div>
<label class="ankreuzfeld">
<input type="checkbox" name="abgeglichen" value="1"
<?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>>
Abgeglichen
</label>
<div class="feld">
<label for="nummer">Nummer <span class="optional">(optional)</span></label>
<input type="text" id="nummer" name="nummer" value="<?= View::h((string)($buchung['nummer'] ?? '')) ?>">
</div>
<h3>Splitbuchung <span class="optional">(optional)</span></h3>
<p class="feldhinweis">
Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und
Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag
entsprechen.
</p>
<div class="feld ganz">
<label class="ankreuzfeld">
<input type="checkbox" name="abgeglichen" value="1"
<?= !empty($buchung['abgeglichen']) ? 'checked' : '' ?>>
Abgeglichen
</label>
</div>
<h3><span title="Sobald hier Beträge stehen, gilt die Zuordnung aus den Zeilen; Kategorie und Gegenkonto oben bleiben dann außen vor. Die Summe muss dem Buchungsbetrag entsprechen.">Splitbuchung</span>
<span class="optional">(optional)</span></h3>
<table class="liste splittabelle">
<thead>

View file

@ -70,6 +70,41 @@ test.describe('Buchungen', () => {
await expect(page.locator('.saldoanzeige').first()).toContainText('1.000,00')
})
// Die Maske nennt oben das Konto, in dem die Buchung liegt, und stellt ihre
// Felder zweispaltig – beides, damit man beim Erfassen weder rätselt noch
// vertikal scrollt.
test('Buchungsmaske nennt das Konto und passt auf einen Bildschirm', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto(`/konten/${giroId}/buchung`)
// Das Konto steht in der Maske selbst, nicht nur in der Kopfzeile.
await expect(page.locator('.buchungsmaske .feldwert')).toContainText(GIRO)
// Gleiche Zeile, zweite Spalte.
const art = await page.locator('#art').boundingBox()
const datum = await page.locator('#datum').boundingBox()
expect(Math.abs(art.y - datum.y)).toBeLessThan(5)
expect(datum.x).toBeGreaterThan(art.x + art.width)
// Und die Beschriftung steht links neben der Eingabe, nicht darüber.
const beschriftung = await page.locator('label[for="betrag"]').boundingBox()
const betrag = await page.locator('#betrag').boundingBox()
expect(beschriftung.x + beschriftung.width).toBeLessThanOrEqual(betrag.x)
expect(Math.abs(
(beschriftung.y + beschriftung.height / 2) - (betrag.y + betrag.height / 2)
)).toBeLessThan(5)
// Die Erklärungen stehen im Tooltip der Beschriftung, nicht als eigene
// Zeile darunter – sonst passte die Maske nicht auf einen Bildschirm.
await expect(page.locator('label[for="zuordnung"]')).toHaveAttribute('title', /Umbuchung/)
await expect(page.locator('label[for="betrag"]')).toHaveAttribute('title', /positiv/)
// Und genau das ist der Zweck: Die ganze Maske samt Splitzeilen und
// Knopfleiste endet oberhalb der Fensterhöhe.
const maske = await page.locator('.buchungsmaske').boundingBox()
expect(maske.y + maske.height).toBeLessThan(900)
})
test('Ausgabe buchen und Saldo fortschreiben', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)