Finanzmanager/public/static/kontoblatt.js
fds2610-bot e56c21bc9b Wertpapiergeschäft aus einer Kontobuchung
Der Bankabruf bringt die Wertpapierabrechnung als Umsatz auf dem Girokonto, und
erst dort entscheidet sich, dass es ein Kauf war. Wer die Buchung einem Depot
zuordnete, buchte bisher eine Umbuchung – ohne Stückzahl, ohne Kurs, ohne
Einstand. Die Zuordnungsliste im Kontoblatt kennt jetzt neben Kategorie und
Gegenkonto eine dritte Gruppe "Wertpapiergeschäft im Depot", die statt zu
speichern die Wertpapiermaske öffnet; ohne JavaScript führt derselbe Weg auf
/buchungen/{id}/wertpapier.

Die Maske ist auf den "Wertpapier kaufen"-Dialog des FinanzManagers
zugeschnitten: vorne Vorgangsart, Datum, Wertpapier, Stück und Kurs, dahinter
hinter dem Knopf "Kosten …" die Nebenkosten nach Arten – sechs Gebühren und
sechs Steuern. ISIN und WKN werden aus dem Verwendungszweck gelesen, ein
bekanntes Papier ist damit vorausgewählt. Ein Abfluss vom Konto kann nur ein
Kauf sein, ein Zufluss ein Verkauf oder eine Dividende.

Der Buchungsbetrag ist die feste Größe: Die Nebenkosten werden aus ihm
herausgerechnet, was übrig bleibt ist der Kurswert und damit der
Einstandspreis. Im Kontoblatt entsteht eine Splitbuchung mit je einer Zeile pro
belegter Kostenart und dem Rest als Umbuchung ins Depot; ohne Nebenkosten
bleibt es bei der schlichten Umbuchung. Die Depotbuchung hängt sich an die
vorhandene Buchung, statt eine zweite anzulegen (neue Spalte
depot_buchungen.buchung_erzeugt) – wird sie gelöscht, bleibt die Buchung der
Bank stehen und ist nur wieder unzugeordnet.

Die Kategorien der Kostenarten liegen im Einstellungsdialog unter "Wertpapiere",
einmalig je Art; für "Sonstige Kosten" ist die Einstellung nur die Vorgabe und
im Dialog überschreibbar. Arten ohne Kategorie werden zu Splitzeilen ohne
Zuordnung, nicht zu einem Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:27:52 +02:00

245 lines
8.6 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: Kategorie oder Gegenkonto an Ort und Stelle zuordnen.
*
* Ein Klick auf die Kategoriespalte öffnet die Auswahlliste in der Zelle. Sie
* 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; die Zuordnung ist
* dort wie bisher zu ändern.
*/
(function () {
'use strict';
const tabelle = document.querySelector('table.kontoblatt');
const auswahl = document.getElementById('zuordnungsauswahl');
if (!tabelle || !auswahl) {
return;
}
const abfrage = tabelle.dataset.abfrage || '';
const liste = auswahl.querySelector('select');
const dialog = document.getElementById('splitdialog');
const splitformular = document.getElementById('splitformular');
// Das Feld, das gerade durch die Auswahlliste ersetzt ist.
let offenesFeld = null;
function ziel(feld, aktion) {
return '/buchungen/' + feld.dataset.buchung + '/' + aktion + abfrage;
}
function schliessen() {
if (!offenesFeld) {
return;
}
offenesFeld.hidden = false;
offenesFeld = null;
auswahl.hidden = true;
document.body.appendChild(auswahl);
}
/* 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;
}
}
function oeffnen(feld) {
schliessen();
offenesFeld = feld;
feld.hidden = true;
auswahl.action = ziel(feld, 'zuordnung');
liste.value = feld.dataset.wert || '';
feld.parentNode.appendChild(auswahl);
auswahl.hidden = false;
liste.focus();
// Die Liste soll sofort aufklappen; showPicker gibt es nicht überall.
try {
liste.showPicker();
} catch (fehler) {
/* Dann klappt sie beim ersten Klick auf – kein Verlust. */
}
}
tabelle.addEventListener('click', function (ereignis) {
// 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 || ereignis.metaKey || ereignis.ctrlKey || ereignis.shiftKey) {
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);
});
/* 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) {
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();
});
// 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')) {
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();
});
})();