// -----------------------------
// 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/PATCH/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.
// recht: welches Recht (Rollen & Rechte) den Abschnitt öffnet; bei einer
// Liste genügt eines davon.
const ADMIN_SECTIONS = [
{ key: 'benutzer', title: 'Benutzerverwaltung', wide: true, recht: 'a.benutzer' },
{ key: 'rollen', title: 'Rollen & Rechte', wide: true, recht: 'a.rollen' },
{ key: 'honorar', title: 'Honorar', recht: 'a.honorar' },
{ key: 'auslastung', title: 'Auslastung', recht: 'a.auslastung' },
{ key: 'termine', title: 'Termine & Sitzungen', recht: 'a.termine' },
{ key: 'feiertage', title: 'Feiertage', recht: 'a.feiertage' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen', recht: 'a.vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale', recht: 'a.checker' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit', recht: 'a.betrieb' },
{ key: 'teams', title: 'Teams', recht: 'a.teams' },
{ key: 'papierkorb', title: 'Papierkorb', recht: 'a.papierkorb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen', recht: ['a.backup', 'a.import'] },
];
// adminNavAbgleichen: blendet Unterpunkte der Seitenleiste ohne Recht aus
// (auch von renderUserBar() nach der Anmeldung gerufen).
function adminNavAbgleichen(){
ADMIN_SECTIONS.forEach(sec => {
const item = document.querySelector('#adminSubnav [data-admin-section="' + sec.key + '"]');
if(item) item.hidden = !adminAbschnittErlaubt(sec);
});
}
// adminAbschnittErlaubt: darf das Konto diesen Abschnitt sehen?
function adminAbschnittErlaubt(sec){
const r = Array.isArray(sec.recht) ? sec.recht : [sec.recht];
return r.some(x => hatRecht(x));
}
// 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 hatAdminRecht();
}
// 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 && adminAbschnittErlaubt(x));
}
// 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.find(adminAbschnittErlaubt) || ADMIN_SECTIONS[0]).key;
adminSection = key;
adminNavAbgleichen();
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();
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
// frisch holen.
if(key === 'papierkorb') loadAdminPapierkorb();
if(key === 'teams') loadAdminTeams();
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
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.
// Referenzhonorar und Verteilzeitraum sind entfallen: Die Auslastung
// rechnet nur noch mit Aufgaben-Stunden (js/load/load.js).
},
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);
}
// 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 und Vollzeit-Stunden in einem Zug. Keine Parameter, keine Rückgabe.
function saveAuslastungSettings(){
const patch = fieldValues('auslastung');
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();
}
// adminSitzungen: ALLE Sitzungen für die Liste — geladene als volles Objekt
// (aktueller Sperrstand), nicht geladene aus db.wochenListe (schlank). Seit
// beim Start nur die jüngsten Sitzungen geladen werden (js/store.js).
function adminSitzungen(){
const liste = (db.wochenListe && db.wochenListe.length) ? db.wochenListe : db.weeks;
return liste.map(k => db.weeks.find(w => w.label === k.label) || k);
}
// 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 = adminSitzungen().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 = hatRecht('s.entsperren') ? `` : '';
} 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.
// Alle vier nehmen das Label: Die Sitzung ist evtl. nicht geladen und wird
// dafür erst nachgeholt (sitzungSicherLaden(), js/store.js).
function adminMitSitzung(label, fn){
sitzungSicherLaden(label).then(w => {
if(!w){ alert('Die Sitzung ' + label + ' wurde am Server nicht gefunden.'); renderAdminSitzungen(); return; }
fn(w);
}).catch(() => alert('Die Sitzung ' + label + ' konnte nicht geladen werden.'));
}
function adminUnlockWeek(label){
adminMitSitzung(label, w => unlockWeek(w, () => { sitzungKurzAbgleichen(w); renderAdminSitzungen(); }));
}
function adminDeleteWeek(label){
adminMitSitzung(label, w => deleteWeekById(w.id, renderAdminSitzungen));
}
function adminOpenWeek(label){
adminMitSitzung(label, w => { switchWeek(w.id); 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(label){
const w = db.weeks.find(x => x.label === label);
if(!w || w.locked) return;
w.locked = true;
sitzungKurzAbgleichen(w);
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 ----------
//
// Vorlagen gibt es je Gewerk und Leistungsphase (Eriks Wunsch). Oben wählt
// man das Gewerk (Umschalter) und die Leistungsphase, darunter steht die
// Liste der Schritte: Bezeichnung, Start und Dauer direkt bearbeitbar
// (gespeichert beim Verlassen des Feldes, PATCH /lph-templates/:id),
// Reihenfolge über ↑/↓ (POST …/verschieben), Löschen über ×.
// Vorlagen ohne Gewerk (Altbestand) stehen unter jedem Gewerk mit dem
// Vermerk „alle Gewerke“.
let adminTemplates = [];
let adminTemplateGewerk = null;
// loadAdminTemplates: holt alle Vorlagen-Schritte (GET /lph-templates) und
// rendert die Liste. Keine Parameter, keine Rückgabe.
function loadAdminTemplates(){
const select = document.getElementById('adminTemplateLph');
if(select && !select.options.length){
select.innerHTML = LPHS.map(l => ``).join('');
}
if(!adminTemplateGewerk){
const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : '';
adminTemplateGewerk = eigen || Object.keys(DEPARTMENTS)[0];
}
const listEl = document.getElementById('templateAdminList');
if(listEl && !adminTemplates.length) 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.';
});
}
// vorlagenAnteilText: Anzeige der Spalte „Anteil %“ — Zahl mit Komma oder
// „in a)“ / „in a) + b)“ (Siemon: in anderen Schritten enthalten).
function vorlagenAnteilText(t){
if(t.enthaltenIn) return 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ');
return t.anteil ? String(t.anteil).replace('.', ',') : '';
}
// waehleAdminTemplateGewerk: Umschalter Gewerk. Parameter: g. Keine Rückgabe.
function waehleAdminTemplateGewerk(g){
adminTemplateGewerk = g;
renderAdminTemplates();
}
// renderAdminTemplates: Gewerk-Umschalter (mit Anzahl je Gewerk), die
// Anzahl je Leistungsphase in der Auswahl und die Schritte der gewählten
// Kombination. Keine Parameter, keine Rückgabe.
function renderAdminTemplates(){
const g = adminTemplateGewerk;
const gewEl = document.getElementById('adminTemplateGewerk');
if(gewEl){
gewEl.innerHTML = Object.keys(DEPARTMENTS).map(d => {
const n = adminTemplates.filter(t => t.gewerk === d).length;
return ``;
}).join('');
}
const select = document.getElementById('adminTemplateLph');
const lph = select ? select.value : '';
if(select){
Array.from(select.options).forEach(o => {
const n = adminTemplates.filter(t => t.lph === o.value && t.gewerk === g).length;
o.textContent = o.value + (n ? ' (' + n + ')' : '');
});
}
const listEl = document.getElementById('templateAdminList');
if(!listEl) return;
const eigene = adminTemplates.filter(t => t.lph === lph && t.gewerk === g);
const alle = adminTemplates.filter(t => t.lph === lph && !t.gewerk);
const zeile = (t, i, n) => `
${n !== null ? `
` : 'alle Gewerke'}
`;
// Summe der Anteile: Beim Einfügen zählt das Verhältnis (der Anteil eines
// Schritts an der Summe der Phase) — die Summe ist nur zur Kontrolle da.
const summe = Math.round([...eigene, ...alle].reduce((a, t) => a + (Number(t.anteil) || 0), 0) * 100) / 100;
listEl.innerHTML = (eigene.length || alle.length)
? eigene.map((t, i) => zeile(t, i, eigene.length)).join('') + alle.map(t => zeile(t, 0, null)).join('')
+ `
Summe der Anteile in ${esc(lph)}: ${summe ? String(summe).replace('.', ',') + ' %' : 'keine hinterlegt'}${summe ? '' : ' — Schritte ohne Anteil werden mit „Start/Dauer“ bzw. ohne Termin eingefügt.'}
`
: `Für ${esc(g)} in ${esc(lph)} ist noch kein Schritt hinterlegt.`;
}
// addTemplateEntry: legt einen Schritt für das gewählte Gewerk und die
// gewählte Leistungsphase an (POST /lph-templates). Keine Parameter.
function addTemplateEntry(){
const select = document.getElementById('adminTemplateLph');
const titleEl = document.getElementById('templateNewTitle');
const offsetEl = document.getElementById('templateNewOffset');
const durationEl = document.getElementById('templateNewDuration');
const anteilEl = document.getElementById('templateNewAnteil');
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 : '',
gewerk: adminTemplateGewerk,
title,
offsetDays: offsetEl ? parseInt(offsetEl.value, 10) || 0 : 0,
// leer oder 0 = ohne Termin
durationDays: durationEl ? parseInt(durationEl.value, 10) || 0 : 0,
anteil: anteilEl ? anteilEl.value : '',
})
})
.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 = ''; titleEl.focus(); }
if(anteilEl) anteilEl.value = '';
loadAdminTemplates();
showAdminNote('Schritt angelegt.');
})
.catch(err => {
console.error('Vorlagen-Schritt anlegen fehlgeschlagen:', err);
alert('Schritt konnte nicht angelegt werden: ' + (err.message || err));
});
}
// aendereTemplateEntry: speichert ein geändertes Feld eines Schritts
// (PATCH /lph-templates/:id). Parameter: id, feld, wert. Keine Rückgabe.
function aendereTemplateEntry(id, feld, wert){
const body = {};
body[feld] = (feld === 'title' || feld === 'anteil') ? wert : parseInt(wert, 10);
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(body)
})
.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.');
const t = adminTemplates.find(x => x.id === id);
if(t) Object.assign(t, json.template);
renderAdminTemplates();
showAdminNote('Gespeichert.');
})
.catch(err => {
console.error('Vorlagen-Schritt ändern fehlgeschlagen:', err);
alert('Änderung konnte nicht gespeichert werden: ' + (err.message || err));
loadAdminTemplates();
});
}
// verschiebeTemplateEntry: Schritt eine Stelle nach oben/unten.
// Parameter: id, richtung (-1/1). Keine Rückgabe.
function verschiebeTemplateEntry(id, richtung){
fetch(baseUrl() + '/lph-templates/' + encodeURIComponent(id) + '/verschieben', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ richtung })
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Verschieben fehlgeschlagen.');
loadAdminTemplates();
})
.catch(err => {
console.error('Vorlagen-Schritt verschieben fehlgeschlagen:', err);
alert('Schritt konnte nicht verschoben 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 ----------
//
// Eine add/delete-only Liste wie früher die Aufgaben-Vorlagen oben, 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 = `
`
+ (neu > 0
? ``
: `Alle ${json.holidays.length} Tage sind bereits hinterlegt.`)
+ ``
+ `
`;
})
.catch(err => {
console.error('Feiertage berechnen fehlgeschlagen:', err);
box.innerHTML = 'Feiertage konnten nicht berechnet werden: ' + esc(err.message || err) + '';
});
}
// importHolidays: uebernimmt die berechneten Tage (POST /holidays/import,
// Admin-only). Bereits hinterlegte Tage bleiben unveraendert — ein von Hand
// angepasster Name soll nicht ueberschrieben werden. Das gewaehlte Bundesland
// wird als firmenweite Vorgabe gemerkt. Keine Parameter, keine Rueckgabe.
function importHolidays(){
const jahr = parseInt((document.getElementById('holidayYear') || {}).value, 10);
const land = (document.getElementById('holidayState') || {}).value || '';
fetch(baseUrl() + '/holidays/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ year: jahr, state: land }),
})
.then(r => r.json().then(json => ({ ok: r.ok, json })))
.then(({ ok, json }) => {
if(!ok || !json.ok) throw new Error((json && json.error) || 'Übernehmen fehlgeschlagen.');
showAdminNote(json.angelegt + ' Feiertag(e) für ' + jahr + ' übernommen'
+ (json.uebersprungen ? ', ' + json.uebersprungen + ' waren schon hinterlegt.' : '.'));
// Gemerktes Bundesland nachziehen, damit der nächste Vorschlag passt.
// Ohne Rueckmeldung — die Zeile darueber gehoert dem Import.
if(land && land !== APP_SETTINGS.feiertagBundesland) putAppSettings({ feiertagBundesland: land }, '');
const box = document.getElementById('holidaySuggestion');
if(box) box.innerHTML = '';
return loadHolidaysIntoState();
})
.catch(err => {
console.error('Feiertage übernehmen fehlgeschlagen:', err);
alert('Feiertage konnten nicht übernommen werden: ' + (err.message || err));
});
}
// loadHolidaysIntoState: holt die Feiertage frisch vom Server in HOLIDAYS/
// HOLIDAY_NAMES (js/globals.js) und zeichnet die Liste neu. Nach einem Import
// sind es zu viele einzelne Tage, um sie von Hand nachzuhalten — und die
// Werktags-Rechnung (js/utils.js) muss sofort damit arbeiten.
// Rueckgabe: Promise.
function loadHolidaysIntoState(){
return fetch(baseUrl() + '/holidays')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
const liste = Array.isArray(data.holidays) ? data.holidays : [];
HOLIDAYS = new Set(liste.map(h => h.date));
HOLIDAY_NAMES = {};
liste.forEach(h => { HOLIDAY_NAMES[h.date] = h.name || ''; });
renderAdminHolidays();
})
.catch(err => console.error('Feiertage neu laden fehlgeschlagen:', err));
}
// -----------------------------
// Papierkorb gelöschter Projekte
// -----------------------------
// Ein aus der Projektliste gelöschtes Projekt legt der Server beim Speichern
// der Sitzung als Schnappschuss in deleted_projects ab (server.js,
// /save-week). Hier lässt es sich 30 Tage lang in die aktuelle Sitzung
// zurückholen oder vorzeitig endgültig entfernen.
// loadAdminPapierkorb: holt die Liste (GET /admin/deleted-projects) und
// rendert sie in #adminPapierkorbList.
async function loadAdminPapierkorb(){
const el = document.getElementById('adminPapierkorbList');
if(!el) return;
try {
const r = await fetch(baseUrl() + '/admin/deleted-projects');
const data = await r.json().catch(() => ({}));
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
if(!data.projekte.length){
el.innerHTML = '
Papierkorb konnte nicht geladen werden: ' + esc(err.message) + '
';
}
}
// adminProjektWiederherstellen: holt ein Projekt in die aktuelle Sitzung
// zurück. Danach wird neu geladen, damit die Projektliste das Projekt zeigt
// und die lokale Revision der Sitzung zum Server passt (sonst meldete das
// nächste Speichern einen Konflikt).
async function adminProjektWiederherstellen(id){
try {
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id) + '/restore', { method: 'POST' });
const data = await r.json().catch(() => ({}));
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
load();
loadAdminPapierkorb();
alert('Projekt wiederhergestellt in „' + data.weekLabel + '".');
} catch(err){
alert('Wiederherstellen fehlgeschlagen: ' + err.message);
}
}
// adminProjektEndgueltigLoeschen: entfernt einen Papierkorb-Eintrag sofort.
async function adminProjektEndgueltigLoeschen(id){
if(!confirm('Projekt endgültig löschen? Das lässt sich nicht rückgängig machen.')) return;
try {
const r = await fetch(baseUrl() + '/admin/deleted-projects/' + encodeURIComponent(id), { method: 'DELETE' });
const data = await r.json().catch(() => ({}));
if(!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
loadAdminPapierkorb();
} catch(err){
alert('Löschen fehlgeschlagen: ' + err.message);
}
}
// ---------- Teams (Admin → Teams, Server: /admin/teams*) ----------
// Die Webhook-Adresse ist ein Geheimnis und kommt nie zurück in den Browser;
// angezeigt wird nur, ob eine eingetragen ist (und an welchen Host).
async function loadAdminTeams(){
const el = document.getElementById('adminTeamsInhalt');
if(!el) return;
let d;
try { d = await fetch(baseUrl() + '/admin/teams').then(r => r.json()); }
catch(e){ el.innerHTML = 'Konnte nicht geladen werden.'; return; }
if(!d || !d.ok){ el.innerHTML = 'Keine Berechtigung.'; return; }
// Ist schon eine Adresse hinterlegt, bleibt das Eingabefeld zu, bis man
// „Ändern“ wählt — die Adresse selbst kommt ohnehin nie zurück.
const feldOffen = !d.konfiguriert;
el.innerHTML = `
${d.konfiguriert
? 'EingerichtetWorkflow bei ' + esc(d.host) + ''
: 'Noch nicht eingerichtetAdresse aus dem Teams-Workflow unten einfügen'}
${d.konfiguriert ? '' : ''}
Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.
${d.konfiguriert ? '' : ''}
${d.konfiguriert ? '' : ''}
`;
}
// teamsFeldZeigen: blendet das Eingabefeld für eine neue Adresse ein.
function teamsFeldZeigen(){
const f = document.getElementById('teamsFeld');
if(f){ f.hidden = false; document.getElementById('teamsWebhook').focus(); }
}
// teamsFeldPruefen: Speichern erst bei einer https-Adresse; Hinweis passt sich an.
function teamsFeldPruefen(){
const wert = (document.getElementById('teamsWebhook').value || '').trim();
const ok = /^https:\/\/\S+$/.test(wert);
const knopf = document.getElementById('teamsSpeichernKnopf');
const hinweis = document.getElementById('teamsFeldHinweis');
if(knopf) knopf.disabled = !ok;
if(hinweis){
hinweis.classList.toggle('ist-fehler', !!wert && !ok);
hinweis.textContent = !wert ? 'Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.'
: ok ? 'Sieht gut aus — jetzt speichern und danach eine Testnachricht senden.'
: 'Die Adresse muss mit https:// beginnen und darf keine Leerzeichen enthalten.';
}
}
async function teamsSpeichern(patch){
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
const r = await fetch(baseUrl() + '/admin/teams', {
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(patch)
});
const d = await r.json().catch(() => ({}));
if(!r.ok || !d.ok){ alert(d.error || 'Speichern fehlgeschlagen.'); return; }
// Nach dem ersten Eintragen der Adresse die Meldungen gleich einschalten.
if('webhook' in patch && !document.getElementById('teamsNeueAufgaben').checked){
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ neueAufgaben: true }) });
}
loadAdminTeams();
}
async function teamsEntfernen(){
if(!confirm('Teams-Anbindung entfernen? Es werden keine Nachrichten mehr gesendet.')) return;
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ webhook: '', neueAufgaben: false }) });
loadAdminTeams();
}
async function teamsTesten(){
const knopf = document.getElementById('teamsTestKnopf');
const fehler = document.getElementById('teamsFehler');
if(knopf){ knopf.disabled = true; knopf.textContent = 'Wird gesendet…'; }
try {
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
if(d.ok) alert('Testnachricht gesendet an ' + (d.an || 'Sie') + ' — bitte im Teams-Chat mit dem Flow-Bot nachsehen.');
} catch(e){
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
} finally {
if(knopf){ knopf.disabled = false; knopf.textContent = 'Testnachricht senden'; }
}
}