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 # Ä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) ## 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. 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; 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 { .formular label.ankreuzfeld {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -19,12 +19,15 @@ include __DIR__ . '/../layout/kopf.php';
<a href="/konten" class="knopf">Zurück zur Kontenübersicht</a> <a href="/konten" class="knopf">Zurück zur Kontenübersicht</a>
</div> </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) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<div class="feld">
<label for="name">Name des Kontos</label> <label for="name">Name des Kontos</label>
<input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus> <input type="text" id="name" name="name" value="<?= $wert('name') ?>" required autofocus>
</div>
<div class="feld">
<label for="kontoart">Kontoart</label> <label for="kontoart">Kontoart</label>
<select id="kontoart" name="kontoart"> <select id="kontoart" name="kontoart">
<?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?> <?php foreach (Konto::ARTEN as $schluessel => $beschriftung): ?>
@ -34,7 +37,9 @@ include __DIR__ . '/../layout/kopf.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div>
<div class="feld">
<label for="kontengruppe_id">Kontengruppe</label> <label for="kontengruppe_id">Kontengruppe</label>
<select id="kontengruppe_id" name="kontengruppe_id"> <select id="kontengruppe_id" name="kontengruppe_id">
<option value="">Nicht gruppiert</option> <option value="">Nicht gruppiert</option>
@ -45,19 +50,34 @@ include __DIR__ . '/../layout/kopf.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div>
<div class="feld">
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label>
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
</div>
<div class="feld">
<label for="bankname">Bank <span class="optional">(optional)</span></label> <label for="bankname">Bank <span class="optional">(optional)</span></label>
<input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>"> <input type="text" id="bankname" name="bankname" value="<?= $wert('bankname') ?>">
</div>
<div class="feld">
<label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label> <label for="kontonummer">Kontonummer <span class="optional">(optional)</span></label>
<input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>"> <input type="text" id="kontonummer" name="kontonummer" value="<?= $wert('kontonummer') ?>">
</div>
<div class="feld">
<label for="iban">IBAN <span class="optional">(optional)</span></label> <label for="iban">IBAN <span class="optional">(optional)</span></label>
<input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>"> <input type="text" id="iban" name="iban" value="<?= $wert('iban') ?>">
</div>
<div class="feld">
<label for="bic">BIC <span class="optional">(optional)</span></label> <label for="bic">BIC <span class="optional">(optional)</span></label>
<input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>"> <input type="text" id="bic" name="bic" value="<?= $wert('bic') ?>">
</div>
<div class="feld">
<label for="anfangssaldo">Anfangssaldo</label> <label for="anfangssaldo">Anfangssaldo</label>
<input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal" <input type="text" id="anfangssaldo" name="anfangssaldo" inputmode="decimal"
value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>"> value="<?= View::h(View::betrag($konto['anfangssaldo'] ?? 0, false)) ?>">
@ -65,19 +85,22 @@ include __DIR__ . '/../layout/kopf.php';
Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht – Kontostand vor der ersten erfassten Buchung. Der QIF-Export enthält ihn nicht –
nach einem Import wird er hier je Konto nachgetragen. nach einem Import wird er hier je Konto nachgetragen.
</p> </p>
</div>
<div class="feld">
<label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label> <label for="kreditlimit">Kreditlimit <span class="optional">(optional)</span></label>
<input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal" <input type="text" id="kreditlimit" name="kreditlimit" inputmode="decimal"
value="<?= $konto === null || $konto['kreditlimit'] === null value="<?= $konto === null || $konto['kreditlimit'] === null
? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>"> ? '' : View::h(View::betrag($konto['kreditlimit'], false)) ?>">
</div>
<label for="beschreibung">Beschreibung <span class="optional">(optional)</span></label> <div class="feld">
<input type="text" id="beschreibung" name="beschreibung" value="<?= $wert('beschreibung') ?>">
<label for="sortierung">Sortierung</label> <label for="sortierung">Sortierung</label>
<input type="number" id="sortierung" name="sortierung" value="<?= $wert('sortierung', '0') ?>" step="1"> <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> <p class="feldhinweis">Kleinere Werte stehen in der Kontenübersicht weiter oben.</p>
</div>
<div class="feld">
<label class="ankreuzfeld"> <label class="ankreuzfeld">
<input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>> <input type="checkbox" name="aktiv" value="1" <?= $neu || (int)$konto['aktiv'] === 1 ? 'checked' : '' ?>>
Konto ist aktiv Konto ist aktiv
@ -93,6 +116,7 @@ include __DIR__ . '/../layout/kopf.php';
darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus, darstellen – durchlaufende Auslagen etwa –, bleiben so aus der Gesamtsumme heraus,
erscheinen aber weiter in der Übersicht. erscheinen aber weiter in der Übersicht.
</p> </p>
</div>
<div class="knopfleiste"> <div class="knopfleiste">
<button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button> <button type="submit" class="knopf primaer"><?= $neu ? 'Konto anlegen' : 'Speichern' ?></button>
@ -101,7 +125,7 @@ include __DIR__ . '/../layout/kopf.php';
</form> </form>
<?php if (!$neu): ?> <?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?"> data-bestaetigen="Konto „<?= View::h($konto['name']) ?>“ wirklich löschen?">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<h3>Konto löschen</h3> <h3>Konto löschen</h3>

View file

@ -89,6 +89,24 @@ test.describe('Stammdaten', () => {
await expect(page.locator('tr', { hasText: name })).not.toContainText('ohne Σ') 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 }) => { test('Kategorie mit Unterkategorie anlegen', async ({ page }) => {
const oben = `E2E Wohnen ${MARKE}` const oben = `E2E Wohnen ${MARKE}`
const unten = `Strom ${MARKE}` const unten = `Strom ${MARKE}`