Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+168 -90
View File
@@ -42,7 +42,7 @@
// Dass der Bereich für Nicht-Administratoren gar nicht erst auftaucht, ist
// wie überall in dieser App ein reines UI-Gate; die eigentliche Sperre sitzt
// serverseitig in requireRole('admin') auf PUT /app-settings,
// PUT /honorar-config, POST/DELETE /lph-templates, POST/DELETE /holidays,
// 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
@@ -52,19 +52,37 @@
// 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 },
{ key: 'honorar', title: 'Honorar' },
{ key: 'auslastung', title: 'Auslastung' },
{ key: 'termine', title: 'Termine & Sitzungen' },
{ key: 'feiertage', title: 'Feiertage' },
{ key: 'vorlagen', title: 'Aufgaben-Vorlagen' },
{ key: 'planchecker', title: 'Plan-Checker-Merkmale' },
{ key: 'betrieb', title: 'Betrieb & Sicherheit' },
{ key: 'papierkorb', title: 'Papierkorb' },
{ key: 'datenbank', title: 'Datenbank & Sitzungen' },
{ 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: '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';
@@ -95,13 +113,13 @@ function renderAdminArea(){
// isAdminUser: Kurzform für die Sichtbarkeit des Bereichs.
// Rückgabe: Boolean.
function isAdminUser(){
return !!(currentUser && currentUser.role === 'admin');
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);
return ADMIN_SECTIONS.some(x => x.key === key && adminAbschnittErlaubt(x));
}
// showAdminSection: schaltet auf einen Abschnitt um — blendet die zugehörige
@@ -111,8 +129,9 @@ function adminSectionExists(key){
// (Klick aus einem anderen Bereich heraus), wird zuerst dorthin gewechselt.
// Parameter: key. Keine Rückgabe.
function showAdminSection(key){
if(!adminSectionExists(key)) key = ADMIN_SECTIONS[0].key;
if(!adminSectionExists(key)) key = (ADMIN_SECTIONS.find(adminAbschnittErlaubt) || ADMIN_SECTIONS[0]).key;
adminSection = key;
adminNavAbgleichen();
if(currentTab !== 'admin'){ showTab('admin'); return; }
@@ -140,6 +159,7 @@ function showAdminSection(key){
// Der Papierkorb ändert sich durch jedes Löschen irgendwo im Büro — ebenso
// frisch holen.
if(key === 'papierkorb') loadAdminPapierkorb();
if(key === 'rollen' && typeof renderRollenRechte === 'function') renderRollenRechte();
try {
history.replaceState(null, '', location.pathname + location.search + '#' + key);
@@ -186,8 +206,8 @@ const APP_SETTING_FIELDS = {
// Kapazität der Person — ein volles Band ist eine volle Woche. Die feste
// Vorgabe 8 hatte zur Folge, dass ein roter Balken (ab 1,0) nur zu einem
// Achtel gefüllt war; Balken und Ampel widersprachen einander.
setReferenzHonorar: 'referenzHonorar',
setVerteilWochen: 'verteilzeitraumWochen',
// Referenzhonorar und Verteilzeitraum sind entfallen: Die Auslastung
// rechnet nur noch mit Aufgaben-Stunden (js/load/load.js).
},
termine: {
setStundenProTag: 'arbeitsstundenProTag',
@@ -213,49 +233,6 @@ function renderAppSettingFields(){
});
const autoLock = document.getElementById('setAutoLock');
if(autoLock) autoLock.checked = !!Number(APP_SETTINGS.autoLockAktiv);
renderAufwandMatrix();
}
// renderAufwandMatrix: die firmenweite Gewichtung als Tabelle Leistungsphase ×
// Rolle. „W+M" fehlt bewusst — das Aufwandsmodell kennt nur die
// Leistungsphasen (AUFWAND_LPS in js/load/load.js). Ein leeres Feld bedeutet
// „kein Eintrag"; dafür zählt die Auslastung dann den vollen Faktor 1,0.
// Keine Parameter, keine Rückgabe.
function renderAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
if(!wrap) return;
const vorlage = APP_SETTINGS.aufwandVorlage || {};
const phasen = LPHS.filter(l => l !== 'W+M');
wrap.innerHTML = `<table class="settings-matrix">
<thead><tr><th>Phase</th>${ROLES.map(r => `<th>${esc(r)}</th>`).join('')}</tr></thead>
<tbody>${phasen.map(l => `<tr>
<th>${esc(l)}</th>
${ROLES.map(r => {
const v = vorlage[l] ? vorlage[l][r] : undefined;
return `<td><input type="number" min="0" max="1" step="0.1"
data-lph="${esc(l)}" data-role="${esc(r)}"
value="${v === undefined ? '' : esc(v)}" placeholder="1,0"></td>`;
}).join('')}
</tr>`).join('')}</tbody>
</table>`;
}
// collectAufwandMatrix: liest die Matrix aus den Feldern zurück. Leere Felder
// werden weggelassen (siehe renderAufwandMatrix), 0 ist ein gültiger Wert und
// bleibt erhalten. Rückgabe: Objekt LPH -> { Rolle: Faktor }.
function collectAufwandMatrix(){
const wrap = document.getElementById('setAufwandMatrix');
const out = {};
if(!wrap) return out;
wrap.querySelectorAll('input[data-lph]').forEach(inp => {
if(inp.value.trim() === '') return;
const v = parseFloat(inp.value);
if(!Number.isFinite(v)) return;
const lph = inp.dataset.lph;
(out[lph] = out[lph] || {})[inp.dataset.role] = v;
});
return out;
}
// putAppSettings: schickt einen Teil-Satz an PUT /app-settings und übernimmt
@@ -294,11 +271,9 @@ function fieldValues(gruppe){
return patch;
}
// saveAuslastungSettings: Schwellen, Skala, Vollzeit-Stunden und die
// Aufwandsvorlage in einem Zug. Keine Parameter, keine Rückgabe.
// saveAuslastungSettings: Schwellen und Vollzeit-Stunden in einem Zug. Keine Parameter, keine Rückgabe.
function saveAuslastungSettings(){
const patch = fieldValues('auslastung');
patch.aufwandVorlage = collectAufwandMatrix();
putAppSettings(patch, 'Auslastungs-Einstellungen gespeichert.');
}
@@ -365,7 +340,7 @@ function renderAdminSitzungen(){
aktionen = `<button class="secondary small" onclick="adminRelockWeek('${esc(w.id)}')">Fertig – wieder sperren</button>`;
} else if(w.locked){
chip = '<span class="sitzung-chip ist-gesperrt">Gesperrt</span>';
aktionen = `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>`;
aktionen = hatRecht('s.entsperren') ? `<button class="secondary small" onclick="adminUnlockWeek('${esc(w.id)}')">Entsperren</button>` : '';
} else {
chip = '<span class="sitzung-chip ist-entsperrt">Entsperrt</span>';
aktionen = `<button class="secondary small" onclick="adminOpenWeek('${esc(w.id)}')">Öffnen</button>`;
@@ -375,7 +350,7 @@ function renderAdminSitzungen(){
<span class="template-admin-title">${esc(w.label)}${datum ? ' · ' + esc(datum) : ''}</span>
${chip}
${aktionen}
<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>
${hatRecht('s.loeschen') ? `<button class="danger small" onclick="adminDeleteWeek('${esc(w.id)}')" title="Sitzung löschen">×</button>` : ''}
</div>`;
}).join('');
}
@@ -609,17 +584,31 @@ function deleteHoliday(date){
}
// ---------- 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 der gerade gewählten Leistungsphase.
// Keine Parameter, keine Rückgabe.
// 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) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
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(); })
@@ -633,33 +622,74 @@ function loadAdminTemplates(){
});
}
// renderAdminTemplates: zeigt die Schritte der gewählten Leistungsphase in
// ihrer gepflegten Reihenfolge, je Zeile mit Versatz/Dauer und Lösch-Knopf.
// Wird auch vom onchange der Leistungsphasen-Auswahl gerufen.
// Keine Parameter, keine Rückgabe.
function renderAdminTemplates(){
const listEl = document.getElementById('templateAdminList');
if(!listEl) return;
const select = document.getElementById('adminTemplateLph');
const lph = select ? select.value : '';
const entries = adminTemplates.filter(t => t.lph === lph);
listEl.innerHTML = entries.length === 0
? '<span class="hint">Für diese Leistungsphase ist noch kein Schritt hinterlegt.</span>'
: entries.map(t => `<div class="template-admin-row">
<span class="template-admin-title">${esc(t.title)}</span>
<span class="hint" style="margin:0">Start +${esc(t.offsetDays)} Tage · Dauer ${esc(t.durationDays)} Tage</span>
<button class="danger small" onclick="deleteTemplateEntry('${esc(t.id)}')" title="Schritt aus der Vorlage löschen">×</button>
</div>`).join('');
// 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('.', ',') : '';
}
// addTemplateEntry: legt einen Schritt für die gewählte Leistungsphase an
// (POST /lph-templates, Admin-only). Keine Parameter, keine Rückgabe.
// 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; }
@@ -668,15 +698,19 @@ function addTemplateEntry(){
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,
durationDays: durationEl ? parseInt(durationEl.value, 10) || 1 : 1,
// 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 = '';
if(titleEl){ titleEl.value = ''; titleEl.focus(); }
if(anteilEl) anteilEl.value = '';
loadAdminTemplates();
showAdminNote('Schritt angelegt.');
})
@@ -686,6 +720,50 @@ function addTemplateEntry(){
});
}
// 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.
@@ -706,7 +784,7 @@ function deleteTemplateEntry(id){
// ---------- Abschnitt Plan-Checker-Merkmale ----------
//
// Dieselbe add/delete-only Liste wie die Aufgaben-Vorlagen oben, nur ohne
// 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),