Kontoformular zweispaltig

Konto bearbeiten und Neues Konto zeigen ihre vierzehn Felder in zwei
Spalten, damit alles ohne vertikales Scrollen auf einen Bildschirm passt.

- Neue Formularvariante .formular.zweispaltig: Grid mit zwei Spalten, je
  Feld ein .feld aus Beschriftung, Eingabe und Hinweis. Überschriften,
  Knopfleiste und .feld.ganz laufen über beide Spalten, unter 720 px
  fällt das Formular auf eine Spalte zurück.
- Feldreihenfolge zeilenweise; Namen, Werte und Feld-IDs unverändert.
- E2E-Test: Kontoart steht rechts neben dem Namen, das Formular endet
  oberhalb der Fensterhöhe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-17 23:12:35 +02:00
parent 2c86e8522b
commit a22c33bb12
4 changed files with 159 additions and 61 deletions

View file

@ -1,5 +1,22 @@
# Änderungen
## Kontoformular zweispaltig (17.08.2026)
*Konto bearbeiten* und *Neues Konto* zeigen ihre Felder in zwei Spalten. Alle Eingaben
stehen damit auf einem Bildschirm, ohne zu scrollen; bisher lagen die vierzehn Felder
untereinander.
- Neue Formularvariante `.formular.zweispaltig` (`public/static/app.css`): ein Grid mit
zwei Spalten, je Feld ein `.feld` aus Beschriftung, Eingabe und Hinweis. Überschriften,
Knopfleiste und `.feld.ganz` laufen über beide Spalten. Unter 720 px Fensterbreite fällt
das Formular auf eine Spalte zurück.
- Feldreihenfolge zeilenweise: Name/Kontoart, Kontengruppe/Beschreibung, Bank/Kontonummer,
IBAN/BIC, Anfangssaldo/Kreditlimit, Sortierung/Kennzeichen. Namen, Werte und Feld-IDs
bleiben unverändert.
- Der Bereich *Konto löschen* darunter bekommt dieselbe Breite, bleibt aber einspaltig.
- E2E-Test in `tests/e2e/specs/stammdaten.spec.js`: Kontoart steht rechts neben dem Namen,
und das Formular endet oberhalb der Fensterhöhe.
## Import und Export in zwei Formaten (17.08.2026)
Aus der Kachel *QIF-Import* wird **Import/Export** mit je einem Knopf für beide Richtungen.

View file

@ -1231,6 +1231,45 @@ table.kostentabelle .gruppenzeile th { padding-top: .7rem; font-weight: 600; }
margin-top: .6rem;
}
/* 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. */
.formular.zweispaltig {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: start;
gap: 1rem 1.5rem;
}
.formular.breit.zweispaltig { max-width: 900px; }
.formular.zweispaltig .feld {
display: flex;
flex-direction: column;
gap: .3rem;
}
.formular.zweispaltig .feld label { margin-top: 0; }
.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 > input[type="hidden"],
.formular.zweispaltig > h3,
.formular.zweispaltig > p,
.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 > .knopfleiste { margin-top: .5rem; }
@media (max-width: 720px) {
.formular.zweispaltig { grid-template-columns: 1fr; }
}
.formular label.ankreuzfeld {
display: flex;
align-items: center;

View file

@ -19,80 +19,104 @@ include __DIR__ . '/../layout/kopf.php';
<a href="/konten" class="knopf">Zurück zur Kontenübersicht</a>
</div>
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit">
<form method="post" action="<?= View::h($ziel) ?>" class="formular breit zweispaltig">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<label for="name">Name des Kontos</label>
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
<div class="feld">
<label for="name">Name des Kontos</label>
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
</div>
<label for="kontoart">Kontoart</label>
<select id="kontoart" name="kontoart">
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>"
<?= ($konto['kontoart'] ?? 'bank') === $schluessel ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
</option>
<?php endforeach; ?>
</select>
<div class="feld">
<label for="kontoart">Kontoart</label>
<select id="kontoart" name="kontoart">
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
<option value="<?= View::h($schluessel) ?>"
<?= ($konto['kontoart'] ?? 'bank') === $schluessel ? 'selected' : '' ?>>
<?= View::h($beschriftung) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<label for="kontengruppe_id">Kontengruppe</label>
<select id="kontengruppe_id" name="kontengruppe_id">
<option value="">Nicht gruppiert</option>
<?php foreach ($gruppen as $gruppe): ?>
<option value="<?= (int)$gruppe['id'] ?>"
<?= (int)($konto['kontengruppe_id'] ?? 0) === (int)$gruppe['id'] ? 'selected' : '' ?>>
<?= View::h($gruppe['name']) ?>
</option>
<?php endforeach; ?>
</select>
<div class="feld">
<label for="kontengruppe_id">Kontengruppe</label>
<select id="kontengruppe_id" name="kontengruppe_id">
<option value="">Nicht gruppiert</option>
<?php foreach ($gruppen as $gruppe): ?>
<option value="<?= (int)$gruppe['id'] ?>"
<?= (int)($konto['kontengruppe_id'] ?? 0) === (int)$gruppe['id'] ? 'selected' : '' ?>>
<?= View::h($gruppe['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<label for="bankname">Bank <span class="optional">(optional)</span></label>
<input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>">
<div class="feld">
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
</div>
<label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label>
<input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>">
<div class="feld">
<label for="bankname">Bank <span class="optional">(optional)</span></label>
<input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>">
</div>
<label for="iban">IBAN <span class="optional">(optional)</span></label>
<input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>">
<div class="feld">
<label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label>
<input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>">
</div>
<label for="bic">BIC <span class="optional">(optional)</span></label>
<input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>">
<div class="feld">
<label for="iban">IBAN <span class="optional">(optional)</span></label>
<input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>">
</div>
<label for="anfangssaldo">Anfangssaldo</label>
<input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal"
value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>">
<p class="feldhinweis">
Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht –
nach einem Import wird er hier je Konto nachgetragen.
</p>
<div class="feld">
<label for="bic">BIC <span class="optional">(optional)</span></label>
<input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>">
</div>
<label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label>
<input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal"
value="<?= $konto === null || $konto['kreditlimit'] === null
? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>">
<div class="feld">
<label for="anfangssaldo">Anfangssaldo</label>
<input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal"
value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>">
<p class="feldhinweis">
Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht –
nach einem Import wird er hier je Konto nachgetragen.
</p>
</div>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
<div class="feld">
<label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label>
<input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal"
value="<?= $konto === null || $konto['kreditlimit'] === null
? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>">
</div>
<label for="sortierung">Sortierung</label>
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1">
<p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
<div class="feld">
<label for="sortierung">Sortierung</label>
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1">
<p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
</div>
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>>
Konto ist aktiv
</label>
<div class="feld">
<label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>>
Konto ist aktiv
</label>
<label class="ankreuzfeld">
<input type="checkbox" name="in_summe" value="1"
<?= $neu || (int)$konto['in_summe'] === 1 ? 'checked' : '' ?>>
Zählt zur Summe der Kontostände
</label>
<p class="feldhinweis">
Entspricht der Σ-Spalte im FinanzManager. Konten, die kein eigenes Vermögen
darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus,
erscheinen aber weiter in der Übersicht.
</p>
<label class="ankreuzfeld">
<input type="checkbox" name="in_summe" value="1"
<?= $neu || (int)$konto['in_summe'] === 1 ? 'checked' : '' ?>>
Zählt zur Summe der Kontostände
</label>
<p class="feldhinweis">
Entspricht der Σ-Spalte im FinanzManager. Konten, die kein eigenes Vermögen
darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus,
erscheinen aber weiter in der Übersicht.
</p>
</div>
<div class="knopfleiste">
<button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button>
@ -101,7 +125,7 @@ include __DIR__ . '/../layout/kopf.php';
</form>
<?php if (!$neu): ?>
<form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit"
<form method="post" action="/konten/<?= (int)$konto['id'] ?>/loeschen" class="formular breit zweispaltig"
data-bestaetigen="Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Konto löschen</h3>

View file

@ -89,6 +89,24 @@ test.describe('Stammdaten', () => {
await expect(page.locator('tr', { hasText: name })).not.toContainText('ohne Σ')
})
// Das Kontoformular steht zweispaltig, damit alle Felder ohne vertikales
// Scrollen sichtbar sind. Geprüft wird an den Kästen: Kontoart steht rechts
// neben dem Namen, und das Formular endet oberhalb der Fensterhöhe.
test('Kontoformular steht zweispaltig und ohne Scrollen', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto('/konten/neu')
const name = await page.locator('#name').boundingBox()
const kontoart = await page.locator('#kontoart').boundingBox()
// Gleiche Zeile, zweite Spalte.
expect(Math.abs(name.y - kontoart.y)).toBeLessThan(5)
expect(kontoart.x).toBeGreaterThan(name.x + name.width)
const knopf = await page.locator('.formular button[type="submit"]').first().boundingBox()
expect(knopf.y + knopf.height).toBeLessThan(900)
})
test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
const oben = `E2E Wohnen ${MARKE}`
const unten = `Strom ${MARKE}`