Finanzmanager/public/static/kontoblatt.js
fds2610-bot ca8496f770 Kontoblatt: Klasse direkt auswählen, Zeilen einheitlich zweizeilig
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>
2026-08-17 22:04:48 +02:00

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