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:
parent
a22c33bb12
commit
4e3230c888
4 changed files with 192 additions and 60 deletions
26
Changes.md
26
Changes.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue