Das Ändern von Kategorie oder Klasse im Kontoblatt lud bisher die ganze Seite neu, um die Zeile zu aktualisieren – dabei sprang der Bildschirm nach ganz oben. kontoblatt.js schickt die Auswahl jetzt per fetch im Hintergrund; Kategorie, Klasse und das Symbol der Bargeld-Abgleichspalte aktualisieren sich an Ort und Stelle, die Bildlaufposition bleibt stehen. Ohne JavaScript bleibt es bei der bisherigen Weiterleitung.
386 lines
14 KiB
JavaScript
386 lines
14 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.
|
||
*/
|
||
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 (!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;
|
||
|
||
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();
|
||
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();
|
||
});
|
||
})();
|