Kontenzuordnung als dritter Einrichtungsschritt: Welches Bankkonto gehört zu welchem eigenen Konto? Umsätze merken sich über `bank_konto_id`, woher sie stammen, und ziehen mit, wenn die Zuordnung erst nach einem Abruf fällt. Abgerufene Umsätze buchen sich auf Wunsch selbst (`Services\Bankabgleich`). Im Kontoblatt lassen sich Buchungen sammelweise löschen; Statusmeldungen liegen jetzt über der Ansicht, statt den Inhalt springen zu lassen. Dazu drei Fehler, die zusammen dafür sorgten, dass abgerufene Umsätze spurlos verschwanden – der Abruf meldete sie als vorhanden, das Kontoblatt zeigte nichts, und in der Vorschlagsliste standen sie ebenfalls nicht: - Eine gelöschte Buchung ließ ihren Umsatz auf „uebernommen“ stehen. Der Fremdschlüssel setzte nur `buchung_id` auf NULL. `Buchung::loeschen()` gibt den Umsatz jetzt vorher frei, `Bankumsatz::verwaisteOeffnen()` fängt die Wege ab, die daran vorbeiführen – Kaskaden lösen in MariaDB keine Trigger aus. - Ein Bankkonto entstand doppelt, wenn die Bank die IBAN nicht bei jedem Dialog mitliefert. Gesucht wird jetzt über die Kontonummer; eine nachgelieferte IBAN wird am vorhandenen Eintrag nachgetragen. - Der Fingerabdruck hing an der IBAN und kippte mit ihr. Er nimmt jetzt die `bank_konto_id`, die fest steht, solange es den Bankzugang gibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
9.8 KiB
JavaScript
259 lines
9.8 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];
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
});
|
||
|
||
// 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.
|
||
document.querySelectorAll('.meldung').forEach(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);
|
||
}
|
||
});
|
||
})();
|