Kontoblatt: Klasse direkt auswählen, Zeilen einheitlich zweizeilig

Die Klassenspalte ist anklickbar wie die Zuordnung daneben: Ein Klick
öffnet die Klassenliste in der Zelle, die Auswahl wird sofort über
POST /buchungen/{id}/klasse gespeichert. Buchung::klasseSetzen() lässt
Kategorie, Gegenkonto und Splitzeilen unberührt.

Die Buchungszeilen sind jetzt alle zwei Textzeilen hoch, ihr Inhalt steht
senkrecht in der Mitte. Was nicht auf zwei Zeilen passt, wird
abgeschnitten; den vollständigen Text zeigt der Tooltip – bei Empfänger
und Buchungstext nur dort, wo wirklich etwas fehlt, über dem
Kategoriefeld einer Splitbuchung deren Zeilen mit Kategorie und Betrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-17 22:04:48 +02:00
parent 396f7f30c5
commit ca8496f770
10 changed files with 477 additions and 45 deletions

View file

@ -1,5 +1,38 @@
# Änderungen # Änderungen
## Ruhige Buchungszeilen im Kontoblatt (17.08.2026)
Die Zeilen des Kontoblatts sind jetzt alle gleich hoch – zwei Textzeilen – und ihr Inhalt
steht senkrecht in der Mitte. Bisher wuchs eine Zeile mit ihrem Text, sodass die Liste beim
Blättern sprang; der Buchungstext war zudem nach 90 Zeichen hart abgeschnitten und der Rest
nirgends zu sehen.
- Was auf zwei Zeilen nicht passt, schneidet das Stylesheet ab. Der vollständige Text steht
im Tooltip – und zwar nur dort, wo tatsächlich etwas fehlt: Ob ein Text abgeschnitten ist,
entscheidet sich erst nach dem Umbruch, `kontoblatt.js` setzt den Tooltip deshalb nach dem
Laden und nach jeder Größenänderung des Fensters.
- Der Buchungstext wird vollständig ausgegeben statt nach 90 Zeichen gekürzt; sichtbar bleibt
dank Abschnitt gleich viel, im Tooltip steht nun aber alles.
- Über dem Kategoriefeld einer **Splitbuchung** nennt der Tooltip die Zeilen der Aufteilung:
je Zeile Kategorie (oder Gegenkonto) und Betrag. In der Spalte selbst steht nur
*Splitbuchung*; wohin das Geld geht, war bisher erst im Dialog zu sehen.
## Klasse im Kontoblatt direkt auswählen (17.08.2026)
Die Klassenspalte des Kontoblatts ist jetzt anklickbar – nach derselben Bauart wie die
Zuordnung daneben: Ein Klick öffnet die Klassenliste in der Zelle, und die gewählte Klasse
wird sofort gespeichert. Der erste Eintrag *— keine —* löscht die vorhandene Klasse. Bisher
war dafür jedes Mal der Umweg über die Buchungsmaske nötig.
- Neue Route `POST /buchungen/{id}/klasse` auf `Buchung::klasseSetzen()`. Die Methode achtet
den Monatsabschluss und weist eine Klasse ab, die es nicht gibt.
- Die Klasse steht **neben** der Zuordnung, nicht in ihr: Kategorie, Gegenkonto und
Splitzeilen bleiben unberührt, eine gesplittete Buchung bleibt gesplittet.
- Die Liste gibt es – wie die der Zuordnung – genau einmal im HTML; `kontoblatt.js` hängt sie
in die angeklickte Zelle. Ohne JavaScript führt der Klick weiterhin in die Buchungsmaske.
- Zeilen der Gegensicht bleiben unverändert Text: Sie werden auf ihrem Ursprungskonto
geändert.
## Splitbuchungen in den Abgleichregeln (13.08.2026) ## Splitbuchungen in den Abgleichregeln (13.08.2026)
Eine Regel für den Abgleich kann jetzt eine **Splitbuchung** vorgeben: mehrere Kategorien mit Eine Regel für den Abgleich kann jetzt eine **Splitbuchung** vorgeben: mehrere Kategorien mit

View file

@ -98,10 +98,10 @@ Drei Punkte, die sich nicht ändern dürfen:
* Zeilen mit `gegensicht = true` führen zu ihrer Buchung, statt die Auswahl zu öffnen: Sie * Zeilen mit `gegensicht = true` führen zu ihrer Buchung, statt die Auswahl zu öffnen: Sie
werden auf ihrem Ursprungskonto geändert, wie beim Bearbeiten und Löschen. werden auf ihrem Ursprungskonto geändert, wie beim Bearbeiten und Löschen.
`kontoblatt.js` erkennt sie an der Klasse `gegenseite`. `kontoblatt.js` erkennt sie an der Klasse `gegenseite`.
* Feld und Auswahlliste sind **gleich breit und gleich hoch** (`13rem` / `1.35rem`), und * Feld und Auswahlliste sind **gleich breit** (`13rem`), und die Zelle zeigt nichts an, was
die Zelle zeigt nichts an, was nicht auch in der Liste stünde – kein „zuordnen …“. Sonst nicht auch in der Liste stünde – kein „zuordnen …“. Sonst rückt beim Anklicken die halbe
rückt beim Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten Zeile: Die Spalte wüchse auf die Breite der längsten Kategorie, und der Empfänger daneben
Kategorie, und der Empfänger daneben bräche um. bräche um. Senkrecht rückt nichts, weil die Zeilenhöhe ohnehin feststeht (siehe unten).
Die Auswahl eines Depots (`depot:7`) öffnet statt beider den **Wertpapierdialog**; ohne Die Auswahl eines Depots (`depot:7`) öffnet statt beider den **Wertpapierdialog**; ohne
JavaScript führt sie auf `/buchungen/{id}/wertpapier?depot=7`, wo dieselben Felder als JavaScript führt sie auf `/buchungen/{id}/wertpapier?depot=7`, wo dieselben Felder als
@ -114,6 +114,42 @@ gewesen. Wer eine Aufteilung wieder in eine einzelne Zuordnung wandeln will, lö
im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die im Dialog und ordnet die dann unzugeordnete Buchung anschließend zu – oder geht über die
Buchungsmaske. Buchungsmaske.
## Klasse im Kontoblatt
Die Klassenspalte ist nach derselben Bauart anklickbar: eine zweite Liste
(`#klassenauswahl`), die `kontoblatt.js` in die angeklickte Zelle hängt. Ihr erster Eintrag
*— keine —* löscht die vorhandene Klasse, jeder andere setzt sie; gespeichert wird sofort
beim Anklicken, über `POST /buchungen/{id}/klasse` und `Buchung::klasseSetzen()`.
Die Klasse steht **neben** der Zuordnung, nicht in ihr: Sie ist die zweite
Auswertungsdimension. Anders als `Buchung::zuordnen()` rührt `klasseSetzen()` deshalb weder
Kategorie noch Gegenkonto noch Splitzeilen an – auch eine gesplittete Buchung bleibt beim
Wechsel der Klasse gesplittet. Den Monatsabschluss achtet die Methode wie alle anderen.
## Zeilenhöhe und Tooltips im Kontoblatt
Jede Buchungszeile ist **zwei Textzeilen hoch**, gleich wie viel in ihr steht, und ihr
Inhalt steht senkrecht in der Mitte. Eine Zeile, die mit ihrem Text wächst, ließe die Liste
beim Blättern hüpfen; eine, die auf eine Zeile schrumpft, machte lange Empfänger unlesbar.
Die Höhe hängt an `table.kontoblatt td`, das Abschneiden an `.zellentext` (zwei Zeilen,
danach Auslassungspunkte). Empfänger und Buchungstext stehen deshalb in einem `<div>` und
nicht unmittelbar in der Zelle: Abschneiden kann nur ein Element, das kein Tabellenfeld ist.
Die Marke der Umbuchungs-Gegenseite steht **neben** dem abgeschnittenen Kasten
(`.zellenreihe`), nicht darin – sonst zählte sie als eine der beiden Zeilen und die Zeile
würde höher als die übrigen.
Den vollständigen Text zeigt der Tooltip:
* **Empfänger und Buchungstext**: `kontoblatt.js` setzt den Tooltip nach dem Laden und nach
jeder Größenänderung des Fensters – aber nur dort, wo wirklich etwas abgeschnitten ist.
Ob das der Fall ist, weiß erst der Browser nach dem Umbruch, und das hängt an der
Fensterbreite. Ein Tooltip, der bloß wiederholt, was ohnehin dasteht, wäre lästig.
* **Kategorie und Klasse** tragen ihren Tooltip vom Server. Bei einer **Splitbuchung** nennt
er die Zeilen der Aufteilung: je Zeile Kategorie (oder Gegenkonto) und Betrag, sonst
nichts. Notiz und Klasse stünden nur im Weg; wer sie ändern will, öffnet ohnehin den
Dialog.
## Splitbuchungen ## Splitbuchungen
Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen

View file

@ -90,6 +90,8 @@ $router->post('/buchungen/{id}/zuordnung',
fn(array $p) => (new BuchungenController())->zuordnung($p)); fn(array $p) => (new BuchungenController())->zuordnung($p));
$router->post('/buchungen/{id}/splitbuchung', $router->post('/buchungen/{id}/splitbuchung',
fn(array $p) => (new BuchungenController())->splitbuchung($p)); fn(array $p) => (new BuchungenController())->splitbuchung($p));
$router->post('/buchungen/{id}/klasse',
fn(array $p) => (new BuchungenController())->klasse($p));
// Das Wertpapiergeschäft zu einer Buchung – Dialog im Kontoblatt, Seite ohne // Das Wertpapiergeschäft zu einer Buchung – Dialog im Kontoblatt, Seite ohne
// JavaScript, ein Ziel für beide. // JavaScript, ein Ziel für beide.
$router->get('/buchungen/{id}/wertpapier', $router->get('/buchungen/{id}/wertpapier',

View file

@ -1025,16 +1025,45 @@ tr.zugeklappt { display: none; }
.filterleiste label { font-size: .88rem; color: var(--text-gedaempft); } .filterleiste label { font-size: .88rem; color: var(--text-gedaempft); }
table.kontoblatt { font-size: .89rem; } table.kontoblatt { font-size: .89rem; }
table.kontoblatt td { padding: .3rem .6rem; }
table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 5%, transparent); } table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent) 5%, transparent); }
.gekuerzt { /* Alle Buchungszeilen sind gleich hoch – zwei Textzeilen – und ihr Inhalt steht
max-width: 26rem; in der Mitte. Eine Zeile, die mit ihrem Text wächst, ließe das Kontoblatt beim
overflow: hidden; Blättern hüpfen; eine, die auf eine Zeile schrumpft, machte lange Empfänger
text-overflow: ellipsis; unlesbar. Was auf zwei Zeilen nicht passt, wird abgeschnitten und steht
white-space: nowrap; vollständig im Tooltip. */
table.kontoblatt td {
padding: .3rem .6rem;
line-height: 1.35;
height: calc(2 * 1.35em + .6rem);
vertical-align: middle;
} }
.zellentext {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
overflow-wrap: anywhere;
}
/* Text und Marke nebeneinander: Abgeschnitten wird nur der Text, die Marke
bleibt vollständig stehen. */
.zellenreihe {
display: flex;
align-items: center;
gap: .4rem;
}
.zellenreihe .zellentext { min-width: 0; }
/* Im abgeschnittenen Kasten wird die Marke zum Blockelement – ohne diese Breite
liefe ihr Rahmen bis zum Rand der Spalte. */
a.zuordnungsfeld .marke { width: fit-content; }
.buchungstext { max-width: 26rem; }
.blaettern { .blaettern {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1050,21 +1079,29 @@ table.kontoblatt tr.gegensicht td { background: color-mix(in srgb, var(--akzent)
Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten
Kategorie, und der Empfänger daneben bräche um. */ Kategorie, und der Empfänger daneben bräche um. */
td.zuordnungszelle { width: 13rem; } td.zuordnungszelle { width: 13rem; }
td.klassenzelle { width: 9rem; }
a.zuordnungsfeld { a.zuordnungsfeld,
display: block; a.klassenfeld {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
max-width: 13rem; max-width: 13rem;
min-height: 1.35rem; min-height: 1.35rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
border-bottom: 1px dashed transparent; border-bottom: 1px dashed transparent;
} }
a.klassenfeld { max-width: 9rem; }
a.zuordnungsfeld:hover, a.zuordnungsfeld:hover,
a.zuordnungsfeld:focus-visible { border-bottom-color: var(--rand-kraeftig); } a.zuordnungsfeld:focus-visible,
a.klassenfeld:hover,
a.klassenfeld:focus-visible { border-bottom-color: var(--rand-kraeftig); }
.zuordnungsauswahl { display: flex; gap: .3rem; } .zuordnungsauswahl { display: flex; gap: .3rem; }
.zuordnungsauswahl[hidden] { display: none; } .zuordnungsauswahl[hidden] { display: none; }

View file

@ -1,30 +1,72 @@
/* Kontoblatt: Kategorie oder Gegenkonto an Ort und Stelle zuordnen. /* Kontoblatt: Zuordnung und Klasse an Ort und Stelle ändern, abgeschnittene
* Texte im Tooltip vollständig zeigen.
* *
* Ein Klick auf die Kategoriespalte öffnet die Auswahlliste in der Zelle. Sie * Ein Klick auf die Kategorie- oder die Klassenspalte öffnet die zugehörige
* gibt es genau einmal und wandert in die angeklickte Zelle – wie das * Auswahlliste in der Zelle. Jede Liste gibt es genau einmal und wandert in die
* Zahnradmenü im Budgetplaner. Eine Liste je Zeile wären bei hundert Zeilen * angeklickte Zelle – wie das Zahnradmenü im Budgetplaner. Eine Liste je Zeile
* ebenso viele vollständige Kategoriebäume im HTML. * wären bei hundert Zeilen ebenso viele vollständige Kategoriebäume im HTML.
* *
* Ohne JavaScript führt derselbe Klick in die Buchungsmaske; die Zuordnung ist * Ohne JavaScript führt derselbe Klick in die Buchungsmaske; Zuordnung und
* dort wie bisher zu ändern. * Klasse sind dort wie bisher zu ändern.
*/ */
(function () { (function () {
'use strict'; 'use strict';
const tabelle = document.querySelector('table.kontoblatt'); const tabelle = document.querySelector('table.kontoblatt');
if (!tabelle) {
return;
}
/* ── Tooltips für abgeschnittene Texte ───────────────────────────────────
*
* Empfänger und Buchungstext stehen auf zwei Zeilen; was darüber hinausgeht,
* schneidet das Stylesheet ab. Der Tooltip mit dem vollständigen Text hängt
* deshalb nicht im HTML, sondern entsteht hier: Ob ein Text überhaupt
* abgeschnitten ist, weiß erst der Browser nach dem Umbruch – und das hängt
* an der Fensterbreite.
*
* Kategorie und Klasse tragen ihren Tooltip vom Server; er nennt bei
* Splitbuchungen mehr, als in der Zelle steht.
*/
function tooltipsSetzen() {
tabelle.querySelectorAll('.zellentext').forEach(function (kasten) {
const abgeschnitten = kasten.scrollHeight > kasten.clientHeight + 1
|| kasten.scrollWidth > kasten.clientWidth + 1;
const text = kasten.textContent.trim();
if (abgeschnitten && text !== '') {
kasten.title = text;
} else {
kasten.removeAttribute('title');
}
});
}
tooltipsSetzen();
let umbruchZaehler = 0;
window.addEventListener('resize', function () {
window.clearTimeout(umbruchZaehler);
umbruchZaehler = window.setTimeout(tooltipsSetzen, 150);
});
const auswahl = document.getElementById('zuordnungsauswahl'); const auswahl = document.getElementById('zuordnungsauswahl');
if (!tabelle || !auswahl) { if (!auswahl) {
return; return;
} }
const abfrage = tabelle.dataset.abfrage || ''; const abfrage = tabelle.dataset.abfrage || '';
const liste = auswahl.querySelector('select'); const liste = auswahl.querySelector('select');
const klassenauswahl = document.getElementById('klassenauswahl');
const klassenliste = klassenauswahl ? klassenauswahl.querySelector('select') : null;
const dialog = document.getElementById('splitdialog'); const dialog = document.getElementById('splitdialog');
const splitformular = document.getElementById('splitformular'); const splitformular = document.getElementById('splitformular');
// Das Feld, das gerade durch die Auswahlliste ersetzt ist. // Das Feld, das gerade durch eine Auswahlliste ersetzt ist, und das
// Formular, das an seiner Stelle steht.
let offenesFeld = null; let offenesFeld = null;
let offenesFormular = null;
function ziel(feld, aktion) { function ziel(feld, aktion) {
return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage; return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage;
@ -37,8 +79,9 @@
offenesFeld.hidden = false; offenesFeld.hidden = false;
offenesFeld = null; offenesFeld = null;
auswahl.hidden = true; offenesFormular.hidden = true;
document.body.appendChild(auswahl); document.body.appendChild(offenesFormular);
offenesFormular = null;
} }
/* Der Splitdialog zu einer Zeile – oder, wo es kein <dialog> gibt, die /* Der Splitdialog zu einer Zeile – oder, wo es kein <dialog> gibt, die
@ -52,31 +95,48 @@
} }
} }
function oeffnen(feld) { /* Das Feld verschwindet, das Formular tritt an seine Stelle. */
function oeffnen(feld, formular, aktion, wert) {
schliessen(); schliessen();
offenesFeld = feld; offenesFeld = feld;
offenesFormular = formular;
feld.hidden = true; feld.hidden = true;
auswahl.action = ziel(feld, 'zuordnung'); const auswahlliste = formular.querySelector('select');
liste.value = feld.dataset.wert || ''; formular.action = ziel(feld, aktion);
feld.parentNode.appendChild(auswahl); auswahlliste.value = wert;
auswahl.hidden = false; feld.parentNode.appendChild(formular);
formular.hidden = false;
liste.focus(); auswahlliste.focus();
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall. // Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
try { try {
liste.showPicker(); auswahlliste.showPicker();
} catch (fehler) { } catch (fehler) {
/* Dann klappt sie beim ersten Klick auf – kein Verlust. */ /* Dann klappt sie beim ersten Klick auf – kein Verlust. */
} }
} }
tabelle.addEventListener('click', function (ereignis) { tabelle.addEventListener('click', function (ereignis) {
if (ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) {
return;
}
// Die Klasse steht neben der Zuordnung, nicht in ihr: Auch eine
// Splitbuchung behält beim Wechsel ihre Zeilen.
const klassenfeld = klassenauswahl && ereignis.target.closest('a.klassenfeld');
if (klassenfeld) {
ereignis.preventDefault();
const klasse = klassenfeld.dataset.klasse || '';
oeffnen(klassenfeld, klassenauswahl, 'klasse', klasse === '0' ? '' : klasse);
return;
}
// Die Gegenseite einer fremden Umbuchung führt zu ihrer Buchung, statt // Die Gegenseite einer fremden Umbuchung führt zu ihrer Buchung, statt
// hier die Auswahl zu öffnen: Geändert wird sie auf ihrem Konto. // hier die Auswahl zu öffnen: Geändert wird sie auf ihrem Konto.
const feld = ereignis.target.closest('a.zuordnungsfeld:not(.gegenseite)'); const feld = ereignis.target.closest('a.zuordnungsfeld:not(.gegenseite)');
if (!feld || ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) { if (!feld) {
return; return;
} }
@ -89,7 +149,7 @@
return; return;
} }
oeffnen(feld); oeffnen(feld, auswahl, 'zuordnung', feld.dataset.wert || '');
}); });
/* Ein Depot ist keine fertige Zuordnung: Stückzahl, Kurs und Nebenkosten /* Ein Depot ist keine fertige Zuordnung: Stückzahl, Kurs und Nebenkosten
@ -108,7 +168,7 @@
} }
liste.addEventListener('change', function () { liste.addEventListener('change', function () {
if (!offenesFeld) { if (!offenesFeld || offenesFormular !== auswahl) {
return; return;
} }
@ -137,6 +197,18 @@
auswahl.requestSubmit(); auswahl.requestSubmit();
}); });
/* Die gewählte Klasse wird sofort gespeichert – der erste Eintrag der Liste
* löscht die vorhandene. Ein eigener Knopf würde nur einen zweiten Klick
* für dasselbe verlangen. */
if (klassenliste) {
klassenliste.addEventListener('change', function () {
if (!offenesFeld || offenesFormular !== klassenauswahl) {
return;
}
klassenauswahl.requestSubmit();
});
}
// Escape und der Klick daneben verwerfen die Auswahl. // Escape und der Klick daneben verwerfen die Auswahl.
document.addEventListener('keydown', function (ereignis) { document.addEventListener('keydown', function (ereignis) {
if (ereignis.key === 'Escape' && offenesFeld) { if (ereignis.key === 'Escape' && offenesFeld) {
@ -148,7 +220,7 @@
if (!(ereignis.target instanceof Element) || !offenesFeld) { if (!(ereignis.target instanceof Element) || !offenesFeld) {
return; return;
} }
if (!ereignis.target.closest('.zuordnungszelle')) { if (!ereignis.target.closest('.zuordnungszelle, .klassenzelle')) {
schliessen(); schliessen();
} }
}); });

View file

@ -227,6 +227,35 @@ class BuchungenController extends BaseController
View::redirect($ziel, 'Zuordnung gespeichert.'); View::redirect($ziel, 'Zuordnung gespeichert.');
} }
/**
* Schnellauswahl aus dem Kontoblatt: die Klasse einer Buchung setzen. Ein
* leerer Wert löscht die vorhandene Klasse.
*
* @param array<string,string> $params
*/
public function klasse(array $params): void
{
$this->verlangeAnmeldung();
$buchung = Buchung::find((int)$params['id']);
if ($buchung === null) {
$this->nichtGefunden('/buchungen/' . $params['id']);
return;
}
$ziel = $this->kontoblattPfad((int)$buchung['konto_id']);
$klasseId = (int)$this->post('klasse_id');
try {
Buchung::klasseSetzen((int)$buchung['id'], $klasseId > 0 ? $klasseId : null);
} catch (\Throwable $e) {
View::redirect($ziel, $e->getMessage(), 'fehler');
return;
}
View::redirect($ziel, $klasseId > 0 ? 'Klasse gespeichert.' : 'Klasse gelöscht.');
}
/** /**
* Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen. * Der Splitdialog des Kontoblatts: die Zeilen einer Buchung ersetzen.
* Ohne Zeilen wird die Splitbuchung aufgehoben. * Ohne Zeilen wird die Splitbuchung aufgehoben.

View file

@ -194,6 +194,32 @@ class Buchung
}); });
} }
/**
* Die Klasse einer Buchung – die Schnellauswahl im Kontoblatt. `null`
* löscht sie.
*
* Die Zuordnung bleibt unberührt: Die Klasse ist die zweite
* Auswertungsdimension neben Kategorie und Gegenkonto, keine Alternative
* dazu. Auch eine Splitbuchung behält deshalb ihre Zeilen.
*/
public static function klasseSetzen(int $id, ?int $klasseId): void
{
$buchung = self::find($id);
if ($buchung === null) {
throw new \RuntimeException('Die Buchung gibt es nicht.');
}
Monatsabschluss::verlangeOffen((string)$buchung['datum']);
if ($klasseId !== null && Klasse::find($klasseId) === null) {
throw new \InvalidArgumentException('Die Klasse gibt es nicht.');
}
Database::execute(
'UPDATE buchungen SET klasse_id = ? WHERE id = ? AND benutzer_id = ?',
[$klasseId, $id, Mandant::id()]
);
}
/** /**
* Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts. * Die Splitzeilen einer Buchung ersetzen – der Splitdialog des Kontoblatts.
* Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne * Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne

View file

@ -83,6 +83,27 @@ $splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string
], $splitzeilen), JSON_UNESCAPED_UNICODE); ], $splitzeilen), JSON_UNESCAPED_UNICODE);
}; };
/**
* Der Tooltip einer Splitbuchung: je Zeile Kategorie und Betrag. In der Spalte
* steht nur „Splitbuchung“ – wohin das Geld geht, wäre sonst erst im Dialog zu
* sehen. Notiz und Klasse bleiben draußen; sie stünden im Weg.
*
* @param list<array<string,mixed>> $splitzeilen
*/
$splituebersicht = static function (array $splitzeilen) use ($kategorieNamen, $kontenNamen): string {
$texte = [];
foreach ($splitzeilen as $zeile) {
$name = match (true) {
!empty($zeile['gegenkonto_id']) => '[' . ($kontenNamen[(int)$zeile['gegenkonto_id']] ?? '?') . ']',
!empty($zeile['kategorie_id']) => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? '?'),
default => '(ohne Kategorie)',
};
$texte[] = $name . ': ' . View::betrag($zeile['betrag']);
}
return implode("\n", $texte);
};
include __DIR__ . '/../layout/kopf.php'; include __DIR__ . '/../layout/kopf.php';
?> ?>
@ -196,16 +217,28 @@ include __DIR__ . '/../layout/kopf.php';
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td> <td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
<td class="nebentext"><?= View::h($zeile['nummer']) ?></td> <td class="nebentext"><?= View::h($zeile['nummer']) ?></td>
<td> <td>
<?php /* Der Text steht in einem eigenen Kasten, nicht direkt in
der Zelle: Abgeschnitten wird über zwei Zeilen, und das
kann nur ein Element, das kein Tabellenfeld ist. Die
Marke steht neben dem Kasten – im Kasten zählte sie als
eine der beiden Zeilen. */ ?>
<?php if ($zeile['gegensicht']): ?> <?php if ($zeile['gegensicht']): ?>
<?= View::h($zeile['empfaenger']) ?> <div class="zellenreihe">
<div class="zellentext"><?= View::h($zeile['empfaenger']) ?></div>
<span class="marke" title="Gegenseite einer Umbuchung – bearbeitbar auf dem Ursprungskonto"> <span class="marke" title="Gegenseite einer Umbuchung – bearbeitbar auf dem Ursprungskonto">
Umbuchung Umbuchung
</span> </span>
</div>
<?php else: ?> <?php else: ?>
<div class="zellentext">
<a href="/buchungen/<?= (int)$zeile['id'] ?>"><?= View::h($zeile['empfaenger'] ?: '(ohne Empfänger)') ?></a> <a href="/buchungen/<?= (int)$zeile['id'] ?>"><?= View::h($zeile['empfaenger'] ?: '(ohne Empfänger)') ?></a>
</div>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="nebentext gekuerzt"><?= View::h(mb_substr((string)$zeile['buchungstext'], 0, 90)) ?></td> <?php /* Ungekürzt: Was nicht auf zwei Zeilen passt, schneidet das
Stylesheet ab – der vollständige Text bleibt für den
Tooltip erhalten. */ ?>
<td class="nebentext buchungstext"><div class="zellentext"><?= View::h($zeile['buchungstext']) ?></div></td>
<?php <?php
// Die Gegenseite einer fremden Umbuchung nennt das Konto, von dem // Die Gegenseite einer fremden Umbuchung nennt das Konto, von dem
// sie kommt – und zwar dort, wo sonst die Kategorie steht. Ohne // sie kommt – und zwar dort, wo sonst die Kategorie steht. Ohne
@ -219,6 +252,14 @@ include __DIR__ . '/../layout/kopf.php';
$gegenkonto !== '' => $gegenkonto, $gegenkonto !== '' => $gegenkonto,
default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''), default => (string)($kategorieNamen[(int)$zeile['kategorie_id']] ?? ''),
}; };
// Eine Splitbuchung nennt im Tooltip ihre Zeilen, alles andere
// seine eigene – womöglich abgeschnittene – Beschriftung.
$zuordnungstitel = match (true) {
isset($splits[(int)$zeile['id']]) => $splituebersicht($splits[(int)$zeile['id']]),
$beschriftung !== '' => $beschriftung,
default => 'Kategorie oder Gegenkonto zuordnen',
};
?> ?>
<td class="zuordnungszelle"> <td class="zuordnungszelle">
<?php if ($zeile['gegensicht']): ?> <?php if ($zeile['gegensicht']): ?>
@ -252,7 +293,7 @@ include __DIR__ . '/../layout/kopf.php';
<?php if (isset($splits[(int)$zeile['id']])): ?> <?php if (isset($splits[(int)$zeile['id']])): ?>
data-splits="<?= View::h($splitdaten($splits[(int)$zeile['id']])) ?>" data-splits="<?= View::h($splitdaten($splits[(int)$zeile['id']])) ?>"
<?php endif; ?> <?php endif; ?>
title="<?= View::h($beschriftung !== '' ? $beschriftung : 'Kategorie oder Gegenkonto zuordnen') ?>"> title="<?= View::h($zuordnungstitel) ?>">
<?php if ($beschriftung === 'Splitbuchung'): ?> <?php if ($beschriftung === 'Splitbuchung'): ?>
<span class="marke">Splitbuchung</span> <span class="marke">Splitbuchung</span>
<?php else: ?> <?php else: ?>
@ -261,7 +302,22 @@ include __DIR__ . '/../layout/kopf.php';
</a> </a>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="nebentext"><?= View::h($klassenNamen[(int)$zeile['klasse_id']] ?? '') ?></td> <?php $klassenname = (string)($klassenNamen[(int)$zeile['klasse_id']] ?? ''); ?>
<td class="klassenzelle nebentext">
<?php if ($zeile['gegensicht']): ?>
<?= View::h($klassenname) ?>
<?php else: ?>
<?php /* Wie bei der Zuordnung: Ohne JavaScript führt der Klick
in die Buchungsmaske, mit ihm öffnet sich die Auswahl
an Ort und Stelle. */ ?>
<a href="/buchungen/<?= (int)$zeile['id'] ?>" class="klassenfeld"
data-buchung="<?= (int)$zeile['id'] ?>"
data-klasse="<?= (int)$zeile['klasse_id'] ?>"
title="<?= View::h($klassenname !== '' ? $klassenname : 'Klasse zuordnen') ?>">
<?= View::h($klassenname) ?>
</a>
<?php endif; ?>
</td>
<td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>"><?= View::betrag($zeile['betrag']) ?></td> <td class="rechts <?= View::betragKlasse($zeile['betrag']) ?>"><?= View::betrag($zeile['betrag']) ?></td>
<td class="rechts <?= $zeile['saldo'] === null ? 'nebentext' : View::betragKlasse($zeile['saldo']) ?>"> <td class="rechts <?= $zeile['saldo'] === null ? 'nebentext' : View::betragKlasse($zeile['saldo']) ?>">
<?= $zeile['saldo'] === null ? '–' : View::betrag($zeile['saldo']) ?> <?= $zeile['saldo'] === null ? '–' : View::betrag($zeile['saldo']) ?>
@ -297,6 +353,19 @@ include __DIR__ . '/../layout/kopf.php';
<noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript> <noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript>
</form> </form>
<?php /* Dieselbe Bauart für die Klassenspalte: eine Liste, die in die
angeklickte Zelle wandert. Der erste Eintrag löscht die Klasse. */ ?>
<form method="post" action="" id="klassenauswahl" class="zuordnungsauswahl" hidden>
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
<select name="klasse_id" aria-label="Klasse">
<option value="">— keine —</option>
<?php foreach ($klassen as $k): ?>
<option value="<?= (int)$k['id'] ?>"><?= View::h($k['name']) ?></option>
<?php endforeach; ?>
</select>
<noscript><button type="submit" class="knopf klein">Übernehmen</button></noscript>
</form>
<dialog id="splitdialog" class="splitdialog"> <dialog id="splitdialog" class="splitdialog">
<form method="post" action="" id="splitformular"> <form method="post" action="" id="splitformular">
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>"> <input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">

View file

@ -33,6 +33,7 @@ class ZuordnungTest extends TestCase
$this->splitzeilenErsetzen(); $this->splitzeilenErsetzen();
$this->splitbuchungLoeschen(); $this->splitbuchungLoeschen();
$this->splitzeilenJeBuchung(); $this->splitzeilenJeBuchung();
$this->klasseWechseln();
$this->pruefungen(); $this->pruefungen();
} }
@ -216,6 +217,53 @@ class ZuordnungTest extends TestCase
Buchung::loeschen($keine); Buchung::loeschen($keine);
} }
/**
* Die Schnellauswahl der Klassenspalte: setzen, wechseln, löschen – und die
* Zuordnung bleibt dabei, wie sie ist.
*/
private function klasseWechseln(): void
{
$zweite = Klasse::anlegen('Zuordnung Beruflich');
$id = $this->buchung(-70.0, '2026-03-17');
Buchung::zuordnen($id, $this->kategorie, null);
Buchung::klasseSetzen($id, $this->klasse);
$buchung = Buchung::find($id);
$this->assertEquals($this->klasse, (int)$buchung['klasse_id'], 'Die Klasse steht in der Buchung');
$this->assertEquals(
$this->kategorie,
(int)$buchung['kategorie_id'],
'Die Zuordnung bleibt von der Klasse unberührt'
);
Buchung::klasseSetzen($id, $zweite);
$this->assertEquals($zweite, (int)Buchung::find($id)['klasse_id'], 'Die Klasse lässt sich wechseln');
// Der erste Eintrag der Liste – „keine“ – löscht die vorhandene Klasse.
Buchung::klasseSetzen($id, null);
$this->assertNull(Buchung::find($id)['klasse_id'], 'Und wieder löschen');
// Eine Splitbuchung behält ihre Zeilen: Die Klasse steht daneben.
Buchung::splitsErsetzen($id, [
['kategorie_id' => $this->kategorie, 'betrag' => -40.0, 'notiz' => 'A'],
['kategorie_id' => $this->kategorie, 'betrag' => -30.0, 'notiz' => 'B'],
]);
Buchung::klasseSetzen($id, $this->klasse);
$this->assertEquals(2, count(Buchung::splits($id)), 'Die Splitzeilen bleiben erhalten');
$this->assertEquals(1, (int)Buchung::find($id)['gesplittet'], 'Und die Buchung bleibt gesplittet');
$fremdeKlasse = false;
try {
Buchung::klasseSetzen($id, 999999);
} catch (\Throwable) {
$fremdeKlasse = true;
}
$this->assert($fremdeKlasse, 'Eine Klasse, die es nicht gibt, wird abgewiesen');
Buchung::loeschen($id);
}
private function pruefungen(): void private function pruefungen(): void
{ {
$id = $this->buchung(-80.0, '2026-03-15'); $id = $this->buchung(-80.0, '2026-03-15');
@ -271,5 +319,13 @@ class ZuordnungTest extends TestCase
$zuFuerSplits = true; $zuFuerSplits = true;
} }
$this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind'); $this->assert($zuFuerSplits, 'Auch die Splitzeilen bleiben, wie sie sind');
$zuFuerKlasse = false;
try {
Buchung::klasseSetzen($id, $this->klasse);
} catch (\Throwable) {
$zuFuerKlasse = true;
}
$this->assert($zuFuerKlasse, 'Und die Klasse ebenso');
} }
} }

View file

@ -8,6 +8,7 @@ const MARKE = Date.now().toString().slice(-6)
const GIRO = `E2E Giro ${MARKE}` const GIRO = `E2E Giro ${MARKE}`
const SPAR = `E2E Spar ${MARKE}` const SPAR = `E2E Spar ${MARKE}`
const KATEGORIE = `E2E Einkauf ${MARKE}` const KATEGORIE = `E2E Einkauf ${MARKE}`
const KLASSE = `E2E Privat ${MARKE}`
async function anmelden(page) { async function anmelden(page) {
await page.goto('/login') await page.goto('/login')
@ -51,6 +52,10 @@ test.describe('Buchungen', () => {
await page.selectOption('#art', 'ausgabe') await page.selectOption('#art', 'ausgabe')
await page.click('.formular button[type="submit"]') await page.click('.formular button[type="submit"]')
await page.goto('/verwaltung/klassen')
await page.fill('form[action="/verwaltung/klassen/neu"] #name', KLASSE)
await page.click('form[action="/verwaltung/klassen/neu"] button[type="submit"]')
await page.close() await page.close()
}) })
@ -141,6 +146,36 @@ test.describe('Buchungen', () => {
.toContainText(KATEGORIE) .toContainText(KATEGORIE)
}) })
test('Klasse im Kontoblatt zuordnen und wieder löschen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-06')
await page.fill('#betrag', '8,80')
await page.fill('#empfaenger', `Ohne Klasse ${MARKE}`)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Dieselbe Bauart wie bei der Zuordnung: die Liste wandert in die Zelle.
const zeile = page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })
await zeile.locator('a.klassenfeld').click()
const liste = zeile.locator('#klassenauswahl select')
await expect(liste).toBeVisible()
await liste.selectOption({ label: KLASSE })
await expect(page.locator('.meldung-erfolg')).toContainText('Klasse gespeichert')
await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }))
.toContainText(KLASSE)
// Der erste Eintrag der Liste löscht die vorhandene Klasse wieder.
const danach = page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` })
await danach.locator('a.klassenfeld').click()
await danach.locator('#klassenauswahl select').selectOption('')
await expect(page.locator('.meldung-erfolg')).toContainText('Klasse gelöscht')
await expect(page.locator('tr', { hasText: `Ohne Klasse ${MARKE}` }))
.not.toContainText(KLASSE)
})
test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => { test('Umbuchung im Kontoblatt zuordnen', async ({ page }) => {
await page.goto(`/konten/${giroId}/buchung`) await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-04-04') await page.fill('#datum', '2026-04-04')
@ -257,6 +292,43 @@ test.describe('Buchungen', () => {
const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` }) const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` })
await expect(zeile).toContainText('Splitbuchung') await expect(zeile).toContainText('Splitbuchung')
await expect(zeile).toContainText('-260,49') await expect(zeile).toContainText('-260,49')
// In der Spalte steht nur „Splitbuchung“ – wohin das Geld geht, zeigt
// der Tooltip: je Zeile Kategorie und Betrag.
const titel = await zeile.locator('a.zuordnungsfeld').getAttribute('title')
expect(titel).toContain(`${KATEGORIE}: -162,51`)
expect(titel).toContain(`${KATEGORIE}: -97,98`)
})
test('Buchungszeilen sind gleich hoch, abgeschnittene Texte stehen im Tooltip', async ({ page }) => {
const LANG = `Verwendungszweck ${MARKE} mit sehr vielen Wörtern, `.repeat(6).trim()
await page.goto(`/konten/${giroId}/buchung`)
await page.fill('#datum', '2026-05-04')
await page.fill('#betrag', '11,11')
await page.fill('#empfaenger', `Langtext ${MARKE}`)
await page.fill('#buchungstext', LANG)
await page.click('.buchungsmaske button[type="submit"]')
await expect(page.locator('.meldung-erfolg')).toBeVisible()
// Einheitlich zwei Textzeilen hoch: Eine Zeile, die mit ihrem Text
// wächst, ließe das Kontoblatt beim Blättern hüpfen.
const hoehen = await page.locator('table.kontoblatt tbody tr').evaluateAll(
(zeilen) => zeilen.map((zeile) => Math.round(zeile.getBoundingClientRect().height))
)
expect(hoehen.length).toBeGreaterThan(1)
// Ein Pixel Toleranz: Die letzte Zeile hat keine Trennlinie.
expect(Math.max(...hoehen) - Math.min(...hoehen)).toBeLessThanOrEqual(1)
const zeile = page.locator('tr', { hasText: `Langtext ${MARKE}` })
// Der abgeschnittene Buchungstext steht vollständig im Tooltip …
await expect(zeile.locator('td.buchungstext .zellentext'))
.toHaveAttribute('title', LANG)
// … der kurze Empfänger daneben bekommt keinen: Dort fehlt nichts.
await expect(zeile.locator('td').nth(3).locator('.zellentext'))
.not.toHaveAttribute('title')
}) })
test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => { test('Splitzeilen, die nicht aufgehen, werden abgewiesen', async ({ page }) => {