Merge pull request 'Kontoblatt: Klasse direkt auswählen, Zeilen einheitlich zweizeilig' (#31) from fds2610-bot/Finanzmanager:main into main
Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/31
This commit is contained in:
commit
377bb12e24
13 changed files with 537 additions and 45 deletions
27
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
27
.gitea/ISSUE_TEMPLATE/bug.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Melde einen Fehler im Projekt
|
||||||
|
title: "[BUG] "
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
### Beschreibung des Fehlers
|
||||||
|
Bitte beschreibe klar und präzise, was nicht funktioniert.
|
||||||
|
|
||||||
|
### Schritte zur Reproduktion
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
### Erwartetes Verhalten
|
||||||
|
Was sollte stattdessen passieren?
|
||||||
|
|
||||||
|
### Tatsächliches Verhalten
|
||||||
|
Was passiert stattdessen?
|
||||||
|
|
||||||
|
### Screenshots/Logs
|
||||||
|
Falls vorhanden, bitte anhängen.
|
||||||
|
|
||||||
|
### Umgebung
|
||||||
|
- Browser/Version:
|
||||||
|
- Betriebssystem:
|
||||||
18
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
18
.gitea/ISSUE_TEMPLATE/feature.md
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Schlage eine neue Funktion oder Verbesserung vor
|
||||||
|
title: "[FEATURE] "
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
|
### Beschreibung der gewünschten Funktion
|
||||||
|
Bitte beschreibe klar und präzise, was du dir wünschst.
|
||||||
|
|
||||||
|
### Motivation
|
||||||
|
Warum wäre diese Funktion nützlich? Welches Problem löst sie?
|
||||||
|
|
||||||
|
### Mögliche Umsetzung
|
||||||
|
Falls du Ideen zur Implementierung hast, beschreibe sie hier.
|
||||||
|
|
||||||
|
### Alternativen
|
||||||
|
Welche alternativen Lösungen hast du in Betracht gezogen?
|
||||||
15
.gitea/ISSUE_TEMPLATE/question.md
Normal file
15
.gitea/ISSUE_TEMPLATE/question.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
name: Frage
|
||||||
|
about: Stelle eine Frage zum Projekt
|
||||||
|
title: "[FRAGE] "
|
||||||
|
labels: question
|
||||||
|
---
|
||||||
|
|
||||||
|
### Deine Frage
|
||||||
|
Bitte stelle deine Frage so klar und präzise wie möglich.
|
||||||
|
|
||||||
|
### Kontext
|
||||||
|
Was hast du bereits versucht oder herausgefunden?
|
||||||
|
|
||||||
|
### Relevante Stellen im Code
|
||||||
|
Falls zutreffend, verweise auf relevante Dateien oder Zeilen.
|
||||||
33
Changes.md
33
Changes.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<span class="marke" title="Gegenseite einer Umbuchung – bearbeitbar auf dem Ursprungskonto">
|
<div class="zellentext"><?= View::h($zeile['empfaenger']) ?></div>
|
||||||
Umbuchung
|
<span class="marke" title="Gegenseite einer Umbuchung – bearbeitbar auf dem Ursprungskonto">
|
||||||
</span>
|
Umbuchung
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<a href="/buchungen/<?= (int)$zeile['id'] ?>"><?= View::h($zeile['empfaenger'] ?: '(ohne Empfänger)') ?></a>
|
<div class="zellentext">
|
||||||
|
<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) ?>">
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue