// -----------------------------
// Projektübersicht — Bereich „Admin"
// -----------------------------
// Der Bereich #admin bündelt alles, was NUR Administratoren zusteht. Er ist
// aus dem Bereich „Einstellungen" herausgelöst worden: Dort standen die
// firmenweiten Masken zunächst neben „Mein Konto", was zwei verschiedene
// Dinge vermischte — die eigenen Kontoangaben (jedes Konto) und Vorgaben, die
// die Zahlen ALLER verschieben. „Einstellungen" enthält seitdem nur noch das
// eigene Konto, alles Firmenweite liegt hier.
//
// Der Bereich zerfällt in ABSCHNITTE, zwischen denen die aufgeklappte Gruppe
// „Admin" in der Seitenleiste umschaltet (ADMIN_SECTIONS unten,
// showAdminSection()). Sichtbar ist immer genau einer; der gewählte steht im
// URL-Fragment (…/Admin#auslastung) und ist damit verlinkbar — dasselbe
// Muster wie die Bereichsleiste des Projektbereichs (js/plan/plan-sections.js).
//
// „Benutzerverwaltung" — Konten, Rollen, Wochenstunden, Abwesenheiten
// (js/admin-users.js). War bis zum Umbau eine
// eigene Seite (admin-users.html) mit nachgebauter
// Kopfzeile statt der Seitenleiste.
// „Honorar" — Stundensatz und Anteile je Leistungsphase.
// „Auslastung" — Wochenstunden einer vollen Stelle, Ampel-
// Schwellen, Balkenskala, Aufwandsvorlage.
// „Termine & Sitzungen" — Arbeitsstunden je Tag, automatische Sperre.
// „Feiertage" — arbeitsfreie Tage (eigene Tabelle).
// „Aufgaben-Vorlagen" — Standardschritte je Leistungsphase.
// „Betrieb & Sicherheit" — Backups, Sitzungsdauer, Anmeldebremse.
// „Datenbank & Sitzungen"— Backup herunterladen, Backup einspielen,
// Sitzung löschen. Diese drei standen vorher als
// nackte Menüpunkte in der Seitenleiste, wo ein
// Fehlklick genügte; hier stehen sie mit dem
// Hinweis, was sie anrichten.
//
// Honorar, Auslastung, Termine und Betrieb waren bis zum Ausbau der
// Einstellungen Konstanten im Quelltext — teils im Frontend
// (Aufwandsvorlage, Ampel-Schwellen, Balkenskala, Arbeitsstunden je Tag),
// teils im Server (Backups, Sitzungsdauer, Anmeldebremse). Sie sind nicht
// deshalb gepflegt, weil sie sich oft ändern, sondern weil sie die Zahlen
// aller Nutzer verschieben. Gelesen werden sie über GET /app-settings (jedes
// Konto — das Frontend rechnet damit), geschrieben über PUT /app-settings.
//
// Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist
// wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt
// serverseitig in requireRole('admin') auf PUT /app-settings,
// PUT /honorar-config, POST/DELETE /lph-templates, POST/DELETE /holidays,
// GET /backup, POST /import und POST /delete-week.
// ADMIN_SECTIONS: die Abschnitte in der Reihenfolge der Seitenleiste. Die
// EINE Stelle, an der ein Abschnitt definiert wird — Schlüssel (zugleich
// URL-Fragment) und Überschrift. Die Zuordnung zum Markup läuft über das
// Attribut data-admin-section, sowohl an der Karte in App.html als auch am
// Unterpunkt in der Seitenleiste; ein neuer Abschnitt braucht damit nur einen
// Eintrag hier plus die Karte. `wide` hebt die Breitenbegrenzung des Rasters
// auf — die Benutzertabelle hat zehn Spalten und scrollte sonst quer.
const ADMIN_SECTIONS = [
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true },
{ key: 'honorar', title: 'Honorar' },
{ key: 'auslastung', title: 'Auslastung' },
{ key: 'termine', title: 'Termine & Sitzungen' },
{ key: 'feiertage', title: 'Feiertage' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
];
// adminSection: der gerade sichtbare Abschnitt.
let adminSection = 'benutzer';
// adminPlanCheckerMerkmale: zuletzt geladene Plan-Checker-Merkmale-Liste
// (siehe Abschnitt "Plan-Checker-Merkmale" weiter unten).
let adminPlanCheckerMerkmale = [];
// renderAdminArea: Einstiegspunkt des Bereichs, von showTab('admin')
// gerufen. Befüllt alle Masken und wählt danach den sichtbaren Abschnitt.
// Keine Parameter, keine Rückgabe.
function renderAdminArea(){
if(!isAdminUser()) return;
loadAdminHonorar();
loadAdminTemplates();
loadAdminPlanCheckerMerkmale();
// Die firmenweiten Zahlen und Feiertage stehen bereits in APP_SETTINGS bzw.
// HOLIDAYS (beim Seitenstart geladen, js/store.js) — die Masken füllen sich
// also ohne einen weiteren Serveraufruf.
renderAppSettingFields();
renderAdminHolidays();
renderAdminSitzungen();
const fromHash = (location.hash || '').replace(/^#/, '');
showAdminSection(adminSectionExists(fromHash) ? fromHash : adminSection);
}
// isAdminUser: Kurzform für die Sichtbarkeit des Bereichs.
// Rückgabe: Boolean.
function isAdminUser(){
return !!(currentUser && currentUser.role === 'admin');
}
// adminSectionExists: prüft, ob ein Schlüssel ein bekannter Abschnitt ist.
// Parameter: key. Rückgabe: Boolean.
function adminSectionExists(key){
return ADMIN_SECTIONS.some(x => x.key === key);
}
// showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige
// Karte ein, alle anderen aus, hebt den Unterpunkt hervor und schreibt den
// Schlüssel ins URL-Fragment (teilbarer Link, ohne je Klick einen
// History-Eintrag zu erzeugen). Wird der Bereich gerade gar nicht angezeigt
// (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt.
// Parameter: key. Keine Rückgabe.
function showAdminSection(key){
if(!adminSectionExists(key)) key = ADMIN_SECTIONS[0].key;
adminSection = key;
if(currentTab !== 'admin'){ showTab('admin'); return; }
ADMIN_SECTIONS.forEach(sec => {
// querySelectorAll, nicht -Single: ein Abschnitt kann grundsätzlich aus
// mehreren Karten bestehen, die zusammen ein-/ausgeblendet werden.
document.querySelectorAll('#admin [data-admin-section="' + sec.key + '"]').forEach(card => {
card.style.display = (sec.key === key) ? '' : 'none';
});
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
if(item) item.classList.toggle('is-active', sec.key === key);
});
const sec = ADMIN_SECTIONS.find(x => x.key === key);
const title = document.getElementById('adminSectionTitle');
if(title && sec) title.textContent = 'Admin — ' + sec.title;
const grid = document.querySelector('#admin .settings-grid');
if(grid) grid.classList.toggle('is-wide', !!(sec && sec.wide));
// Die Benutzerliste wird beim Betreten des Abschnitts frisch geholt: Sie
// aendert sich auch ausserhalb dieses Browsers, und die uebrigen Masken
// rechnen mit ihr (Wochenstunden -> Auslastung).
if(key === 'benutzer' && typeof renderAdminUsers === 'function') renderAdminUsers();
try {
history.replaceState(null, '', location.pathname + location.search + '#' + key);
} catch(e) { /* z. B. file:// — dann bleibt die Adresse eben unverändert */ }
}
// toggleAdminNav: Kopf-Eintrag der Gruppe. Er klappt nicht nur auf und zu wie
// „Werkzeuge", sondern führt auch in den Bereich — die Unterpunkte sind hier
// Navigation innerhalb einer Seite, keine Aktionen, und ein Kopf, der beim
// Anklicken nichts zeigt, wäre irreführend. Steht man WOANDERS, führt ein
// Klick immer hinein und klappt auf; nur wer schon drin ist, klappt damit auf
// und zu. Keine Parameter, keine Rückgabe.
function toggleAdminNav(){
if(currentTab !== 'admin'){
showTab('admin'); // klappt über syncAdminNavState() auch auf
return;
}
toggleSidebarAccordion('admin');
}
// Ein von außen geändertes Fragment (Vor-/Zurück-Taste, getippter Anker)
// mitziehen — showAdminSection() selbst nutzt replaceState und löst dieses
// Ereignis daher nicht aus.
window.addEventListener('hashchange', () => {
if(currentTab !== 'admin') return;
const key = (location.hash || '').replace(/^#/, '');
if(adminSectionExists(key) && key !== adminSection) showAdminSection(key);
});
// ---------- Firmenweite Zahlen (PUT /app-settings) ----------
//
// Bedient die Abschnitte Auslastung, Termine & Sitzungen und Betrieb.
// APP_SETTING_FIELDS: Zuordnung Eingabefeld -> Einstellungs-Schlüssel, je
// Abschnitt. Eine Tabelle statt einzelner getElementById-Zeilen, damit Füllen
// und Speichern denselben Weg gehen und ein neues Feld nur hier auftaucht.
const APP_SETTING_FIELDS = {
auslastung: {
setVollzeitStunden: 'vollzeitStundenProWoche',
setSchwelleGelb: 'auslastungSchwelleGelb',
setSchwelleRot: 'auslastungSchwelleRot',
// Das frühere Feld "Maximum der Balkenskala" (auslastungBalkenMax) ist
// entfallen: Seit die Auslastung in Stunden rechnet, ist die Skala die
// Kapazität der Person — ein volles Band ist eine volle Woche. Die feste
// Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem
// Achtel gefüllt war; Balken und Ampel widersprachen einander.
setReferenzHonorar: 'referenzHonorar',
setVerteilWochen: 'verteilzeitraumWochen',
},
termine: {
setStundenProTag: 'arbeitsstundenProTag',
},
betrieb: {
setMaxBackups: 'maxBackups',
setSessionTage: 'sessionTage',
setLoginVersuche: 'loginMaxVersuche',
setLoginSperre: 'loginSperreMinuten',
},
};
// renderAppSettingFields: schreibt die aktuellen Werte aus APP_SETTINGS in
// alle Eingabefelder und baut die Aufwands-Matrix auf. Ein gerade
// fokussiertes Feld bleibt unangetastet, sonst spränge beim Tippen der Cursor.
// Keine Parameter, keine Rückgabe.
function renderAppSettingFields(){
Object.values(APP_SETTING_FIELDS).forEach(group => {
Object.entries(group).forEach(([elId, key]) => {
const el = document.getElementById(elId);
if(el && document.activeElement !== el) el.value = APP_SETTINGS[key];
});
});
const autoLock = document.getElementById('setAutoLock');
if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv);
renderAufwandMatrix();
}
// renderAufwandMatrix: die firmenweite Gewichtung als Tabelle Leistungsphase ×
// Rolle. „W+M" fehlt bewusst — das Aufwandsmodell kennt nur die
// Leistungsphasen (AUFWAND_LPS in js/load/load.js). Ein leeres Feld bedeutet
// „kein Eintrag"; dafür zählt die Auslastung dann den vollen Faktor 1,0.
// Keine Parameter, keine Rückgabe.
function renderAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
if(!wrap) return;
const vorlage = APP_SETTINGS.aufwandVorlage || {};
const phasen = LPHS.filter(l => l !== 'W+M');
wrap.innerHTML = `
`;
}
// collectAufwandMatrix: liest die Matrix aus den Feldern zurück. Leere Felder
// werden weggelassen (siehe renderAufwandMatrix), 0 ist ein gültiger Wert und
// bleibt erhalten. Rückgabe: Objekt LPH -> { Rolle: Faktor }.
function collectAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
const out = {};
if(!wrap) return out;
wrap.querySelectorAll('input[data-lph]').forEach(inp => {
if(inp.value.trim() === '') return;
const v = parseFloat(inp.value);
if(!Number.isFinite(v)) return;
const lph = inp.dataset.lph;
(out[lph] = out[lph] || {})[inp.dataset.role] = v;
});
return out;
}
// putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt
// die Antwort (der Server liefert immer den vollständigen, geprüften Stand
// zurück) in APP_SETTINGS. Danach rechnet die Anwendung sofort damit — ein
// Neuladen ist nicht nötig, deshalb wird die Auslastung gleich neu gezeichnet,
// falls sie gerade sichtbar ist. Parameter: patch (Objekt), erfolgsText.
// Rückgabe: keine.
function putAppSettings(patch, erfolgsText){
fetch(baseUrl() + '/app-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(patch),
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
APP_SETTINGS = Object.assign({}, APP_SETTINGS, json.settings || {});
renderAppSettingFields();
showAdminNote(erfolgsText);
})
.catch(err => {
console.error('Einstellungen speichern fehlgeschlagen:', err);
alert('Einstellungen konnten nicht gespeichert werden: ' + (err.message || err));
});
}
// fieldValues: sammelt die Zahlenwerte einer Gruppe aus APP_SETTING_FIELDS.
// Parameter: gruppe (Schlüssel in APP_SETTING_FIELDS). Rückgabe: Objekt.
function fieldValues(gruppe){
const patch = {};
Object.entries(APP_SETTING_FIELDS[gruppe]).forEach(([elId, key]) => {
const el = document.getElementById(elId);
if(el && el.value.trim() !== '') patch[key] = parseFloat(el.value);
});
return patch;
}
// saveAuslastungSettings: Schwellen, Skala, Vollzeit-Stunden und die
// Aufwandsvorlage in einem Zug. Keine Parameter, keine Rückgabe.
function saveAuslastungSettings(){
const patch = fieldValues('auslastung');
patch.aufwandVorlage = collectAufwandMatrix();
putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.');
}
// saveTerminSettings: Arbeitsstunden je Tag und die automatische Sperre.
// Keine Parameter, keine Rückgabe.
// juengsteWoche: die chronologisch letzte Sitzung — das ist die, die
// applyAutoLock() im Normalbetrieb offen hält und auf die db.currentWeek
// nach jedem vollständigen Neuladen zurückfällt. Rückgabe: Woche-Objekt oder
// undefined (keine Sitzungen vorhanden).
function juengsteWoche(){
return [...db.weeks].sort((a, b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))).pop();
}
// renderAdminSitzungen: listet alle Sitzungen (js/weeks.js: db.weeks),
// neueste zuerst, mit Datum, Zustand und den passenden Aktionen.
//
// Diese Liste ist seit dem Wegfall der Sitzungsauswahl im Menüband die
// einzige Stelle, die alle Sitzungen nebeneinander zeigt — die übrige
// Oberfläche zeigt immer nur die eine offene. Vier Zustände:
// "Geöffnet" — das ist gerade db.currentWeek UND zugleich die
// jüngste Sitzung: der Normalfall, keine Aktion
// nötig.
// "Geöffnet (Korrektur)" — db.currentWeek, aber NICHT die jüngste — man
// hat eine ältere Sitzung über "Öffnen" zur
// Bearbeitung geholt. "Fertig – wieder sperren"
// sperrt sie zurück UND wechselt die Oberfläche
// wieder auf die jüngste Sitzung, statt beides
// getrennten Handgriffen zu überlassen.
// "Gesperrt" — historisch; "Entsperren" macht sie für eine
// Korrektur kurz editierbar. Ist die Auto-Sperre
// aktiv, sperrt sie beim nächsten Neuladen (oder
// beim nächsten Anlegen/Duplizieren/Löschen
// einer Sitzung — applyAutoLock() läuft dabei
// überall) automatisch wieder.
// "Entsperrt" — editierbar, aber gerade nicht die angezeigte
// Sitzung. Kommt vor, wenn eine gesperrte
// entsperrt, aber noch nicht geöffnet wurde, wenn
// man von einer Korrektur zur jüngsten
// zurückgekehrt ist (die bleibt dabei entsperrt),
// oder wenn die Auto-Sperre firmenweit aus ist.
// "Öffnen" macht sie zur angezeigten Sitzung.
// Keine Parameter, keine Rückgabe.
function renderAdminSitzungen(){
const el = document.getElementById('adminSessionsList');
if(!el || !Array.isArray(db.weeks)) return;
const juengste = juengsteWoche();
const sortiert = [...db.weeks].sort((a, b) => weekSortKey(b.label).localeCompare(weekSortKey(a.label)));
el.innerHTML = sortiert.map(w => {
const aktuell = w.id === db.currentWeek;
const istJuengste = juengste && w.id === juengste.id;
const datum = w.date ? formatWeekDate(w.date) : '';
let chip, aktionen;
if(aktuell && istJuengste){
chip = 'Geöffnet';
aktionen = '';
} else if(aktuell){
// Zur Bearbeitung geöffnete, ältere Sitzung — noch nicht wieder
// gesperrt. Ohne diesen eigenen Zustand sähe man hier "Geöffnet" ohne
// jede Aktion und hätte keinen Weg zurück außer über eine andere,
// zufällige applyAutoLock()-auslösende Handlung.
chip = 'Geöffnet (Korrektur)';
aktionen = ``;
} else if(w.locked){
chip = 'Gesperrt';
aktionen = ``;
} else {
chip = 'Entsperrt';
aktionen = ``;
}
return `
`;
}).join('');
}
// adminUnlockWeek / adminDeleteWeek / adminOpenWeek / adminRelockWeek: dünne
// Wrapper der Sitzungsliste um die allgemeinen Funktionen aus js/weeks.js.
// Nach Entsperren/Löschen/Sperren zeichnet sich NUR diese Liste neu (nicht
// die ganze Seite) — die übrige Oberfläche zeigt ohnehin nur die eine offene
// Sitzung und ändert sich durch das Entsperren oder Löschen einer anderen
// nicht. "Öffnen" macht dagegen ausdrücklich sichtbar, was sich geändert
// hat: Es wechselt zur Sitzung UND zum Board, statt sie nur im Hintergrund
// zur aktuellen zu machen.
function adminUnlockWeek(weekId){
unlockWeek(db.weeks.find(w => w.id === weekId), renderAdminSitzungen);
}
function adminDeleteWeek(weekId){
deleteWeekById(weekId, renderAdminSitzungen);
}
function adminOpenWeek(weekId){
switchWeek(weekId);
showTab('board');
}
// adminRelockWeek: schließt eine Korrektur ab — sperrt die übergebene
// Sitzung wieder UND stellt die jüngste Sitzung als db.currentWeek wieder
// her, statt beides getrennten Schritten zu überlassen (sperren allein
// ließe die Oberfläche weiter auf der alten Sitzung stehen; nur zurück-
// wechseln ließe sie entsperrt liegen). markWeekDirty() + save({week:false})
// statt des einfachen save(), weil save() ohne Parameter db.currentWeek
// markieren würde — und das ist zu diesem Zeitpunkt schon die jüngste, NICHT
// die gerade gesperrte Sitzung (gleiches Muster wie savePlan() in
// js/plan/plan-state.js). Parameter: weekId. Keine Rückgabe.
function adminRelockWeek(weekId){
const w = db.weeks.find(x => x.id === weekId);
if(!w || w.locked) return;
w.locked = true;
markWeekDirty(w.label);
save({ week: false });
const juengste = juengsteWoche();
if(juengste) db.currentWeek = juengste.id;
renderAdminSitzungen();
}
function saveTerminSettings(){
const patch = fieldValues('termine');
const autoLock = document.getElementById('setAutoLock');
if(autoLock) patch.autoLockAktiv = autoLock.checked ? 1 : 0;
putAppSettings(patch, 'Termin-Einstellungen gespeichert.');
}
// saveBetriebSettings: Backups, Sitzungsdauer und Anmeldebremse.
// Keine Parameter, keine Rückgabe.
function saveBetriebSettings(){
putAppSettings(fieldValues('betrieb'), 'Betriebs-Einstellungen gespeichert.');
}
// ---------- Abschnitt Honorar ----------
// loadAdminHonorar: holt Anteile und Stundensatz (GET /honorar-config, für
// jedes angemeldete Konto lesbar) und rendert die Maske.
// Keine Parameter, keine Rückgabe.
function loadAdminHonorar(){
const fieldsEl = document.getElementById('honorarAdminFields');
if(fieldsEl) fieldsEl.innerHTML = 'Lade Honorar-Einstellungen…';
fetch(baseUrl() + '/honorar-config')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
adminHonorar = { anteile: data.anteile || {}, stundensatz: Number(data.stundensatz) || 135 };
renderAdminHonorar();
})
.catch(err => {
console.error('Honorar-Konfiguration laden fehlgeschlagen:', err);
if(fieldsEl) fieldsEl.innerHTML = 'Honorar-Einstellungen konnten nicht geladen werden.';
});
}
// renderAdminHonorar: ein Eingabefeld je Leistungsphase plus die laufende
// Summe. Die Summe steht dabei, weil die HOAI-Sätze eines Leistungsbildes
// zusammen 100 % ergeben — ein unvollständiger Satz fällt sonst erst auf,
// wenn ein gerechneter Terminplan zu kurz gerät.
// Keine Parameter, keine Rückgabe.
function renderAdminHonorar(){
const fieldsEl = document.getElementById('honorarAdminFields');
if(!fieldsEl || !adminHonorar) return;
const anteile = adminHonorar.anteile || {};
fieldsEl.innerHTML = LPHS.map(l => ``).join('');
const satzEl = document.getElementById('honorarStundensatz');
if(satzEl && document.activeElement !== satzEl) satzEl.value = adminHonorar.stundensatz;
refreshAdminHonorarSum();
}
// refreshAdminHonorarSum: rechnet die Summe der eingetippten Anteile neu
// und färbt sie (grün bei 100 %, sonst Warnfarbe). Läuft bei jeder Eingabe,
// liest deshalb aus den Feldern und nicht aus adminHonorar.
// Keine Parameter, keine Rückgabe.
function refreshAdminHonorarSum(){
const fieldsEl = document.getElementById('honorarAdminFields');
const sumEl = document.getElementById('honorarAdminSum');
if(!fieldsEl || !sumEl) return;
let summe = 0;
fieldsEl.querySelectorAll('input[data-lph]').forEach(inp => {
const v = parseFloat(inp.value);
if(Number.isFinite(v)) summe += v;
});
const ok = Math.abs(summe - 100) < 0.05;
sumEl.innerHTML = `Summe ${summe.toFixed(1)} %`
+ (ok ? '' : ' — die Sätze eines Leistungsbildes ergeben zusammen 100 %.');
}
// saveHonorarConfig: schreibt Anteile und Stundensatz firmenweit
// (PUT /honorar-config, Admin-only). Danach wird der Zwischenspeicher des
// Honorar-Dialogs im Projektbereich verworfen, damit er nicht mit den alten
// Zahlen weiterrechnet. Keine Parameter, keine Rückgabe.
function saveHonorarConfig(){
const fieldsEl = document.getElementById('honorarAdminFields');
const anteile = {};
if(fieldsEl){
fieldsEl.querySelectorAll('input[data-lph]').forEach(inp => {
const v = parseFloat(inp.value);
if(Number.isFinite(v) && v > 0) anteile[inp.dataset.lph] = v;
});
}
const satzEl = document.getElementById('honorarStundensatz');
const stundensatz = satzEl ? parseFloat(satzEl.value) : undefined;
fetch(baseUrl() + '/honorar-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ anteile, stundensatz }),
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Speichern fehlgeschlagen.');
adminHonorar = { anteile: json.anteile || {}, stundensatz: Number(json.stundensatz) || 135 };
if(typeof invalidateHonorarConfig === 'function') invalidateHonorarConfig();
renderAdminHonorar();
showAdminNote('Honorar-Einstellungen gespeichert.');
})
.catch(err => {
console.error('Honorar-Konfiguration speichern fehlgeschlagen:', err);
alert('Einstellungen konnten nicht gespeichert werden: ' + (err.message || err));
});
}
// ---------- Abschnitt Feiertage ----------
// renderAdminHolidays: listet die gepflegten arbeitsfreien Tage
// chronologisch, mit Wochentag (ein Feiertag auf einem Samstag ändert an der
// Rechnung nichts — das soll man sehen) und Lösch-Knopf.
// Keine Parameter, keine Rückgabe.
function renderAdminHolidays(){
renderHolidayYearHint();
renderHolidayStateSelect();
const el = document.getElementById('adminHolidayList');
if(!el) return;
const dates = Array.from(HOLIDAYS).sort();
if(dates.length === 0){
el.innerHTML = 'Noch keine arbeitsfreien Tage hinterlegt — es zählen nur Wochenenden.';
return;
}
const wochentage = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
el.innerHTML = dates.map(d => {
const date = parseDateISO(d);
const tag = date ? wochentage[date.getDay()] : '';
const amWochenende = date && (date.getDay() === 0 || date.getDay() === 6);
return `
`;
}).join('');
}
// addHoliday: legt einen arbeitsfreien Tag an (POST /holidays, Admin-only).
// Ein bereits vorhandenes Datum wird nur umbenannt — das Datum ist der
// Primärschlüssel. Keine Parameter, keine Rückgabe.
function addHoliday(){
const dateEl = document.getElementById('holidayNewDate');
const nameEl = document.getElementById('holidayNewName');
const date = dateEl ? dateEl.value : '';
if(!date){ alert('Bitte ein Datum wählen.'); return; }
fetch(baseUrl() + '/holidays', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ date, name: nameEl ? nameEl.value.trim() : '' }),
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
HOLIDAYS.add(json.holiday.date);
HOLIDAY_NAMES[json.holiday.date] = json.holiday.name || '';
if(nameEl) nameEl.value = '';
renderAdminHolidays();
showAdminNote('Arbeitsfreier Tag hinterlegt.');
})
.catch(err => {
console.error('Feiertag anlegen fehlgeschlagen:', err);
alert('Tag konnte nicht angelegt werden: ' + (err.message || err));
});
}
// deleteHoliday: entfernt einen arbeitsfreien Tag (DELETE, Admin-only).
// Bereits errechnete Terminpläne bleiben unberührt — sie sind nach dem
// Einfügen ganz normale Aufgaben. Parameter: date (ISO). Keine Rückgabe.
function deleteHoliday(date){
fetch(baseUrl() + '/holidays/' + encodeURIComponent(date), { method: 'DELETE' })
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
HOLIDAYS.delete(date);
delete HOLIDAY_NAMES[date];
renderAdminHolidays();
})
.catch(err => {
console.error('Feiertag löschen fehlgeschlagen:', err);
alert('Tag konnte nicht entfernt werden: ' + (err.message || err));
});
}
// ---------- Abschnitt Aufgaben-Vorlagen ----------
// loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und
// rendert die Liste der gerade gewählten Leistungsphase.
// Keine Parameter, keine Rückgabe.
function loadAdminTemplates(){
const select = document.getElementById('adminTemplateLph');
if(select && !select.options.length){
select.innerHTML = LPHS.map(l => ``).join('');
}
const listEl = document.getElementById('templateAdminList');
if(listEl) listEl.innerHTML = 'Lade Vorlagen…';
fetch(baseUrl() + '/lph-templates')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
adminTemplates = Array.isArray(data.templates) ? data.templates : [];
renderAdminTemplates();
})
.catch(err => {
console.error('Vorlagen laden fehlgeschlagen:', err);
if(listEl) listEl.innerHTML = 'Vorlagen konnten nicht geladen werden.';
});
}
// renderAdminTemplates: zeigt die Schritte der gewählten Leistungsphase in
// ihrer gepflegten Reihenfolge, je Zeile mit Versatz/Dauer und Lösch-Knopf.
// Wird auch vom onchange der Leistungsphasen-Auswahl gerufen.
// Keine Parameter, keine Rückgabe.
function renderAdminTemplates(){
const listEl = document.getElementById('templateAdminList');
if(!listEl) return;
const select = document.getElementById('adminTemplateLph');
const lph = select ? select.value : '';
const entries = adminTemplates.filter(t => t.lph === lph);
listEl.innerHTML = entries.length === 0
? 'Für diese Leistungsphase ist noch kein Schritt hinterlegt.'
: entries.map(t => `
${esc(t.title)}Start +${esc(t.offsetDays)} Tage · Dauer ${esc(t.durationDays)} Tage
`).join('');
}
// addTemplateEntry: legt einen Schritt für die gewählte Leistungsphase an
// (POST /lph-templates, Admin-only). Keine Parameter, keine Rückgabe.
function addTemplateEntry(){
const select = document.getElementById('adminTemplateLph');
const titleEl = document.getElementById('templateNewTitle');
const offsetEl = document.getElementById('templateNewOffset');
const durationEl = document.getElementById('templateNewDuration');
const title = titleEl ? titleEl.value.trim() : '';
if(!title){ alert('Bitte eine Bezeichnung für den Schritt eingeben.'); return; }
fetch(baseUrl() + '/lph-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
lph: select ? select.value : '',
title,
offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0,
durationDays: durationEl ? parseInt(durationEl.value, 10) || 1 : 1,
})
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
if(titleEl) titleEl.value = '';
loadAdminTemplates();
showAdminNote('Schritt angelegt.');
})
.catch(err => {
console.error('Vorlagen-Schritt anlegen fehlgeschlagen:', err);
alert('Schritt konnte nicht angelegt werden: ' + (err.message || err));
});
}
// deleteTemplateEntry: löscht einen Vorlagen-Schritt (DELETE, Admin-only).
// Bereits in Projekte eingefügte Aufgaben bleiben unberührt — sie sind nach
// dem Einfügen ganz normale plan_todos ohne Verbindung zur Vorlage.
// Parameter: id (String). Keine Rückgabe.
function deleteTemplateEntry(id){
if(!confirm('Diesen Schritt aus der Vorlage löschen?\n\nBereits in Projekte eingefügte Aufgaben bleiben erhalten.')) return;
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id), { method: 'DELETE' })
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
loadAdminTemplates();
})
.catch(err => {
console.error('Vorlagen-Schritt löschen fehlgeschlagen:', err);
alert('Schritt konnte nicht gelöscht werden: ' + (err.message || err));
});
}
// ---------- Abschnitt Plan-Checker-Merkmale ----------
//
// Dieselbe add/delete-only Liste wie die Aufgaben-Vorlagen oben, nur ohne
// Gruppierung (die Merkmale gelten unterschiedslos für jede Prüfung) — siehe
// plan_checker_merkmale/GET+POST+DELETE /plan-checker-merkmale, server.js.
// Die Liste selbst nutzt auch der Plan Checker (js/planchecker/planchecker.js),
// um zu zeigen, was geprüft wird — beide Stellen lesen denselben Endpunkt.
// loadAdminPlanCheckerMerkmale: holt alle Merkmale und rendert die Liste.
// Keine Parameter, keine Rückgabe.
function loadAdminPlanCheckerMerkmale(){
const listEl = document.getElementById('planCheckerMerkmaleAdminListe');
if(listEl) listEl.innerHTML = 'Lade Merkmale…';
fetch(baseUrl() + '/plan-checker-merkmale')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
adminPlanCheckerMerkmale = Array.isArray(data.merkmale) ? data.merkmale : [];
renderAdminPlanCheckerMerkmale();
})
.catch(err => {
console.error('Plan-Checker-Merkmale laden fehlgeschlagen:', err);
if(listEl) listEl.innerHTML = 'Merkmale konnten nicht geladen werden.';
});
}
// renderAdminPlanCheckerMerkmale: zeigt die Merkmale in gepflegter Reihenfolge,
// je Zeile mit Prüfanweisung und Lösch-Knopf. Keine Parameter, keine Rückgabe.
function renderAdminPlanCheckerMerkmale(){
const listEl = document.getElementById('planCheckerMerkmaleAdminListe');
if(!listEl) return;
listEl.innerHTML = adminPlanCheckerMerkmale.length === 0
? 'Noch kein Merkmal hinterlegt.'
: adminPlanCheckerMerkmale.map(m => `
${esc(m.titel)}${esc(m.prompt)}
`).join('');
}
// addPlanCheckerMerkmal: legt ein neues Merkmal an (POST, Admin-only).
// Keine Parameter, keine Rückgabe.
function addPlanCheckerMerkmal(){
const titelEl = document.getElementById('merkmalNeuTitel');
const promptEl = document.getElementById('merkmalNeuPrompt');
const titel = titelEl ? titelEl.value.trim() : '';
const prompt = promptEl ? promptEl.value.trim() : '';
if(!titel){ alert('Bitte einen Titel eingeben.'); return; }
if(!prompt){ alert('Bitte eine Prüfanweisung eingeben.'); return; }
fetch(baseUrl() + '/plan-checker-merkmale', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ titel, prompt })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Anlegen fehlgeschlagen.');
if(titelEl) titelEl.value = '';
if(promptEl) promptEl.value = '';
loadAdminPlanCheckerMerkmale();
showAdminNote('Merkmal angelegt.');
})
.catch(err => {
console.error('Merkmal anlegen fehlgeschlagen:', err);
alert('Merkmal konnte nicht angelegt werden: ' + (err.message || err));
});
}
// deletePlanCheckerMerkmal: löscht ein Merkmal (DELETE, Admin-only).
// Parameter: id (String). Keine Rückgabe.
function deletePlanCheckerMerkmal(id){
if(!confirm('Dieses Merkmal löschen?')) return;
fetch(baseUrl() + '/plan-checker-merkmale/' + encodeURIComponent(id), { method: 'DELETE' })
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Löschen fehlgeschlagen.');
loadAdminPlanCheckerMerkmale();
})
.catch(err => {
console.error('Merkmal löschen fehlgeschlagen:', err);
alert('Merkmal konnte nicht gelöscht werden: ' + (err.message || err));
});
}
// ---------- Rückmeldung ----------
// showAdminNote: kurze Bestätigung am Fuß des Bereichs. Bewusst kein
// alert(): Ein erfolgreiches Speichern soll den Arbeitsfluss nicht anhalten,
// ein Fehlschlag dagegen schon (die Fehlerfälle oben nutzen weiter alert).
// Parameter: text (String). Keine Rückgabe.
function showAdminNote(text){
const el = document.getElementById('adminNote');
// Ein leerer Text bedeutet "nichts melden": So kann ein Speichervorgang,
// der nur nebenbei laeuft (etwa das gemerkte Bundesland beim Feiertags-
// Import), die eigentliche Rueckmeldung stehen lassen, statt sie zu
// ueberschreiben.
if(!el || !text) return;
el.textContent = text;
el.classList.add('show');
if(showAdminNote.timer) clearTimeout(showAdminNote.timer);
showAdminNote.timer = setTimeout(() => { el.classList.remove('show'); }, 2500);
}
// ---------- Gesetzliche Feiertage berechnen lassen ----------
//
// Die Tage kommen NICHT von einem Netzdienst, sondern aus dem Kalender:
// holidaysForYear() in server.js rechnet sie aus (feste Termine plus alles,
// was am Osterdatum haengt). Das spart eine Abhaengigkeit von fremder
// Verfuegbarkeit und einem Internetzugang des Servers, den es im Buero-Netz
// nicht zwingend gibt. Eingetragen werden sie erst auf Knopfdruck — sie
// verschieben jeden gerechneten Terminplan.
// renderHolidayStateSelect: fuellt Jahr und Bundesland vor. Das Bundesland
// steht als firmenweite Einstellung (feiertagBundesland), damit es nicht bei
// jedem Jahr neu gewaehlt werden muss. Keine Parameter, keine Rueckgabe.
function renderHolidayStateSelect(){
const jahrEl = document.getElementById('holidayYear');
if(jahrEl && !jahrEl.value) jahrEl.value = new Date().getFullYear();
const sel = document.getElementById('holidayState');
if(!sel) return;
const laender = Object.entries(GERMAN_STATES);
if(laender.length === 0) return; // /holidays noch nicht beantwortet
const gewaehlt = sel.value || APP_SETTINGS.feiertagBundesland || 'NW';
sel.innerHTML = laender
.map(([k, name]) => ``)
.join('');
}
// renderHolidayYearHint: weist darauf hin, wenn fuer das laufende oder das
// kommende Jahr noch kein einziger Tag hinterlegt ist. Ohne diesen Hinweis
// faellt erst beim naechsten falsch gerechneten Terminplan auf, dass die
// Feiertage fehlen. Keine Parameter, keine Rueckgabe.
function renderHolidayYearHint(){
const el = document.getElementById('holidayYearHint');
if(!el) return;
const jahre = [new Date().getFullYear(), new Date().getFullYear() + 1];
const fehlend = jahre.filter(j => !Array.from(HOLIDAYS).some(d => d.startsWith(String(j))));
if(fehlend.length === 0){ el.style.display = 'none'; return; }
el.style.display = '';
el.textContent = fehlend.length === 2
? `Für ${fehlend[0]} und ${fehlend[1]} ist noch kein arbeitsfreier Tag hinterlegt.`
: `Für ${fehlend[0]} ist noch kein arbeitsfreier Tag hinterlegt.`;
}
// suggestHolidays: laesst sich die gesetzlichen Feiertage des gewaehlten
// Jahres und Bundeslandes berechnen (GET /holidays/suggest) und zeigt sie zur
// Durchsicht. Bereits hinterlegte Tage sind als solche gekennzeichnet und
// werden beim Uebernehmen nicht angetastet. Keine Parameter, keine Rueckgabe.
function suggestHolidays(){
const jahr = parseInt((document.getElementById('holidayYear') || {}).value, 10);
const land = (document.getElementById('holidayState') || {}).value || '';
const box = document.getElementById('holidaySuggestion');
if(!box) return;
if(!Number.isInteger(jahr)){ alert('Bitte ein Jahr eingeben.'); return; }
box.innerHTML = 'Wird berechnet…';
fetch(baseUrl() + '/holidays/suggest?year=' + jahr + '&state=' + encodeURIComponent(land))
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Berechnung fehlgeschlagen.');
const neu = json.holidays.filter(h => !h.vorhanden).length;
box.innerHTML = `