Die Klassenspalte ist anklickbar wie die Zuordnung daneben: Ein Klick
öffnet die Klassenliste in der Zelle, die Auswahl wird sofort über
POST /buchungen/{id}/klasse gespeichert. Buchung::klasseSetzen() lässt
Kategorie, Gegenkonto und Splitzeilen unberührt.
Die Buchungszeilen sind jetzt alle zwei Textzeilen hoch, ihr Inhalt steht
senkrecht in der Mitte. Was nicht auf zwei Zeilen passt, wird
abgeschnitten; den vollständigen Text zeigt der Tooltip – bei Empfänger
und Buchungstext nur dort, wo wirklich etwas fehlt, über dem
Kategoriefeld einer Splitbuchung deren Zeilen mit Kategorie und Betrag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
317 lines
12 KiB
JavaScript
317 lines
12 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;
|
||
}
|
||
|
||
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.
|
||
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.
|
||
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();
|
||
});
|
||
})();
|