/* 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 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 . 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(); 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(); }); })();