Files
projektuebersicht/public/js/admin/admin.js
erik 5b20adee48 All new
alle new
2026-10-01 06:46:51 +02:00

843 lines
39 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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 Test.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 = `<table class="settings-matrix">
<thead><tr><th>Phase</th>${ROLES.map(r => `<th>${esc(r)}</th>`).join('')}</tr></thead>
<tbody>${phasen.map(l => `<tr>
<th>${esc(l)}</th>
${ROLES.map(r => {
const v = vorlage[l] ? vorlage[l][r] : undefined;
return `<td><input type="number" min="0" max="1" step="0.1"
data-lph="${esc(l)}" data-role="${esc(r)}"
value="${v === undefined ? '' : esc(v)}" placeholder="1,0"></td>`;
}).join('')}
</tr>`).join('')}</tbody>
</table>`;
}
// 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 = '<span class="sitzung-chip ist-geoeffnet">Geöffnet</span>';
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 = '<span class="sitzung-chip ist-geoeffnet">Geöffnet (Korrektur)</span>';
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
} else if(w.locked){
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
aktionen = `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>`;
} else {
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`;
}
return `<div class="template-admin-row">
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip}
${aktionen}
<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>
</div>`;
}).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 = '<span class="hint">Lade Honorar-Einstellungen…</span>';
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 = '<span class="hint">Honorar-Einstellungen konnten nicht geladen werden.</span>';
});
}
// 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 => `<label class="honorar-anteil-field">
<span>${esc(l)}</span>
<input type="number" min="0" max="100" step="0.1" data-lph="${esc(l)}"
value="${anteile[l] !== undefined ? esc(anteile[l]) : ''}" placeholder="0"
oninput="refreshAdminHonorarSum()">
</label>`).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 = `<span class="honorar-sum ${ok ? 'ok' : 'warn'}">Summe ${summe.toFixed(1)} %</span>`
+ (ok ? '' : ' <span class="hint" style="margin:0">— die Sätze eines Leistungsbildes ergeben zusammen 100 %.</span>');
}
// 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 = '<span class="hint">Noch keine arbeitsfreien Tage hinterlegt — es zählen nur Wochenenden.</span>';
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 `<div class="template-admin-row">
<span class="template-admin-title">${esc(formatDateDE(date))} · ${esc(HOLIDAY_NAMES[d] || 'Feiertag')}</span>
<span class="hint" style="margin:0">${esc(tag)}${amWochenende ? ' — ohnehin arbeitsfrei' : ''}</span>
<button class="danger small" onclick="deleteHoliday('${esc(d)}')" title="Tag entfernen">×</button>
</div>`;
}).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 => `<option value="${esc(l)}">${esc(l)}</option>`).join('');
}
const listEl = document.getElementById('templateAdminList');
if(listEl) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
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 = '<span class="hint">Vorlagen konnten nicht geladen werden.</span>';
});
}
// 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
? '<span class="hint">Für diese Leistungsphase ist noch kein Schritt hinterlegt.</span>'
: entries.map(t => `<div class="template-admin-row">
<span class="template-admin-title">${esc(t.title)}</span>
<span class="hint" style="margin:0">Start +${esc(t.offsetDays)} Tage · Dauer ${esc(t.durationDays)} Tage</span>
<button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
</div>`).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]) => `<option value="${esc(k)}"${k === gewaehlt ? ' selected' : ''}>${esc(name)}</option>`)
.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 = '<span class="hint">Wird berechnet…</span>';
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 = `<div class="settings-holiday-list">`
+ json.holidays.map(h => `<div class="template-admin-row">
<span class="template-admin-title">${esc(formatDateDE(parseDateISO(h.date)))} · ${esc(h.name)}</span>
<span class="hint" style="margin:0">${h.vorhanden ? 'bereits hinterlegt' : 'neu'}</span>
</div>`).join('')
+ `</div>`
+ `<div class="toolbar" style="margin-top:10px">`
+ (neu > 0
? `<button class="secondary small" onclick="importHolidays()">${neu} Tag${neu === 1 ? '' : 'e'} übernehmen</button>`
: `<span class="hint" style="margin:0">Alle ${json.holidays.length} Tage sind bereits hinterlegt.</span>`)
+ `<button class="secondary small" onclick="document.getElementById('holidaySuggestion').innerHTML='';">Schließen</button>`
+ `</div>`;
})
.catch(err => {
console.error('Feiertage berechnen fehlgeschlagen:', err);
box.innerHTML = '<span class="hint">Feiertage konnten nicht berechnet werden: ' + esc(err.message || err) + '</span>';
});
}
// 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));
}