Files
projektuebersicht/public/js/admin/admin.js
T
erikandClaude Opus 5.5 4c2e04e78b Kleine Verbesserungen und Teams, Outlook Integration
- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine,
  Abwesenheiten als .ics), einzurichten unter Einstellungen
- Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt
  auf die Aufgabe; einzurichten unter Admin → Teams
- Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei
  davor, im Verlauf „Weitere laden“ in Fünferschritten
- Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei
  Meilensteinen, nur aktiv mit eingerichteter KI
- Kalender zeigt die KW, auch statt der Browser-Datumsfelder
- Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:23:02 +01:00

1166 lines
57 KiB
JavaScript
Raw 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/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 = '<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.label)}')">Fertig – wieder sperren</button>`;
} else if(w.locked){
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.label)}')">Entsperren</button>` : '';
} else {
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.label)}')">Öffnen</button>`;
}
return `<div class="template-admin-row">
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip}
${aktionen}
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.label)}')" 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.
// 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 = '<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 ----------
//
// 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 => `<option value="${esc(l)}">${esc(l)}</option>`).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 = '<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>';
});
}
// 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 `<button type="button" class="vorlagen-gewerk${d === g ? ' is-active' : ''}" aria-pressed="${d === g}"
title="${esc(DEPARTMENTS[d].label)}" onclick="waehleAdminTemplateGewerk('${esc(d)}')">${esc(d)}<span>${n}</span></button>`;
}).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) => `<div class="template-admin-row vorlagen-zeile">
<input type="text" value="${esc(t.title)}" aria-label="Bezeichnung" onchange="aendereTemplateEntry('${esc(t.id)}', 'title', this.value)">
<input type="text" value="${esc(vorlagenAnteilText(t))}" placeholder="–" aria-label="Anteil in Prozent oder „in a)“" title="Zahl in Prozent oder „in a)“ = zeitgleich mit Schritt a)" onchange="aendereTemplateEntry('${esc(t.id)}', 'anteil', this.value)">
<input type="number" min="0" value="${t.durationDays ? esc(t.offsetDays) : ''}" placeholder="–" aria-label="Start in Tagen" onchange="aendereTemplateEntry('${esc(t.id)}', 'offsetDays', this.value === '' ? 0 : this.value)">
<input type="number" min="0" value="${t.durationDays ? esc(t.durationDays) : ''}" placeholder="ohne" aria-label="Dauer in Tagen, leer = ohne Termin" onchange="aendereTemplateEntry('${esc(t.id)}', 'durationDays', this.value === '' ? 0 : this.value)">
<span class="vorlagen-aktionen">
${n !== null ? `<button class="secondary small" title="nach oben" ${i === 0 ? 'disabled' : ''} onclick="verschiebeTemplateEntry('${esc(t.id)}', -1)">↑</button>
<button class="secondary small" title="nach unten" ${i === n - 1 ? 'disabled' : ''} onclick="verschiebeTemplateEntry('${esc(t.id)}', 1)">↓</button>` : '<span class="hint" style="margin:0">alle Gewerke</span>'}
<button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
</span>
</div>`;
// 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('')
+ `<div class="vorlagen-summe">Summe der Anteile in ${esc(lph)}: <b>${summe ? String(summe).replace('.', ',') + ' %' : 'keine hinterlegt'}</b>${summe ? '' : ' — Schritte ohne Anteil werden mit „Start/Dauer“ bzw. ohne Termin eingefügt.'}</div>`
: `<span class="hint">Für ${esc(g)} in ${esc(lph)} ist noch kein Schritt hinterlegt.</span>`;
}
// 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 = '<span class="hint">Lade Merkmale…</span>';
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 = '<span class="hint">Merkmale konnten nicht geladen werden.</span>';
});
}
// 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
? '<span class="hint">Noch kein Merkmal hinterlegt.</span>'
: adminPlanCheckerMerkmale.map(m => `<div class="template-admin-row">
<span class="template-admin-title">${esc(m.titel)}</span>
<span class="hint" style="margin:0">${esc(m.prompt)}</span>
<button class="danger small" onclick="deletePlanCheckerMerkmal('${esc(m.id)}')" title="Merkmal löschen">×</button>
</div>`).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]) => `<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));
}
// -----------------------------
// 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 = '<p class="hint">Der Papierkorb ist leer.</p>';
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 `<div class="template-admin-row">
<span class="template-admin-title">${esc(bez)}
<span class="hint" style="margin:0"> · gelöscht ${esc(am)}${p.deletedBy ? ' von ' + esc(p.deletedBy) : ''} · aus ${esc(p.weekLabel)}</span>
</span>
<span class="sitzung-chip">noch ${rest} ${rest === 1 ? 'Tag' : 'Tage'}</span>
<button class="secondary small" onclick="adminProjektWiederherstellen('${esc(p.id)}')">Wiederherstellen</button>
<button class="danger small" onclick="adminProjektEndgueltigLoeschen('${esc(p.id)}')" title="Sofort endgültig löschen">×</button>
</div>`;
}).join('');
} catch(err){
el.innerHTML = '<p class="hint">Papierkorb konnte nicht geladen werden: ' + esc(err.message) + '</p>';
}
}
// 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 = '<span class="settings-warn">Konnte nicht geladen werden.</span>'; return; }
if(!d || !d.ok){ el.innerHTML = '<span class="settings-warn">Keine Berechtigung.</span>'; return; }
el.innerHTML = `
<div class="settings-kv-row"><span class="settings-kv-key">Status</span>
<span>${d.konfiguriert ? '<span class="sitzung-chip ist-geoeffnet">Verbunden</span> <span class="hint" style="margin:0">' + esc(d.host) + '</span>'
: '<span class="sitzung-chip ist-gesperrt">Nicht eingerichtet</span>'}</span></div>
<label class="settings-field"><span>${d.konfiguriert ? 'Neue Adresse (ersetzt die bisherige)' : 'Adresse aus dem Teams-Workflow'}</span>
<input type="password" id="teamsWebhook" placeholder="https://…" autocomplete="off"></label>
<label class="settings-glas-schalter"><input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''}
onchange="teamsSpeichern({ neueAufgaben: this.checked })"> Neue Aufgaben melden</label>
<div class="kalender-aktionen" style="margin-top:10px">
<button type="button" onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Adresse speichern</button>
<button type="button" class="secondary" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht senden</button>
${d.konfiguriert ? '<button type="button" class="secondary" onclick="teamsEntfernen()">Entfernen</button>' : ''}
</div>
<p class="field-error" id="teamsFehler" hidden></p>`;
}
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'; }
}
}