Die Marke oben in der Seitenleiste trägt jetzt das Coinstack-Symbol aus den Altdaten und den zweifarbigen Schriftzug Finanz|manager – wie die sidebar-brand im Steuerkram. Sie ist zugleich der Schalter: Ein Klick schiebt die Leiste von 232 px auf 64 px zusammen, ein Klick auf das Symbol klappt sie wieder auf. Die Menüpunkte behalten dabei Ziel und Symbol. 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. Die Beschriftungen wandern eingeklappt per clip-path aus dem Bild statt per display:none – sonst verlören die Verweise ihren Namen für Vorleseprogramme. Nebenbei: Die Marke hieß bisher .marke, wie die kleine Statusmarke weiter unten im Stilblatt, deren Regeln sie überschrieben. Sie heißt jetzt .seitenleisten-marke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
12 KiB
JavaScript
306 lines
12 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.
|
||
// 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();
|
||
});
|
||
|
||
// 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();
|
||
});
|
||
});
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
});
|
||
})();
|