Bei einer Splitbuchung zeigt die Klassenspalte jetzt die gemeinsame Klasse der Splitzeilen. Unterscheiden sie sich, steht dort die Marke „Splitbuchung“, der Tooltip nennt je Splitzeile Kategorie und Klasse. Ein Klick auf die Klassenzelle einer Splitbuchung öffnet den Splitdialog. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
418 lines
16 KiB
JavaScript
418 lines
16 KiB
JavaScript
/* 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 vorlage = zeilenkasten.innerHTML;
|
||
let buchungsbetrag = 0;
|
||
|
||
function felder() {
|
||
return Array.from(zeilenkasten.querySelectorAll('.splitbetrag'));
|
||
}
|
||
|
||
function rechneSumme() {
|
||
const stand = window.FmBetrag.splitsumme(felder(), buchungsbetrag);
|
||
summenfeld.textContent = stand.text;
|
||
summenfeld.classList.toggle('negativ', 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();
|
||
});
|
||
})();
|