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:
parent
396f7f30c5
commit
ca8496f770
10 changed files with 477 additions and 45 deletions
33
Changes.md
33
Changes.md
|
|
@ -1,5 +1,38 @@
|
|||
# Ä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)
|
||||
|
||||
Eine Regel für den Abgleich kann jetzt eine **Splitbuchung** vorgeben: mehrere Kategorien mit
|
||||
|
|
|
|||
|
|
@ -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
|
||||
werden auf ihrem Ursprungskonto geändert, wie beim Bearbeiten und Löschen.
|
||||
`kontoblatt.js` erkennt sie an der Klasse `gegenseite`.
|
||||
* Feld und Auswahlliste sind **gleich breit und gleich hoch** (`13rem` / `1.35rem`), und
|
||||
die Zelle zeigt nichts an, was nicht auch in der Liste stünde – kein „zuordnen …“. Sonst
|
||||
rückt beim Anklicken die halbe Zeile: Die Spalte wüchse auf die Breite der längsten
|
||||
Kategorie, und der Empfänger daneben bräche um.
|
||||
* Feld und Auswahlliste sind **gleich breit** (`13rem`), und die Zelle zeigt nichts an, was
|
||||
nicht auch in der Liste stünde – kein „zuordnen …“. Sonst rückt beim Anklicken die halbe
|
||||
Zeile: Die Spalte wüchse auf die Breite der längsten Kategorie, und der Empfänger daneben
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
Eine Buchung ist entweder einfach zugeordnet **oder** gesplittet. Sobald Splitzeilen
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ $router->post('/buchungen/{id}/zuordnung',
|
|||
fn(array $p) => (new BuchungenController())->zuordnung($p));
|
||||
$router->post('/buchungen/{id}/splitbuchung',
|
||||
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
|
||||
// JavaScript, ein Ziel für beide.
|
||||
$router->get('/buchungen/{id}/wertpapier',
|
||||
|
|
|
|||
|
|
@ -1025,16 +1025,45 @@ tr.zugeklappt { display: none; }
|
|||
.filterleiste label { font-size: .88rem; color: var(--text-gedaempft); }
|
||||
|
||||
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); }
|
||||
|
||||
.gekuerzt {
|
||||
max-width: 26rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
/* Alle Buchungszeilen sind gleich hoch – zwei Textzeilen – und ihr Inhalt steht
|
||||
in der Mitte. Eine Zeile, die mit ihrem Text wächst, ließe das Kontoblatt beim
|
||||
Blättern hüpfen; eine, die auf eine Zeile schrumpft, machte lange Empfänger
|
||||
unlesbar. Was auf zwei Zeilen nicht passt, wird abgeschnitten und steht
|
||||
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 {
|
||||
display: flex;
|
||||
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
|
||||
Kategorie, und der Empfänger daneben bräche um. */
|
||||
td.zuordnungszelle { width: 13rem; }
|
||||
td.klassenzelle { width: 9rem; }
|
||||
|
||||
a.zuordnungsfeld {
|
||||
display: block;
|
||||
a.zuordnungsfeld,
|
||||
a.klassenfeld {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
max-width: 13rem;
|
||||
min-height: 1.35rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dashed transparent;
|
||||
}
|
||||
|
||||
a.klassenfeld { max-width: 9rem; }
|
||||
|
||||
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[hidden] { display: none; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* gibt es genau einmal und wandert in die angeklickte Zelle – wie das
|
||||
* Zahnradmenü im Budgetplaner. Eine Liste je Zeile wären bei hundert Zeilen
|
||||
* ebenso viele vollständige Kategoriebäume im HTML.
|
||||
* Ein Klick auf die Kategorie- oder die Klassenspalte öffnet die zugehörige
|
||||
* Auswahlliste in der Zelle. Jede Liste gibt es genau einmal und wandert in die
|
||||
* angeklickte Zelle – wie das Zahnradmenü im Budgetplaner. Eine Liste je Zeile
|
||||
* 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
|
||||
* dort wie bisher zu ändern.
|
||||
* Ohne JavaScript führt derselbe Klick in die Buchungsmaske; Zuordnung und
|
||||
* Klasse sind dort wie bisher zu ändern.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
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');
|
||||
if (!tabelle || !auswahl) {
|
||||
if (!auswahl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abfrage = tabelle.dataset.abfrage || '';
|
||||
const liste = auswahl.querySelector('select');
|
||||
const klassenauswahl = document.getElementById('klassenauswahl');
|
||||
const klassenliste = klassenauswahl ? klassenauswahl.querySelector('select') : null;
|
||||
const dialog = document.getElementById('splitdialog');
|
||||
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 offenesFormular = null;
|
||||
|
||||
function ziel(feld, aktion) {
|
||||
return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage;
|
||||
|
|
@ -37,8 +79,9 @@
|
|||
offenesFeld.hidden = false;
|
||||
offenesFeld = null;
|
||||
|
||||
auswahl.hidden = true;
|
||||
document.body.appendChild(auswahl);
|
||||
offenesFormular.hidden = true;
|
||||
document.body.appendChild(offenesFormular);
|
||||
offenesFormular = null;
|
||||
}
|
||||
|
||||
/* 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();
|
||||
|
||||
offenesFeld = feld;
|
||||
offenesFormular = formular;
|
||||
feld.hidden = true;
|
||||
|
||||
auswahl.action = ziel(feld, 'zuordnung');
|
||||
liste.value = feld.dataset.wert || '';
|
||||
feld.parentNode.appendChild(auswahl);
|
||||
auswahl.hidden = false;
|
||||
const auswahlliste = formular.querySelector('select');
|
||||
formular.action = ziel(feld, aktion);
|
||||
auswahlliste.value = wert;
|
||||
feld.parentNode.appendChild(formular);
|
||||
formular.hidden = false;
|
||||
|
||||
liste.focus();
|
||||
auswahlliste.focus();
|
||||
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
|
||||
try {
|
||||
liste.showPicker();
|
||||
auswahlliste.showPicker();
|
||||
} catch (fehler) {
|
||||
/* Dann klappt sie beim ersten Klick auf – kein Verlust. */
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// hier die Auswahl zu öffnen: Geändert wird sie auf ihrem Konto.
|
||||
const feld = ereignis.target.closest('a.zuordnungsfeld:not(.gegenseite)');
|
||||
if (!feld || ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) {
|
||||
if (!feld) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +149,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
oeffnen(feld);
|
||||
oeffnen(feld, auswahl, 'zuordnung', feld.dataset.wert || '');
|
||||
});
|
||||
|
||||
/* Ein Depot ist keine fertige Zuordnung: Stückzahl, Kurs und Nebenkosten
|
||||
|
|
@ -108,7 +168,7 @@
|
|||
}
|
||||
|
||||
liste.addEventListener('change', function () {
|
||||
if (!offenesFeld) {
|
||||
if (!offenesFeld || offenesFormular !== auswahl) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -137,6 +197,18 @@
|
|||
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.
|
||||
document.addEventListener('keydown', function (ereignis) {
|
||||
if (ereignis.key === 'Escape' && offenesFeld) {
|
||||
|
|
@ -148,7 +220,7 @@
|
|||
if (!(ereignis.target instanceof Element) || !offenesFeld) {
|
||||
return;
|
||||
}
|
||||
if (!ereignis.target.closest('.zuordnungszelle')) {
|
||||
if (!ereignis.target.closest('.zuordnungszelle, .klassenzelle')) {
|
||||
schliessen();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -227,6 +227,35 @@ class BuchungenController extends BaseController
|
|||
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.
|
||||
* Ohne Zeilen wird die Splitbuchung aufgehoben.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
* Eine leere Liste hebt die Splitbuchung auf; die Buchung steht danach ohne
|
||||
|
|
|
|||
|
|
@ -83,6 +83,27 @@ $splitdaten = static function (array $splitzeilen) use ($zuordnungswert): string
|
|||
], $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';
|
||||
?>
|
||||
|
||||
|
|
@ -196,16 +217,28 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<td class="nowrap"><?= View::datum($zeile['datum']) ?></td>
|
||||
<td class="nebentext"><?= View::h($zeile['nummer']) ?></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']): ?>
|
||||
<?= 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">
|
||||
Umbuchung
|
||||
</span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="zellentext">
|
||||
<a href="/buchungen/<?= (int)$zeile['id'] ?>"><?= View::h($zeile['empfaenger'] ?: '(ohne Empfänger)') ?></a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</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
|
||||
// Die Gegenseite einer fremden Umbuchung nennt das Konto, von dem
|
||||
// sie kommt – und zwar dort, wo sonst die Kategorie steht. Ohne
|
||||
|
|
@ -219,6 +252,14 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
$gegenkonto !== '' => $gegenkonto,
|
||||
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">
|
||||
<?php if ($zeile['gegensicht']): ?>
|
||||
|
|
@ -252,7 +293,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php if (isset($splits[(int)$zeile['id']])): ?>
|
||||
data-splits="<?= View::h($splitdaten($splits[(int)$zeile['id']])) ?>"
|
||||
<?php endif; ?>
|
||||
title="<?= View::h($beschriftung !== '' ? $beschriftung : 'Kategorie oder Gegenkonto zuordnen') ?>">
|
||||
title="<?= View::h($zuordnungstitel) ?>">
|
||||
<?php if ($beschriftung === 'Splitbuchung'): ?>
|
||||
<span class="marke">Splitbuchung</span>
|
||||
<?php else: ?>
|
||||
|
|
@ -261,7 +302,22 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
</a>
|
||||
<?php endif; ?>
|
||||
</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 <?= $zeile['saldo'] === null ? 'nebentext' : View::betragKlasse($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>
|
||||
</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">
|
||||
<form method="post" action="" id="splitformular">
|
||||
<input type="hidden" name="csrf_token" value="<?= View::h($csrfToken) ?>">
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ class ZuordnungTest extends TestCase
|
|||
$this->splitzeilenErsetzen();
|
||||
$this->splitbuchungLoeschen();
|
||||
$this->splitzeilenJeBuchung();
|
||||
$this->klasseWechseln();
|
||||
$this->pruefungen();
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +217,53 @@ class ZuordnungTest extends TestCase
|
|||
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
|
||||
{
|
||||
$id = $this->buchung(-80.0, '2026-03-15');
|
||||
|
|
@ -271,5 +319,13 @@ class ZuordnungTest extends TestCase
|
|||
$zuFuerSplits = true;
|
||||
}
|
||||
$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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const MARKE = Date.now().toString().slice(-6)
|
|||
const GIRO = `E2E Giro ${MARKE}`
|
||||
const SPAR = `E2E Spar ${MARKE}`
|
||||
const KATEGORIE = `E2E Einkauf ${MARKE}`
|
||||
const KLASSE = `E2E Privat ${MARKE}`
|
||||
|
||||
async function anmelden(page) {
|
||||
await page.goto('/login')
|
||||
|
|
@ -51,6 +52,10 @@ test.describe('Buchungen', () => {
|
|||
await page.selectOption('#art', 'ausgabe')
|
||||
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()
|
||||
})
|
||||
|
||||
|
|
@ -141,6 +146,36 @@ test.describe('Buchungen', () => {
|
|||
.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 }) => {
|
||||
await page.goto(`/konten/${giroId}/buchung`)
|
||||
await page.fill('#datum', '2026-04-04')
|
||||
|
|
@ -257,6 +292,43 @@ test.describe('Buchungen', () => {
|
|||
const zeile = page.locator('tr', { hasText: `Versicherung ${MARKE}` })
|
||||
await expect(zeile).toContainText('Splitbuchung')
|
||||
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 }) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue