- Admin → Update: neuere Versionen (Tags) vom Git-Server anzeigen und per Knopf einspielen; ausgeführt vom Dienst auf dem Rechner (Docker/update-einrichten.sh, Docker/update-ausfuehren.sh), nur vorwärts, Fehler bei ausbleibendem Neustart, abgebrochene Läufe sperren nicht - Tests (npm test): Zusammenführen paralleler Änderungen, Rechteprüfung beim Speichern; SESSIONS_DIR lenkt den Datenordner für Testserver um - node_modules, Sicherungen und Uploads aus dem Repo genommen - cors und requireRole() entfernt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1341 lines
66 KiB
JavaScript
1341 lines
66 KiB
JavaScript
// -----------------------------
|
||
// 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 requireRecht() (je Abschnitt ein Recht) 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: 'update', title: 'Update', recht: 'a.update' },
|
||
{ 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);
|
||
});
|
||
// Zwischenüberschriften (Personen/Vorgaben/System) nur, wenn darunter
|
||
// mindestens ein erlaubter Punkt steht.
|
||
document.querySelectorAll('#adminSubnav .sidebar-subgruppe').forEach(kopf => {
|
||
let el = kopf.nextElementSibling, sichtbar = false;
|
||
while(el && !el.classList.contains('sidebar-subgruppe')){ if(!el.hidden) sichtbar = true; el = el.nextElementSibling; }
|
||
kopf.hidden = !sichtbar;
|
||
});
|
||
}
|
||
|
||
// 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 === 'update') loadAdminUpdate();
|
||
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).
|
||
// ---------- Update über den Git-Server (Admin → Update) ----------
|
||
//
|
||
// Versionen sind Tags vX.Y.Z auf dem Git-Server; gesucht wird nur auf
|
||
// Knopfdruck (GET /admin/update?suchen=1). „Update installieren“ legt einen
|
||
// Auftrag ab, den der Dienst auf dem Rechner ausführt
|
||
// (Docker/update-ausfuehren.sh). Währenddessen fragt die Karte alle 5 s nach
|
||
// dem Stand — auch über den Neustart des Servers hinweg, in dem die Anfrage
|
||
// scheitert.
|
||
let updateTimer = null;
|
||
let updateZiel = ''; // Version, auf die gerade aktualisiert wird
|
||
let updateSuche = null; // letzte Antwort von „Nach Updates suchen“
|
||
let updateBestaetigen = ''; // Version, deren Knopf auf „Wirklich?“ steht
|
||
|
||
function updateDatum(iso){
|
||
const d = iso ? new Date(iso) : null;
|
||
return d && !isNaN(d) ? d.toLocaleDateString('de-DE') + ', ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
|
||
}
|
||
|
||
async function loadAdminUpdate(suchen){
|
||
const el = document.getElementById('adminUpdateInhalt');
|
||
if(!el) return;
|
||
let d;
|
||
try {
|
||
d = await fetch(baseUrl() + '/admin/update' + (suchen ? '?suchen=1' : '')).then(r => r.json());
|
||
} catch(e){
|
||
// Während des Neustarts ist der Server kurz weg — dann weiter warten.
|
||
if(updateZiel){ el.innerHTML = updateLaufHtml({ version: updateZiel, schritt: 'Neustart', laeuft: true }, true); updateWeiterFragen(); }
|
||
else 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; }
|
||
if(suchen) updateSuche = d;
|
||
|
||
const inst = d.installiert;
|
||
const st = d.status;
|
||
const laeuft = d.auftragOffen || (st && st.laeuft);
|
||
if(laeuft) updateZiel = (st && st.laeuft && st.version) || updateZiel;
|
||
|
||
let html = `<div class="teams-status${inst && inst.version ? ' ist-an' : ''}">
|
||
<span class="teams-punkt" aria-hidden="true"></span>
|
||
<span class="teams-status-text">${inst && inst.version
|
||
? '<b>Installiert: <span class="update-version">' + esc(inst.version) + '</span></b><span>' + esc(inst.commit || '') + (inst.am ? ' · seit ' + esc(updateDatum(inst.am)) : '') + '</span>'
|
||
: '<b>Installierte Version unbekannt</b><span>Wird beim Einrichten des Update-Dienstes vermerkt</span>'}</span>
|
||
</div>`;
|
||
|
||
if(!d.eingerichtet){
|
||
html += '<p class="settings-warn">Der Git-Server ist nicht eingetragen (UPDATE_REPO_API in docker-compose.yml).</p>';
|
||
} else if(!d.dienst){
|
||
html += '<p class="settings-warn">Der Update-Dienst ist auf diesem Rechner nicht eingerichtet. Einmal im Repo ausführen: <code>sudo bash Docker/update-einrichten.sh</code></p>';
|
||
}
|
||
|
||
if(laeuft){
|
||
html += updateLaufHtml(st && st.laeuft ? st : { version: updateZiel, schritt: 'Auftrag übergeben', laeuft: true });
|
||
el.innerHTML = html;
|
||
updateWeiterFragen();
|
||
return;
|
||
}
|
||
if(updateZiel){
|
||
// Lauf beendet: fertig oder Fehler.
|
||
const ok = st && !st.fehler && inst && inst.version === updateZiel;
|
||
html += ok
|
||
? `<div class="update-meldung ist-ok"><b>Aktualisiert auf ${esc(updateZiel)}</b><span>Bitte die Seite neu laden, damit der Browser die neue Version verwendet.</span>
|
||
<button type="button" class="small" onclick="location.reload()">Seite neu laden</button></div>`
|
||
: '';
|
||
if(ok) updateZiel = '';
|
||
}
|
||
if(st && st.fehler){
|
||
html += `<div class="update-meldung ist-fehler"><b>Update auf ${esc(st.version || '')} fehlgeschlagen</b>
|
||
<span>${esc(st.schritt || '')}: ${esc(st.fehler)}${st.am ? ' (' + esc(updateDatum(st.am)) + ')' : ''}</span>
|
||
${d.log ? '<pre class="update-log">' + esc(d.log) + '</pre>' : ''}</div>`;
|
||
updateZiel = '';
|
||
}
|
||
|
||
const s = updateSuche;
|
||
if(s && s.fehler) html += '<p class="settings-warn">' + esc(s.fehler) + '</p>';
|
||
else if(s && s.neuer && !s.neuer.length) html += '<p class="hint">Keine neuere Version. Neueste auf dem Git-Server: <span class="update-version">' + esc(s.neueste || '–') + '</span></p>';
|
||
else if(s && s.neuer){
|
||
const neueste = s.neuer[0].version;
|
||
html += '<div class="update-liste">' + s.neuer.map(t => `<div class="update-eintrag">
|
||
<span class="update-version">${esc(t.version)}</span>
|
||
<span class="update-text">${esc(t.text.replace(/^v\d+\.\d+\.\d+:\s*/, '') || 'ohne Beschreibung')}</span>
|
||
<span class="update-datum">${esc(updateDatum(t.datum))}</span></div>`).join('') + '</div>';
|
||
const wirklich = updateBestaetigen === neueste;
|
||
html += `<div class="teams-aktionen">
|
||
<button type="button" class="small${wirklich ? ' danger' : ''}" ${d.dienst && inst && inst.version ? '' : 'disabled'} onclick="updateInstallieren('${esc(neueste)}')">
|
||
${wirklich ? 'Wirklich? App ist einige Minuten weg' : 'Update auf ' + esc(neueste) + ' installieren'}</button>
|
||
${wirklich ? '<button type="button" class="secondary small" onclick="updateBestaetigen=\'\';loadAdminUpdate()">Abbrechen</button>' : ''}
|
||
</div>`;
|
||
}
|
||
html += `<div class="teams-aktionen"><button type="button" class="secondary small" ${d.eingerichtet ? '' : 'disabled'} onclick="loadAdminUpdate(true)">Nach Updates suchen</button></div>`;
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
// updateLaufHtml: Fortschritt eines laufenden Updates.
|
||
function updateLaufHtml(st, serverWeg){
|
||
const schritte = ['Version holen', 'Container bauen', 'Neustart'];
|
||
const i = Math.max(0, schritte.indexOf(st.schritt));
|
||
return `<div class="update-meldung ist-lauf"><b>Update auf ${esc(st.version || '')} läuft</b>
|
||
<ol class="update-schritte">${schritte.map((x, j) => `<li class="${j < i ? 'ist-fertig' : (j === i ? 'ist-jetzt' : '')}">${esc(x)}</li>`).join('')}</ol>
|
||
<span>${serverWeg ? 'Der Server startet gerade neu …' : 'Das Bauen dauert auf dem Pi einige Minuten. Diese Seite kann offen bleiben.'}</span></div>`;
|
||
}
|
||
|
||
function updateWeiterFragen(){
|
||
clearTimeout(updateTimer);
|
||
updateTimer = setTimeout(() => {
|
||
if(currentTab === 'admin' && adminSection === 'update') loadAdminUpdate();
|
||
}, 5000);
|
||
}
|
||
|
||
async function updateInstallieren(version){
|
||
if(updateBestaetigen !== version){ updateBestaetigen = version; loadAdminUpdate(); return; }
|
||
updateBestaetigen = '';
|
||
try {
|
||
const r = await fetch(baseUrl() + '/admin/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version }) });
|
||
const d = await r.json().catch(() => ({}));
|
||
if(!r.ok || !d.ok){ alert(d.error || 'Update konnte nicht gestartet werden.'); return loadAdminUpdate(); }
|
||
updateZiel = version;
|
||
updateSuche = null;
|
||
} catch(e){ alert('Server nicht erreichbar.'); }
|
||
loadAdminUpdate();
|
||
}
|
||
|
||
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; }
|
||
// Ist schon eine Adresse hinterlegt, bleibt das Eingabefeld zu, bis man
|
||
// „Ändern“ wählt — die Adresse selbst kommt ohnehin nie zurück.
|
||
const feldOffen = !d.konfiguriert;
|
||
el.innerHTML = `
|
||
<div class="teams-status${d.konfiguriert ? ' ist-an' : ''}">
|
||
<span class="teams-punkt" aria-hidden="true"></span>
|
||
<span class="teams-status-text">${d.konfiguriert
|
||
? '<b>Eingerichtet</b><span>Workflow bei ' + esc(d.host) + '</span>'
|
||
: '<b>Noch nicht eingerichtet</b><span>Adresse aus dem Teams-Workflow unten einfügen</span>'}</span>
|
||
${d.konfiguriert ? '<button type="button" class="teams-text-knopf" onclick="teamsFeldZeigen()">Adresse ändern</button>' : ''}
|
||
</div>
|
||
|
||
<div class="teams-feld" id="teamsFeld"${feldOffen ? '' : ' hidden'}>
|
||
<label for="teamsWebhook">${d.konfiguriert ? 'Neue Workflow-Adresse' : 'Workflow-Adresse'}</label>
|
||
<textarea id="teamsWebhook" rows="3" spellcheck="false" autocomplete="off"
|
||
placeholder="https://… (beim Auslöser des Teams-Workflows kopieren und hier einfügen)"
|
||
oninput="teamsFeldPruefen()"></textarea>
|
||
<div class="teams-feld-fuss">
|
||
<span class="teams-feld-hinweis" id="teamsFeldHinweis">Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.</span>
|
||
${d.konfiguriert ? '<button type="button" class="secondary small" onclick="loadAdminTeams()">Abbrechen</button>' : ''}
|
||
<button type="button" class="small" id="teamsSpeichernKnopf" disabled onclick="teamsSpeichern({ webhook: document.getElementById('teamsWebhook').value })">Speichern</button>
|
||
</div>
|
||
</div>
|
||
|
||
<label class="teams-schalter">
|
||
<input type="checkbox" id="teamsNeueAufgaben" ${d.neueAufgaben ? 'checked' : ''} ${d.konfiguriert ? '' : 'disabled'}
|
||
onchange="teamsSpeichern({ neueAufgaben: this.checked })">
|
||
<span class="teams-schalter-bahn" aria-hidden="true"></span>
|
||
<span class="teams-schalter-text"><b>Zuständige über neue Aufgaben benachrichtigen</b>
|
||
<span>Persönliche Nachricht mit Link zur Aufgabe</span></span>
|
||
</label>
|
||
|
||
<div class="teams-aktionen">
|
||
<button type="button" class="secondary small" id="teamsTestKnopf" onclick="teamsTesten()" ${d.konfiguriert ? '' : 'disabled'}>Testnachricht an mich senden</button>
|
||
${d.konfiguriert ? '<button type="button" class="teams-text-knopf ist-gefahr" onclick="teamsEntfernen()">Anbindung entfernen</button>' : ''}
|
||
</div>
|
||
<p class="field-error" id="teamsFehler" hidden></p>`;
|
||
}
|
||
|
||
// teamsFeldZeigen: blendet das Eingabefeld für eine neue Adresse ein.
|
||
function teamsFeldZeigen(){
|
||
const f = document.getElementById('teamsFeld');
|
||
if(f){ f.hidden = false; document.getElementById('teamsWebhook').focus(); }
|
||
}
|
||
|
||
// teamsFeldPruefen: Speichern erst bei einer https-Adresse; Hinweis passt sich an.
|
||
function teamsFeldPruefen(){
|
||
const wert = (document.getElementById('teamsWebhook').value || '').trim();
|
||
const ok = /^https:\/\/\S+$/.test(wert);
|
||
const knopf = document.getElementById('teamsSpeichernKnopf');
|
||
const hinweis = document.getElementById('teamsFeldHinweis');
|
||
if(knopf) knopf.disabled = !ok;
|
||
if(hinweis){
|
||
hinweis.classList.toggle('ist-fehler', !!wert && !ok);
|
||
hinweis.textContent = !wert ? 'Bleibt geheim: Nach dem Speichern wird die Adresse nicht mehr angezeigt.'
|
||
: ok ? 'Sieht gut aus — jetzt speichern und danach eine Testnachricht senden.'
|
||
: 'Die Adresse muss mit https:// beginnen und darf keine Leerzeichen enthalten.';
|
||
}
|
||
}
|
||
|
||
async function teamsSpeichern(patch){
|
||
if('webhook' in patch && !String(patch.webhook || '').trim()){ alert('Bitte die Adresse aus dem Teams-Workflow einfügen.'); return; }
|
||
const r = await fetch(baseUrl() + '/admin/teams', {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(patch)
|
||
});
|
||
const d = await r.json().catch(() => ({}));
|
||
if(!r.ok || !d.ok){ alert(d.error || 'Speichern fehlgeschlagen.'); return; }
|
||
// Nach dem ersten Eintragen der Adresse die Meldungen gleich einschalten.
|
||
if('webhook' in patch && !document.getElementById('teamsNeueAufgaben').checked){
|
||
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ neueAufgaben: true }) });
|
||
}
|
||
loadAdminTeams();
|
||
}
|
||
|
||
async function teamsEntfernen(){
|
||
if(!confirm('Teams-Anbindung entfernen? Es werden keine Nachrichten mehr gesendet.')) return;
|
||
await fetch(baseUrl() + '/admin/teams', { method: 'PUT', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ webhook: '', neueAufgaben: false }) });
|
||
loadAdminTeams();
|
||
}
|
||
|
||
async function teamsTesten(){
|
||
const knopf = document.getElementById('teamsTestKnopf');
|
||
const fehler = document.getElementById('teamsFehler');
|
||
if(knopf){ knopf.disabled = true; knopf.textContent = 'Wird gesendet…'; }
|
||
try {
|
||
const d = await fetch(baseUrl() + '/admin/teams/test', { method: 'POST' }).then(r => r.json());
|
||
if(fehler){ fehler.hidden = d.ok; fehler.textContent = d.ok ? '' : 'Nicht gesendet: ' + (d.error || 'unbekannter Fehler'); }
|
||
if(d.ok) alert('Testnachricht gesendet an ' + (d.an || 'Sie') + ' — bitte im Teams-Chat mit dem Flow-Bot nachsehen.');
|
||
} catch(e){
|
||
if(fehler){ fehler.hidden = false; fehler.textContent = 'Der Server ist nicht erreichbar.'; }
|
||
} finally {
|
||
if(knopf){ knopf.disabled = false; knopf.textContent = 'Testnachricht senden'; }
|
||
}
|
||
}
|