Liegen zwei Buchungen, die eigentlich derselbe Vorgang sind, zu weit auseinander oder gehörte eine ursprünglich einem anderen Ursprungskonto, findet der automatische Abgleich sie nicht – die Umbuchung und ihre einzeln erfasste Dublette stehen dann dauerhaft nebeneinander im Kontoblatt. Buchung::verknuepfen() führt eine Umbuchung (eigene Seite oder Gegenseite auf diesem Konto) und eine einzeln erfasste Buchung mit passendem Betrag zusammen: Die Umbuchung bleibt, die Dublette fällt weg, ihr Bankumsatz hängt sich an die Umbuchung. Im Kontoblatt lässt sich dafür jetzt auch die Gegenseite einer fremden Umbuchung ankreuzen; ein neuer Knopf „Verknüpfen“ nutzt dieselbe Auswahl wie „Auswahl löschen“. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
378 lines
15 KiB
JavaScript
378 lines
15 KiB
JavaScript
/* Finanzmanager – Frontend-Hilfen.
|
||
*
|
||
* Bewusst schlank und ohne Framework.
|
||
*
|
||
* Bestätigungen hängen an <form data-bestaetigen="…"> und nicht als onsubmit
|
||
* im HTML: Die Content-Security-Policy erlaubt keine Inline-Skripte, ein
|
||
* onsubmit-Attribut würde stillschweigend verworfen – und dann löschte der
|
||
* Knopf ohne Rückfrage.
|
||
*/
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// Die Marke klappt die Seitenleiste auf Symbolbreite zusammen und wieder
|
||
// auf. Der Zustand steht in einem Cookie und nicht im localStorage: So
|
||
// rendert der Server die Leiste schon in der richtigen Breite, statt sie
|
||
// bei jedem Seitenwechsel kurz breit aufblitzen zu lassen.
|
||
const schalter = document.querySelector('[data-seitenleiste-schalter]');
|
||
const rahmen = document.querySelector('.rahmen');
|
||
if (schalter && rahmen) {
|
||
schalter.addEventListener('click', function () {
|
||
const eingeklappt = rahmen.classList.toggle('eingeklappt');
|
||
|
||
schalter.setAttribute('aria-expanded', eingeklappt ? 'false' : 'true');
|
||
schalter.title = eingeklappt ? 'Seitenleiste ausklappen' : 'Seitenleiste einklappen';
|
||
|
||
document.cookie = 'seitenleiste=' + (eingeklappt ? 'eingeklappt' : 'offen')
|
||
+ '; path=/; max-age=31536000; samesite=lax';
|
||
});
|
||
}
|
||
|
||
// Formulare mit data-bestaetigen fragen vor dem Absenden nach. Trägt der
|
||
// gedrückte Knopf selbst ein data-bestaetigen, gilt seine Frage – so
|
||
// können mehrere Knöpfe im selben Formular (verschiedene Ziele über
|
||
// formaction) je ihre eigene Rückfrage stellen.
|
||
// In der Capture-Phase, damit die Rückfrage auch dann greift, wenn ein
|
||
// anderer Handler das Ereignis unterwegs stoppt.
|
||
document.addEventListener('submit', function (ereignis) {
|
||
const formular = ereignis.target;
|
||
if (!(formular instanceof HTMLFormElement)) {
|
||
return;
|
||
}
|
||
|
||
const frage = (ereignis.submitter && ereignis.submitter.dataset.bestaetigen)
|
||
|| formular.dataset.bestaetigen;
|
||
if (frage && !window.confirm(frage)) {
|
||
ereignis.preventDefault();
|
||
ereignis.stopPropagation();
|
||
}
|
||
}, true);
|
||
|
||
// Kategoriebaum im Budgetplaner auf- und zuklappen. Die Zeilen kennen ihre
|
||
// Elternkategorie über data-eltern; von dort aus lässt sich der Teilbaum
|
||
// einsammeln, ohne ihn im HTML zu verschachteln (in einer Tabelle ginge das
|
||
// ohnehin nicht).
|
||
document.querySelectorAll('.klappknopf').forEach(function (knopf) {
|
||
knopf.setAttribute('aria-expanded', 'true');
|
||
|
||
knopf.addEventListener('click', function () {
|
||
const wurzel = knopf.dataset.klappt;
|
||
const offen = knopf.getAttribute('aria-expanded') !== 'false';
|
||
knopf.setAttribute('aria-expanded', offen ? 'false' : 'true');
|
||
|
||
const zeilen = Array.from(document.querySelectorAll('tr.kategoriezeile'));
|
||
const betroffen = new Set([wurzel]);
|
||
|
||
zeilen.forEach(function (zeile) {
|
||
const eltern = zeile.dataset.eltern;
|
||
if (eltern && betroffen.has(eltern)) {
|
||
betroffen.add(zeile.dataset.kategorie);
|
||
zeile.classList.toggle('zugeklappt', offen);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// Zahnrad im Budgetplaner: verteilt den eingegebenen Wert über die Zeile,
|
||
// wahlweise auf das ganze oder nur auf das restliche Jahr. Gearbeitet wird
|
||
// auf den Eingabefeldern, nicht auf der Datenbank – so ist das Ergebnis vor
|
||
// dem Speichern zu sehen und geht mit demselben Knopf weg wie alles andere.
|
||
//
|
||
// Das Menü gibt es nur einmal und wandert in die angeklickte Zelle: Ein
|
||
// eigenes Menü je Feld wären zwölf Stück pro Kategoriezeile, und ein frei
|
||
// positioniertes bräuchte ein style-Attribut, das die
|
||
// Content-Security-Policy verwirft.
|
||
const planer = document.querySelector('table.budgetplaner');
|
||
if (planer) {
|
||
const menue = document.createElement('div');
|
||
menue.className = 'zahnradmenue';
|
||
|
||
let feld = null;
|
||
let zahnrad = null;
|
||
|
||
function schliessen() {
|
||
if (menue.parentNode) {
|
||
menue.parentNode.removeChild(menue);
|
||
}
|
||
if (zahnrad) {
|
||
zahnrad.setAttribute('aria-expanded', 'false');
|
||
}
|
||
feld = null;
|
||
zahnrad = null;
|
||
}
|
||
|
||
function uebertragen(abMonat) {
|
||
const zeile = feld.closest('tr');
|
||
const wert = feld.value;
|
||
|
||
zeile.querySelectorAll('input.betragsfeld').forEach(function (anderes) {
|
||
if (Number(anderes.dataset.monat) >= abMonat) {
|
||
anderes.value = wert;
|
||
}
|
||
});
|
||
|
||
schliessen();
|
||
}
|
||
|
||
[
|
||
{ text: 'Ganzes Jahr', ab: function () { return 1; } },
|
||
{ text: 'Restliches Jahr', ab: function (monat) { return monat + 1; } }
|
||
].forEach(function (eintrag) {
|
||
const knopf = document.createElement('button');
|
||
knopf.type = 'button';
|
||
knopf.className = 'menueeintrag';
|
||
knopf.textContent = eintrag.text;
|
||
|
||
knopf.addEventListener('click', function () {
|
||
if (feld) {
|
||
uebertragen(eintrag.ab(Number(feld.dataset.monat)));
|
||
}
|
||
});
|
||
|
||
menue.appendChild(knopf);
|
||
});
|
||
|
||
planer.addEventListener('click', function (ereignis) {
|
||
const knopf = ereignis.target.closest('.zahnrad');
|
||
if (!knopf) {
|
||
return;
|
||
}
|
||
|
||
const zelle = knopf.closest('td');
|
||
const warOffen = menue.parentNode === zelle;
|
||
schliessen();
|
||
if (warOffen) {
|
||
return;
|
||
}
|
||
|
||
feld = zelle.querySelector('input.betragsfeld');
|
||
zahnrad = knopf;
|
||
knopf.setAttribute('aria-expanded', 'true');
|
||
zelle.appendChild(menue);
|
||
menue.firstChild.focus();
|
||
});
|
||
|
||
// Klick daneben oder Escape schließt das Menü wieder.
|
||
document.addEventListener('click', function (ereignis) {
|
||
if (!(ereignis.target instanceof Element)) {
|
||
return;
|
||
}
|
||
if (!ereignis.target.closest('.zahnrad, .zahnradmenue')) {
|
||
schliessen();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', function (ereignis) {
|
||
if (ereignis.key === 'Escape') {
|
||
schliessen();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Empfängerauswahl im Zahlungsverkehr: Die Adresse füllt Name, IBAN und BIC.
|
||
// Geändert werden darf danach alles – die Auswahl ist eine Abkürzung, keine
|
||
// Bindung.
|
||
const adressauswahl = document.querySelector('[data-adressauswahl]');
|
||
if (adressauswahl) {
|
||
adressauswahl.addEventListener('change', function () {
|
||
const gewaehlt = adressauswahl.selectedOptions[0];
|
||
if (!gewaehlt || !gewaehlt.value) {
|
||
return;
|
||
}
|
||
|
||
const felder = {
|
||
empfaenger: gewaehlt.dataset.name,
|
||
iban: gewaehlt.dataset.iban,
|
||
bic: gewaehlt.dataset.bic
|
||
};
|
||
|
||
Object.keys(felder).forEach(function (name) {
|
||
const feld = document.getElementById(name);
|
||
if (feld && felder[name]) {
|
||
feld.value = felder[name];
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Echtzeitüberweisung: Sie geht sofort hinaus, ein Ausführungstermin hat
|
||
// daneben keinen Sinn. Das Feld wird gesperrt, solange sie gewählt ist –
|
||
// die Prüfung beim Speichern bleibt trotzdem die maßgebliche.
|
||
const echtzeit = document.querySelector('[data-echtzeit]');
|
||
const termin = document.getElementById('ausfuehrung_am');
|
||
if (echtzeit && termin) {
|
||
const abgleichen = function () {
|
||
termin.disabled = echtzeit.checked;
|
||
if (echtzeit.checked) {
|
||
termin.value = '';
|
||
}
|
||
};
|
||
echtzeit.addEventListener('change', abgleichen);
|
||
abgleichen();
|
||
}
|
||
|
||
// Kontenzuordnung einer Bankverbindung: Die Felder für ein neues Konto
|
||
// stehen in einer eigenen Tabellenzeile und werden erst sichtbar, wenn
|
||
// „neues Konto anlegen“ gewählt ist. Ausgeblendet wird hier und nicht im
|
||
// HTML – ohne JavaScript sollen die Felder da sein, sonst ließe sich kein
|
||
// Konto anlegen.
|
||
document.querySelectorAll('select[data-neues-konto]').forEach(function (auswahl) {
|
||
const zeile = document.getElementById(auswahl.dataset.neuesKonto);
|
||
if (!zeile) {
|
||
return;
|
||
}
|
||
|
||
const anpassen = function () {
|
||
zeile.hidden = auswahl.value !== 'neu';
|
||
};
|
||
|
||
auswahl.addEventListener('change', anpassen);
|
||
anpassen();
|
||
});
|
||
|
||
// Mehrfachauswahl in Listen: Das Ankreuzfeld in der Titelzeile schaltet alle
|
||
// angezeigten Zeilen, der Knopf im zugehörigen Formular zählt die Auswahl
|
||
// mit und bleibt ohne Auswahl gesperrt. Ohne JavaScript funktioniert alles
|
||
// weiter – dann ist der Knopf offen und der Controller weist eine leere
|
||
// Auswahl ab.
|
||
document.querySelectorAll('.alle-waehlen').forEach(function (alle) {
|
||
const formular = document.getElementById(alle.dataset.waehlt || '');
|
||
if (!formular) {
|
||
return;
|
||
}
|
||
|
||
const kaestchen = Array.from(
|
||
document.querySelectorAll('input[type="checkbox"][form="' + formular.id + '"]')
|
||
);
|
||
const knopf = formular.querySelector('button[type="submit"]');
|
||
const beschriftung = knopf ? knopf.textContent : '';
|
||
|
||
const anpassen = function () {
|
||
const anzahl = kaestchen.filter(function (k) { return k.checked; }).length;
|
||
|
||
alle.checked = anzahl > 0 && anzahl === kaestchen.length;
|
||
alle.indeterminate = anzahl > 0 && anzahl < kaestchen.length;
|
||
|
||
if (knopf) {
|
||
knopf.disabled = anzahl === 0;
|
||
knopf.textContent = anzahl === 0 ? beschriftung : beschriftung + ' (' + anzahl + ')';
|
||
}
|
||
// Die Rückfrage nennt die Anzahl; ihr Wortlaut steht in der Ansicht.
|
||
const vorlage = anzahl === 1
|
||
? formular.dataset.frageEine
|
||
: formular.dataset.frageMehrere;
|
||
if (vorlage) {
|
||
formular.dataset.bestaetigen = vorlage.replace('{anzahl}', String(anzahl));
|
||
}
|
||
};
|
||
|
||
alle.addEventListener('change', function () {
|
||
kaestchen.forEach(function (k) { k.checked = alle.checked; });
|
||
anpassen();
|
||
});
|
||
kaestchen.forEach(function (k) { k.addEventListener('change', anpassen); });
|
||
anpassen();
|
||
});
|
||
|
||
// Formatwahl von Import und Export. Die Kachel in der Verwaltung führt zwei
|
||
// Knöpfe; welches Dateiformat gemeint ist, fragt dieser Dialog ab und hängt
|
||
// es dann an die Zieladresse. Die Karten liefert die Ansicht mit – hier
|
||
// stehen nur die Ziele.
|
||
//
|
||
// Kennt der Browser <dialog> nicht, bleibt der Knopf ein gewöhnlicher Link
|
||
// und führt auf dieselbe Auswahl als eigene Seite.
|
||
const formatdialog = document.getElementById('formatwahl');
|
||
if (formatdialog && typeof formatdialog.showModal === 'function') {
|
||
const titel = formatdialog.querySelector('[data-formatdialog-titel]');
|
||
|
||
document.querySelectorAll('[data-formatwahl]').forEach(function (knopf) {
|
||
knopf.addEventListener('click', function (ereignis) {
|
||
ereignis.preventDefault();
|
||
|
||
const ziel = knopf.getAttribute('href');
|
||
if (titel) {
|
||
titel.textContent = knopf.dataset.formatwahl + ': Format wählen';
|
||
}
|
||
|
||
formatdialog.querySelectorAll('.formatkarte').forEach(function (karte) {
|
||
karte.setAttribute('href', ziel + '?format=' + karte.dataset.format);
|
||
});
|
||
|
||
formatdialog.showModal();
|
||
});
|
||
});
|
||
}
|
||
|
||
// Übermittlungsprotokoll im Zahlungsverkehr: Ein Klick auf den Status holt das
|
||
// Protokoll der Überweisung in einen modalen Dialog. Ohne <dialog> oder
|
||
// fetch bleibt der Status ein Link auf dieselbe Ansicht als eigene Seite.
|
||
const protokolldialog = document.getElementById('protokolldialog');
|
||
if (protokolldialog && typeof protokolldialog.showModal === 'function' && window.fetch) {
|
||
const inhalt = protokolldialog.querySelector('[data-protokoll-inhalt]');
|
||
|
||
document.querySelectorAll('[data-protokoll]').forEach(function (link) {
|
||
link.addEventListener('click', function (ereignis) {
|
||
ereignis.preventDefault();
|
||
|
||
window.fetch(link.getAttribute('href'), {
|
||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||
credentials: 'same-origin'
|
||
}).then(function (antwort) {
|
||
if (!antwort.ok) { throw new Error(antwort.status); }
|
||
return antwort.text();
|
||
}).then(function (html) {
|
||
inhalt.innerHTML = html;
|
||
protokolldialog.showModal();
|
||
}).catch(function () {
|
||
window.location.href = link.getAttribute('href');
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// Meldungen ausblenden. Über eine Klasse, nicht über element.style: Die
|
||
// Content-Security-Policy (style-src 'self') verwirft auch per JavaScript
|
||
// gesetzte Inline-Styles, die Meldung bliebe sonst einfach stehen.
|
||
const ausblenden = function (element) {
|
||
element.classList.add('verblasst');
|
||
window.setTimeout(function () { element.remove(); }, 400);
|
||
};
|
||
|
||
// Erfolgsmeldungen gehen von selbst; Fehler und Warnungen bleiben stehen,
|
||
// bis sie weggeklickt werden. Sie liegen über der Ansicht – ohne diesen Weg
|
||
// verdeckten sie die Werkzeugleiste bis zum nächsten Seitenwechsel.
|
||
const einrichten = function (element) {
|
||
element.title = 'Zum Ausblenden anklicken';
|
||
element.addEventListener('click', function () { ausblenden(element); });
|
||
|
||
if (element.classList.contains('meldung-erfolg')) {
|
||
window.setTimeout(function () { ausblenden(element); }, 5000);
|
||
}
|
||
};
|
||
|
||
document.querySelectorAll('.meldung').forEach(einrichten);
|
||
|
||
// Für Ansichten, die eine Meldung nachträglich zeigen, ohne die Seite neu
|
||
// zu laden – etwa das Kontoblatt nach einer Schnellzuordnung
|
||
// (kontoblatt.js). Den Meldungsbereich selbst gibt es im HTML nur, wenn
|
||
// der Server ihn beim Laden der Seite mitgab; hier entsteht er bei Bedarf.
|
||
window.FmMeldungen = {
|
||
zeigen: function (text, typ) {
|
||
let bereich = document.querySelector('.meldungen');
|
||
if (!bereich) {
|
||
bereich = document.createElement('div');
|
||
bereich.className = 'meldungen';
|
||
document.body.appendChild(bereich);
|
||
}
|
||
|
||
const element = document.createElement('div');
|
||
element.className = 'meldung meldung-' + (typ || 'erfolg');
|
||
element.setAttribute('role', 'status');
|
||
element.textContent = text;
|
||
bereich.appendChild(element);
|
||
|
||
einrichten(element);
|
||
}
|
||
};
|
||
})();
|