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