Finanzmanager/public/static/kontoblatt.js
fds2610-bot 619e427a30 Fix #96: Splitbuchungen zeigen Restbetrag, Speichern nur bei Null
Die Summenzeile der Splitbuchungen (Buchungsmaske und Splitdialog im
Kontoblatt) zeigt jetzt den noch offenen Restbetrag zum Buchungswert statt
der Summe der Zeilen. „Speichern" bleibt gesperrt, solange Splitzeilen
befüllt sind und der Restbetrag nicht Null ist; ohne Splitzeilen (auch mit
direkter Kategorie/Gegenkonto) bleibt der Knopf wie bisher an.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 23:34:27 +02:00

425 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Kontoblatt: Zuordnung und Klasse an Ort und Stelle ändern, abgeschnittene
* Texte im Tooltip vollständig zeigen.
*
* 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; 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.
*
* Der Buchungstext trägt zusätzlich `data-herkunft`: IBAN und Bank der
* Gegenseite, sofern der Bankabruf sie kennt. Die stehen unabhängig davon
* im Tooltip, ob der Text selbst abgeschnitten ist – sie stecken sonst
* nirgends in der Zelle.
*/
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();
const herkunft = kasten.dataset.herkunft || '';
const zeilen = [];
if (abgeschnitten && text !== '') {
zeilen.push(text);
}
if (herkunft !== '') {
zeilen.push(herkunft);
}
if (zeilen.length > 0) {
kasten.title = zeilen.join('\n');
} 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 (!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 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;
}
function schliessen() {
if (!offenesFeld) {
return;
}
offenesFeld.hidden = false;
offenesFeld = null;
offenesFormular.hidden = true;
document.body.appendChild(offenesFormular);
offenesFormular = null;
}
/* Der Splitdialog zu einer Zeile – oder, wo es kein <dialog> gibt, die
* Buchungsmaske. Die Weiche steht hier oben, weil beide Wege hierher
* führen: der Klick auf eine Splitbuchung und die Wahl „Splitbuchung“. */
function splitOeffnen(feld) {
if (dialog && splitformular && window.FmBetrag) {
splitdialogOeffnen(feld);
} else {
window.location.href = '/buchungen/' + feld.dataset.buchung;
}
}
/* Das Feld verschwindet, das Formular tritt an seine Stelle. */
function oeffnen(feld, formular, aktion, wert) {
schliessen();
offenesFeld = feld;
offenesFormular = formular;
feld.hidden = true;
const auswahlliste = formular.querySelector('select');
formular.action = ziel(feld, aktion);
auswahlliste.value = wert;
feld.parentNode.appendChild(formular);
formular.hidden = false;
// Die Kategorieliste ist per kombobox.js eine Typeahead-Kombobox
// (Fix #41) – ihr <select> ist unsichtbar und ließe sich weder
// fokussieren noch mit showPicker öffnen. Die Klassenliste bleibt ein
// gewöhnliches <select>.
if (auswahlliste._kombobox) {
auswahlliste._kombobox.oeffnen();
return;
}
auswahlliste.focus();
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
try {
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();
// Die Klassen einer Splitbuchung stehen an ihren Zeilen – der Klick
// führt deshalb in den Splitdialog, nicht in die Klassenliste.
const splitfeld = klassenfeld.dataset.split
? klassenfeld.closest('tr').querySelector('a.zuordnungsfeld[data-splits]')
: null;
if (splitfeld) {
splitOeffnen(splitfeld);
return;
}
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) {
return;
}
ereignis.preventDefault();
// Eine schon aufgeteilte Buchung führt geradewegs in den Splitdialog:
// Die Auswahlliste hätte für sie nichts anzubieten außer dem Weg dorthin.
if (feld.dataset.wert === 'split') {
splitOeffnen(feld);
return;
}
oeffnen(feld, auswahl, 'zuordnung', feld.dataset.wert || '');
});
/* Ein Depot ist keine fertige Zuordnung: Stückzahl, Kurs und Nebenkosten
* fehlen noch. Ohne den Dialog – etwa wenn das Skript nicht geladen ist –
* nimmt der Server dieselbe Auswahl entgegen und schickt auf die Seite mit
* denselben Feldern. */
function wertpapierOeffnen(feld, wert, name) {
const depotId = wert.slice('depot:'.length);
if (window.FmWertpapier) {
window.FmWertpapier.oeffnen(feld, depotId, name, ziel(feld, 'wertpapier'));
return;
}
window.location.href = '/buchungen/' + feld.dataset.buchung + '/wertpapier?depot=' + depotId;
}
/* Die Beschriftung der gewählten Option entspricht Wort für Wort dem, was
* die Zeile nach dem Speichern zeigen soll – Kategorie- und Kontonamen
* kommen serverseitig aus denselben Listen (zuordnungsliste.php). Der
* erste Eintrag „— keine —“ steht für eine leere Zuordnung. */
function beschriftungAus(auswahlliste) {
if (auswahlliste.value === '') {
return '';
}
return auswahlliste.options[auswahlliste.selectedIndex].textContent.trim();
}
/* Speichert eine Schnellzuordnung im Hintergrund, statt das Formular
* abzuschicken: Eine echte Formularübermittlung lädt die Seite neu und
* reißt den Bildschirm nach oben – bei einer alten Buchung weit unten in
* der Liste ein langer Weg zurück (Fehler #40). Das Feld schließt sofort;
* seinen Inhalt trägt die Antwort nach.
*
* Ohne Netz oder JSON-Antwort (etwa eine abgelaufene Anmeldung) übernimmt
* eine echte Formularübermittlung wie bisher – die Änderung geht dann
* nicht verloren, nur der Bildschirm springt in diesem Ausnahmefall.
*/
function schnellSpeichern(formular, aktualisieren) {
const daten = new FormData(formular);
schliessen();
fetch(formular.action, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: daten
}).then(function (antwort) {
return antwort.json().then(function (ergebnis) {
if (ergebnis.typ === 'fehler') {
window.FmMeldungen.zeigen(ergebnis.meldung, 'fehler');
return;
}
aktualisieren(ergebnis);
if (ergebnis.meldung) {
window.FmMeldungen.zeigen(ergebnis.meldung, 'erfolg');
}
});
}).catch(function () {
formular.requestSubmit();
});
}
liste.addEventListener('change', function () {
if (!offenesFeld || offenesFormular !== auswahl) {
return;
}
const feld = offenesFeld;
if (liste.value === 'split') {
schliessen();
splitOeffnen(feld);
return;
}
if (liste.value.indexOf('depot:') === 0) {
const wert = liste.value;
// Die Beschriftung lautet „[Depotname] …“ – der Dialog nennt nur
// den Namen.
const name = liste.options[liste.selectedIndex].textContent
.trim().replace(/^\[/, '').replace(/\]\s*…?$/, '').trim();
schliessen();
wertpapierOeffnen(feld, wert, name);
return;
}
// Eine Rückfrage wegen verlorener Splitzeilen braucht es hier nicht mehr:
// Für eine aufgeteilte Buchung öffnet sich diese Liste gar nicht erst.
const wert = liste.value;
const beschriftung = beschriftungAus(liste);
schnellSpeichern(auswahl, function (ergebnis) {
feld.dataset.wert = wert;
feld.title = beschriftung !== '' ? beschriftung : 'Kategorie oder Gegenkonto zuordnen';
feld.textContent = beschriftung;
if (ergebnis.bargeldHtml) {
const bargeldzelle = feld.closest('tr').querySelector('.bargeldspalte');
if (bargeldzelle) {
bargeldzelle.innerHTML = ergebnis.bargeldHtml;
}
}
});
});
/* 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;
}
const feld = offenesFeld;
const wert = klassenliste.value;
const beschriftung = beschriftungAus(klassenliste);
schnellSpeichern(klassenauswahl, function () {
feld.dataset.klasse = wert === '' ? '0' : wert;
feld.title = beschriftung !== '' ? beschriftung : 'Klasse zuordnen';
feld.textContent = beschriftung;
});
});
}
// Escape und der Klick daneben verwerfen die Auswahl.
document.addEventListener('keydown', function (ereignis) {
if (ereignis.key === 'Escape' && offenesFeld) {
schliessen();
}
});
document.addEventListener('click', function (ereignis) {
if (!(ereignis.target instanceof Element) || !offenesFeld) {
return;
}
if (!ereignis.target.closest('.zuordnungszelle, .klassenzelle')) {
schliessen();
}
});
/* ── Splitdialog ─────────────────────────────────────────────────────── */
if (!dialog || !splitformular || !window.FmBetrag) {
return;
}
const zeilenkasten = document.getElementById('splitdialogzeilen');
const summenfeld = document.getElementById('splitdialogsumme');
const loeschknopf = document.getElementById('splitloeschen');
const speichernknopf = dialog.querySelector('button[type="submit"].primaer');
const vorlage = zeilenkasten.innerHTML;
let buchungsbetrag = 0;
function felder() {
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag'));
}
// Gesperrt nur, solange Beträge stehen, deren Summe den Buchungswert
// verfehlt. Bleiben alle Zeilen leer, hebt „Splitbuchung speichern“ die
// Aufteilung auf – das bleibt erlaubt.
function rechneSumme() {
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
summenfeld.textContent = stand.text;
summenfeld.classList.toggle('negativ', stand.belegt && stand.rest !== 0);
if (speichernknopf) {
speichernknopf.disabled = stand.belegt && stand.rest !== 0;
}
}
/* Eine weitere Zeile: die letzte kopieren, leeren und neu durchnummerieren.
* Die Namen tragen den Index (`split[3][betrag]`) – ohne Umnummerierung
* überschriebe die Kopie ihre Vorlage. */
function zeileAnhaengen() {
const letzte = zeilenkasten.lastElementChild;
const kopie = letzte.cloneNode(true);
const nr = zeilenkasten.children.length;
kopie.querySelectorAll('[name]').forEach(function (feld) {
feld.name = feld.name.replace(/^split\[\d+\]/, 'split[' + nr + ']');
if (feld.tagName === 'SELECT') {
feld.selectedIndex = 0;
} else {
feld.value = '';
}
});
zeilenkasten.appendChild(kopie);
return kopie;
}
function splitdialogOeffnen(feld) {
splitformular.action = ziel(feld, 'splitbuchung');
buchungsbetrag = Number(feld.dataset.betrag || '0');
document.getElementById('splitbezeichnung').textContent = feld.dataset.bezeichnung || '';
document.getElementById('splitbuchungsbetrag').textContent =
window.FmBetrag.formatiere(buchungsbetrag);
// Immer mit leeren Zeilen beginnen: Der Dialog gilt für alle Buchungen
// der Seite, die Zeilen der vorigen dürfen nicht stehen bleiben.
zeilenkasten.innerHTML = vorlage;
const vorhandene = JSON.parse(feld.dataset.splits || '[]');
// Zu löschen gibt es nur, was schon aufgeteilt ist. Beim erstmaligen
// Aufteilen wäre der Knopf ohne Wirkung – und ohne Sinn.
loeschknopf.hidden = vorhandene.length === 0;
vorhandene.forEach(function (zeile, nr) {
const tr = zeilenkasten.children[nr] || zeileAnhaengen();
tr.querySelector('select[name$="[zuordnung]"]').value = zeile.zuordnung || '';
tr.querySelector('select[name$="[klasse_id]"]').value = zeile.klasse_id
? String(zeile.klasse_id)
: '';
tr.querySelector('input[name$="[notiz]"]').value = zeile.notiz || '';
tr.querySelector('input[name$="[betrag]"]').value = zeile.betrag || '';
});
rechneSumme();
dialog.showModal();
}
zeilenkasten.addEventListener('input', function (ereignis) {
if (ereignis.target.classList.contains('splitbetrag')) {
rechneSumme();
}
});
document.getElementById('splitzeileMehr').addEventListener('click', function () {
zeileAnhaengen().querySelector('select').focus();
});
document.getElementById('splitabbrechen').addEventListener('click', function () {
dialog.close();
});
})();