Damit ist der geplante Funktionsumfang beisammen. Phase 4 abgeschlossen - Konfigurierbare Cockpit-Kacheln; abgewählte werden nicht berechnet - Einnahmen/Ausgaben je Monat als Balkenpaare mit Saldolinie, aufgesetzt auf den Summenbericht statt auf eine eigene Abfrage - Meine Ausgaben als Ring auf der obersten Kategorieebene - Offene Vorgänge aus allen Modulen Phase 5 – Online-Banking - nemiah/php-fints als Fork auf Codeberg, psr/log direkt eingebaut: die Anwendung hängt an keinem fremden Update-Zyklus - PIN AES-256-GCM-verschlüsselt; Schlüssel aus APP_KEY oder aus data/schluessel/app_key, den der Entrypoint einmalig erzeugt - TAN-Dialog über mehrere Requests (FinTs::persist plus serialisierte Aktion in der Sitzung) – ein wartender Prozess wäre mit php-fpm der falsche Weg - Abgleich statt Direktbuchung: verbinden, übernehmen oder verwerfen - Regeln mit vier Vergleichsarten, Betragsgrenzen und Split-Schema Phase 6 – Wertpapiere - Depotbuchung und Geldbuchung als zwei Seiten eines Geschäfts; betrag ist der Bruttowert, Nebenkosten stehen daneben - Einstand nach gleitendem Durchschnitt, realisierte Gewinne, Erträge - Kursabruf über Leeway oder FMP, täglicher Lauf per Cron - QIF-Import für !Type:Invst Dabei behoben: Der Import buchte den Geldfluss jedes Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden; und er erkannte das Verrechnungskonto nicht, wenn dem Konto im L-Feld eine Kategorie vorangeht (bei Verkäufen also immer). Phase 7 – Nebenmodule und Zahlungsverkehr - Buchungsvorlagen inklusive QIF-Import, Splits werden mitskaliert - Regelmäßige Buchungen mit Fälligkeitslauf, Finanzkalender, Erinnerungen - Adressen mit IBAN-Prüfung, Verträge mit Kündigungsfrist, Sparziele - Kredite mit gerechnetem Tilgungsplan - Überweisungen über FinTS; die Buchung entsteht erst nach Annahme durch die Bank. Die pain.001-Nachricht erzeugt SepaXml selbst Nebenbei: .dockerignore ergänzt (ohne sie brach der Build an data/mariadb ab), PlatzhalterController entfernt, zwei Inline-Handler beseitigt, die die Content-Security-Policy ohnehin blockierte. 888 PHP-Prüfungen und 45 E2E-Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
187 lines
6.7 KiB
JavaScript
187 lines
6.7 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';
|
||
|
||
// Formulare mit data-bestaetigen fragen vor dem Absenden nach.
|
||
// 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 = 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];
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Meldungen nach kurzer Zeit ausblenden – Fehler bleiben stehen.
|
||
// Ü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.
|
||
document.querySelectorAll('.meldung-erfolg').forEach(function (element) {
|
||
window.setTimeout(function () {
|
||
element.classList.add('verblasst');
|
||
window.setTimeout(function () { element.remove(); }, 400);
|
||
}, 5000);
|
||
});
|
||
})();
|