// ----------------------------- // 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 `
${esc(w.label)}${datum ? ' · ' + esc(datum) : ''} ${chip} ${aktionen} ${hatRecht('s.loeschen') ? `` : ''}
`; }).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 `
${esc(formatDateDE(date))} · ${esc(HOLIDAY_NAMES[d] || 'Feiertag')} ${esc(tag)}${amWochenende ? ' — ohnehin arbeitsfrei' : ''}
`; }).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 = `
` + json.holidays.map(h => `
${esc(formatDateDE(parseDateISO(h.date)))} · ${esc(h.name)} ${h.vorhanden ? 'bereits hinterlegt' : 'neu'}
`).join('') + `
` + `
` + (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 = '

Der Papierkorb ist leer.

'; return; } const tagMs = 24 * 60 * 60 * 1000; el.innerHTML = data.projekte.map(p => { const bez = ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() || '(ohne Bezeichnung)'; const am = new Date(p.deletedAt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); const rest = Math.max(0, Math.ceil((Date.parse(p.endgueltigAm) - Date.now()) / tagMs)); return `
${esc(bez)} · gelöscht ${esc(am)}${p.deletedBy ? ' von ' + esc(p.deletedBy) : ''} · aus ${esc(p.weekLabel)} noch ${rest} ${rest === 1 ? 'Tag' : 'Tage'}
`; }).join(''); } catch(err){ 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; } el.innerHTML = `
Status ${d.konfiguriert ? 'Verbunden ' + esc(d.host) + '' : 'Nicht eingerichtet'}
${d.konfiguriert ? '' : ''}
`; } 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 — bitte im Teams-Kanal 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'; } } }