Merge pull request 'Berichte: Tabellen so breit wie nötig, Zahlen rechtsbündig' (#37) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/37
This commit is contained in:
fds2610 2026-08-18 21:15:41 +02:00
commit e3d5e58900
5 changed files with 123 additions and 5 deletions

View file

@ -1,5 +1,33 @@
# Änderungen # Änderungen
## Berichte: Tabellen so breit wie nötig, Zahlen rechtsbündig (18.08.2026)
Ein Bericht mit zwei oder vier Spalten wurde bisher über die gesamte Fensterbreite gezogen –
zwischen Bezeichnung und Betrag klaffte ein leeres Feld, und das Auge musste die Zeile
zweimal überqueren. Die **Berichtstabellen nehmen jetzt nur den Platz, den ihre Spalten
brauchen** (`width: auto`) und stehen dabei mittig unter der Überschrift; erst wenn der
Inhalt mehr verlangt, wachsen sie bis zur vollen Breite. Im Scrollrahmen `.breittabelle` (zwölf Monatsspalten) bleibt es beim
bisherigen Verhalten: Die Tabelle darf hinausragen und waagerecht scrollen, statt umzubrechen.
Dabei fiel auf, dass die Zahlenspalten **gar nicht rechtsbündig** standen: `.rechts` und
`.betrag` sind nur einfach spezifisch, `table.liste th, table.liste td { text-align: left }`
weiter oben im Stilblatt ist spezifischer und gewann. Solange die Spalten über den Bildschirm
gezogen waren, fiel es kaum auf – in der schmalen Tabelle sofort. Die Ausrichtung steht jetzt
für **alle Listen** ausdrücklich (`table.liste th.rechts, table.liste td.rechts`): Beträge,
Salden, Stückzahlen und Aktionsspalten stehen überall rechts, wo `.rechts` gesetzt ist –
Kontenübersicht, Kontoblatt, Wertpapiere, Depotbuchungen, Kategorien und die Berichte
gleichermaßen. Die Klasse tut damit endlich das, was ihr Name sagt.
Der neue E2E-Test misst alles drei: dass der Summenbericht deutlich schmaler bleibt als sein
Rahmen und links wie rechts gleich viel Luft behält, dass die letzte Spalte
`text-align: right` hat und dass die Monatsansicht weiterhin breiter wird.
Nachgezogen: Der E2E-Test der eingeklappten Seitenleiste erwartete `toBeHidden()` für die
Beschriftungen. Die sind aber mit Absicht nur aus dem Bild geschoben (`clip-path`, 1×1 Pixel)
statt entfernt, damit Vorleseprogramme die Namen behalten – für Playwright bleibt so ein
Element „sichtbar“. Geprüft wird jetzt, dass die Beschriftung keinen Platz mehr einnimmt und
der Verweis trotzdem seinen zugänglichen Namen behält.
## Seitenleiste: Marke mit Münzstapel, ein Klick schiebt sie auf Symbolbreite (18.08.2026) ## Seitenleiste: Marke mit Münzstapel, ein Klick schiebt sie auf Symbolbreite (18.08.2026)
Oben in der Seitenleiste steht jetzt eine richtige **Marke**: das Bild `Coinstack.png` aus den Oben in der Seitenleiste steht jetzt eine richtige **Marke**: das Bild `Coinstack.png` aus den

View file

@ -762,6 +762,13 @@ table.liste tbody tr:hover { background: var(--flaeche-gedaempft); }
.rechts { text-align: right; } .rechts { text-align: right; }
/* In Listen reicht `.rechts` allein nicht: `table.liste th, table.liste td`
weiter oben setzt text-align auf links und ist spezifischer. Zahlen- und
Aktionsspalten stehen deshalb ausdrücklich rechts – die Klasse tut damit
überall das, was ihr Name sagt. */
table.liste th.rechts,
table.liste td.rechts { text-align: right; }
.betrag { .betrag {
font-family: var(--schrift-zahl); font-family: var(--schrift-zahl);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
@ -1737,6 +1744,20 @@ body.schlicht {
table.bericht { font-size: .9rem; } table.bericht { font-size: .9rem; }
table.bericht td { padding: .28rem .7rem; } table.bericht td { padding: .28rem .7rem; }
/* Ein Bericht mit wenigen Spalten soll nicht über die ganze Breite gezogen
werden: Die Tabelle nimmt nur den Platz, den ihre Spalten brauchen, und
steht dabei mittig unter der Überschrift. Die Zahlenspalten bleiben in sich
rechtsbündig (.rechts), die letzte schließt bündig mit dem Tabellenrand ab. */
table.liste.bericht {
width: auto;
max-width: 100%;
margin-inline: auto;
}
/* Im Scrollrahmen darf die Tabelle über die sichtbare Breite hinausgehen –
sonst quetschte max-width die zwölf Monatsspalten in Umbrüche. */
.breittabelle table.liste.bericht { max-width: none; }
/* Abschnittsüberschrift „Einnahmen“ / „Ausgaben“. */ /* Abschnittsüberschrift „Einnahmen“ / „Ausgaben“. */
table.bericht tr.abschnittszeile th { table.bericht tr.abschnittszeile th {
text-align: left; text-align: left;

View file

@ -145,6 +145,38 @@ test.describe('Auswertungen', () => {
await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht') await expect(page.locator('.meldung-erfolg')).toContainText('gelöscht')
}) })
test('Schmale Berichte bleiben schmal, die Zahlenspalte bleibt rechtsbündig', async ({ page }) => {
// Zwei Spalten brauchen keine Bildschirmbreite: Die Tabelle steht links
// und ist deutlich schmaler als der Platz, den sie hätte.
await vorlage(page, 'summenbericht')
const tabelle = page.locator('table.bericht')
const rahmen = page.locator('.breittabelle')
const tabelleKasten = await tabelle.boundingBox()
const rahmenKasten = await rahmen.boundingBox()
const tabelleBreit = tabelleKasten.width
expect(tabelleBreit).toBeLessThan(rahmenKasten.width * 0.9)
// … und steht dabei mittig: links und rechts bleibt gleich viel Luft.
const links = tabelleKasten.x - rahmenKasten.x
const rechts = (rahmenKasten.x + rahmenKasten.width) - (tabelleKasten.x + tabelleBreit)
expect(Math.abs(links - rechts)).toBeLessThanOrEqual(1)
// Letzte Spalte: Zahlen rechtsbündig in ihrer eigenen Spalte.
// Die erste Zeile eines Abschnitts ist seine Überschrift (th) – deshalb
// die erste echte Wertzeile.
const letzte = tabelle.locator('tbody tr > td:last-child').first()
await expect(letzte).toHaveCSS('text-align', 'right')
// Zwölf Monatsspalten dürfen dagegen weiterhin über die Breite gehen
// und im Rahmen scrollen, statt umzubrechen.
await page.selectOption('select[name="aufteilung"]', 'monat')
await page.click('button:has-text("Anzeigen")')
expect((await tabelle.boundingBox()).width)
.toBeGreaterThan(tabelleBreit)
})
test('CSV-Ausgabe kommt als Datei mit deutschem Zahlenformat', async ({ page }) => { test('CSV-Ausgabe kommt als Datei mit deutschem Zahlenformat', async ({ page }) => {
await vorlage(page, 'summenbericht') await vorlage(page, 'summenbericht')

View file

@ -54,6 +54,25 @@ test.describe('Module der Phasen 4 bis 7', () => {
} }
}) })
test('Zahlenspalten stehen in allen Listen rechtsbündig', async ({ page }) => {
// `.rechts` markiert Zahlen- und Aktionsspalten. Die Klasse muss die
// linksbündige Grundregel der Listen überall schlagen – nicht nur in den
// Berichten, wo es zuerst auffiel.
for (const pfad of ['/konten', '/wertpapiere', '/wertpapiere/buchungen']) {
await page.goto(pfad)
for (const art of ['th', 'td']) {
const zellen = page.locator(`table.liste ${art}.rechts`)
const anzahl = await zellen.count()
expect(anzahl, `${pfad}: ${art}.rechts vorhanden`).toBeGreaterThan(0)
for (const stelle of [0, anzahl - 1]) {
await expect(zellen.nth(stelle)).toHaveCSS('text-align', 'right')
}
}
}
})
test('Das Cockpit zeichnet seine Kacheln', async ({ page }) => { test('Das Cockpit zeichnet seine Kacheln', async ({ page }) => {
await page.goto('/') await page.goto('/')

View file

@ -14,6 +14,19 @@ async function anmelden(page) {
await expect(page).toHaveURL(/\/$/) await expect(page).toHaveURL(/\/$/)
} }
/**
* Eingeklappt werden die Beschriftungen nur aus dem Bild geschoben (`clip-path`
* auf 1×1 Pixel), nicht mit `display:none` entfernt – sonst verlören die
* Verweise ihren Namen für Vorleseprogramme. Für Playwright bleibt ein solches
* Element „sichtbar“; geprüft wird deshalb, dass es keinen Platz mehr einnimmt.
*/
async function istWeggeschoben(beschriftung) {
await expect.poll(async () => {
const kasten = await beschriftung.boundingBox()
return kasten === null ? 0 : Math.max(kasten.width, kasten.height)
}, { message: 'Beschriftung nimmt keinen Platz mehr ein' }).toBeLessThanOrEqual(1)
}
test.describe('Seitenleiste ein- und ausklappen', () => { test.describe('Seitenleiste ein- und ausklappen', () => {
test('Klick auf die Marke schiebt die Leiste auf Symbolbreite und zurück', async ({ page }) => { test('Klick auf die Marke schiebt die Leiste auf Symbolbreite und zurück', async ({ page }) => {
await anmelden(page) await anmelden(page)
@ -26,6 +39,7 @@ test.describe('Seitenleiste ein- und ausklappen', () => {
await expect(symbol).toBeVisible() await expect(symbol).toBeVisible()
await expect(beschriftung).toBeVisible() await expect(beschriftung).toBeVisible()
const breitOffen = (await page.locator('.seitenleiste').boundingBox()).width const breitOffen = (await page.locator('.seitenleiste').boundingBox()).width
const hauptbereichOffen = (await page.locator('.hauptbereich').boundingBox()).width
await marke.click() await marke.click()
@ -34,21 +48,25 @@ test.describe('Seitenleiste ein- und ausklappen', () => {
// Das Markensymbol und die Symbole der Menüpunkte bleiben stehen, // Das Markensymbol und die Symbole der Menüpunkte bleiben stehen,
// nur die Beschriftungen verschwinden. // nur die Beschriftungen verschwinden.
await expect(symbol).toBeVisible() await expect(symbol).toBeVisible()
await expect(beschriftung).toBeHidden() await istWeggeschoben(beschriftung)
await expect(page.locator('.seitenleiste .icon').first()).toBeVisible() await expect(page.locator('.seitenleiste .icon').first()).toBeVisible()
// Der Verweis behält dabei seinen Namen – nur das Auge verliert ihn.
await expect(page.locator('.seitenleiste a[href="/konten"]'))
.toHaveAccessibleName(/Kontenübersicht/)
const breitEingeklappt = (await page.locator('.seitenleiste').boundingBox()).width const breitEingeklappt = (await page.locator('.seitenleiste').boundingBox()).width
expect(breitEingeklappt).toBeLessThan(breitOffen) expect(breitEingeklappt).toBeLessThan(breitOffen)
// Der Hauptbereich bekommt den gewonnenen Platz. // Der Hauptbereich bekommt den gewonnenen Platz.
const hauptOffen = breitOffen expect((await page.locator('.hauptbereich').boundingBox()).width)
expect((await page.locator('.hauptbereich').boundingBox()).width).toBeGreaterThan(0) .toBeGreaterThan(hauptbereichOffen)
await marke.click() await marke.click()
await expect(rahmen).not.toHaveClass(/eingeklappt/) await expect(rahmen).not.toHaveClass(/eingeklappt/)
await expect(marke).toHaveAttribute('aria-expanded', 'true') await expect(marke).toHaveAttribute('aria-expanded', 'true')
await expect(beschriftung).toBeVisible() await expect(beschriftung).toBeVisible()
expect((await page.locator('.seitenleiste').boundingBox()).width).toBe(hauptOffen) expect((await page.locator('.seitenleiste').boundingBox()).width).toBe(breitOffen)
}) })
test('Die Symbole führen eingeklappt zu denselben Seiten', async ({ page }) => { test('Die Symbole führen eingeklappt zu denselben Seiten', async ({ page }) => {
@ -64,7 +82,7 @@ test.describe('Seitenleiste ein- und ausklappen', () => {
// Der Zustand hält über den Seitenwechsel hinweg – ohne Aufblitzen, // Der Zustand hält über den Seitenwechsel hinweg – ohne Aufblitzen,
// weil der Server ihn schon aus dem Cookie kennt. // weil der Server ihn schon aus dem Cookie kennt.
await expect(page.locator('.rahmen')).toHaveClass(/eingeklappt/) await expect(page.locator('.rahmen')).toHaveClass(/eingeklappt/)
await expect(page.locator('.seitenleiste .beschriftung').first()).toBeHidden() await istWeggeschoben(page.locator('.seitenleiste .beschriftung').first())
await page.locator('[data-seitenleiste-schalter]').click() await page.locator('[data-seitenleiste-schalter]').click()
await expect(page.locator('.rahmen')).not.toHaveClass(/eingeklappt/) await expect(page.locator('.rahmen')).not.toHaveClass(/eingeklappt/)