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:
1 parent
f4fb3c550b
commit
b7e0bd0d92
56 files changed
+12265
-2974
No files matched your search
+168
-90
@@ -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),
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// -----------------------------
|
||||
// Admin — Rollen & Rechte
|
||||
// -----------------------------
|
||||
// Eriks Wahl „B“ aus dem Entwurfsvergleich: links die Rollen (Anzahl Konten,
|
||||
// Anteil der Rechte), darunter „+ Neue Rolle“; rechts die gewählte Rolle mit
|
||||
// einem Schalter je Recht, nach Gruppen, und „alle/keine“ je Gruppe. Über
|
||||
// „Vergleich mit“ steht neben jedem Schalter, ob eine zweite Rolle das Recht
|
||||
// hat (Abweichungen hervorgehoben).
|
||||
//
|
||||
// Daten und Katalog kommen vom Server (GET /rollen, server.js „Rollen &
|
||||
// Rechte: Routen“); jede Änderung geht sofort hin (PUT /rollen/:key/rechte,
|
||||
// POST/PATCH/DELETE /rollen) und die Antwort ersetzt den Stand hier. Kein
|
||||
// eigenes Speichern. Ändert jemand seine eigene Rolle, lädt sich /whoami neu,
|
||||
// damit Seitenleiste und Knöpfe sofort stimmen.
|
||||
|
||||
let rrDaten = null; // { katalog:[{gruppe, rechte:[{key,name,hinweis}]}], rollen:[…] }
|
||||
let rrAuswahl = 'member'; // gewählte Rolle
|
||||
let rrVergleich = ''; // Vergleichsrolle ('' = keiner)
|
||||
let rrNeuOffen = false; // Formular „+ Neue Rolle“ aufgeklappt
|
||||
let rrMeldung = '';
|
||||
let rrMeldungTimer = null;
|
||||
|
||||
// renderRollenRechte: Einstieg beim Öffnen des Abschnitts (showAdminSection).
|
||||
function renderRollenRechte(){
|
||||
const el = document.getElementById('rollenRechte');
|
||||
if(!el) return;
|
||||
if(!rrDaten) el.innerHTML = '<span class="hint">Lade Rollen…</span>';
|
||||
fetch(baseUrl() + '/rollen')
|
||||
.then(r => r.json())
|
||||
.then(j => { if(!j.ok) throw new Error(j.error || 'Laden fehlgeschlagen'); rrUebernehmen(j); })
|
||||
.catch(err => { el.innerHTML = `<span class="hint">Rollen konnten nicht geladen werden: ${esc(err.message || err)}</span>`; });
|
||||
}
|
||||
|
||||
// rrUebernehmen: neue Serverantwort übernehmen und neu zeichnen.
|
||||
function rrUebernehmen(j){
|
||||
rrDaten = j;
|
||||
if(!j.rollen.some(r => r.key === rrAuswahl)) rrAuswahl = (j.rollen.find(r => !r.fest) || j.rollen[0] || {}).key;
|
||||
if(rrVergleich && !j.rollen.some(r => r.key === rrVergleich)) rrVergleich = '';
|
||||
rrZeichnen();
|
||||
}
|
||||
|
||||
function rrMelden(text){
|
||||
rrMeldung = text;
|
||||
const el = document.getElementById('rrMeldung');
|
||||
if(el) el.textContent = text;
|
||||
clearTimeout(rrMeldungTimer);
|
||||
rrMeldungTimer = setTimeout(() => { rrMeldung = ''; const e = document.getElementById('rrMeldung'); if(e) e.textContent = ''; }, 2500);
|
||||
}
|
||||
|
||||
// rrSenden: gemeinsamer Weg aller Änderungen. Parameter: methode, pfad,
|
||||
// body, okText. Rückgabe: Promise der Serverantwort (oder null bei Fehler).
|
||||
function rrSenden(methode, pfad, body, okText){
|
||||
return fetch(baseUrl() + pfad, {
|
||||
method: methode,
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
})
|
||||
.then(r => r.json().then(j => ({ ok: r.ok, j })))
|
||||
.then(({ ok, j }) => {
|
||||
if(!ok || !j.ok) throw new Error((j && j.error) || 'Fehlgeschlagen');
|
||||
rrUebernehmen(j);
|
||||
if(okText) rrMelden(okText);
|
||||
rrEigeneRechteAuffrischen();
|
||||
return j;
|
||||
})
|
||||
.catch(err => { rrMelden(err.message || String(err)); rrZeichnen(); return null; });
|
||||
}
|
||||
|
||||
// rrEigeneRechteAuffrischen: betrifft eine Änderung die eigene Rolle, holt
|
||||
// /whoami die neuen Rechte, damit Seitenleiste und Knöpfe gleich stimmen.
|
||||
function rrEigeneRechteAuffrischen(){
|
||||
fetch(baseUrl() + '/whoami').then(r => r.json()).then(j => {
|
||||
if(j && j.user && currentUser && j.user.id === currentUser.id){
|
||||
currentUser = j.user;
|
||||
if(typeof renderUserBar === 'function') renderUserBar();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const rrRolle = key => rrDaten && rrDaten.rollen.find(r => r.key === key);
|
||||
const rrAlleRechte = () => rrDaten.katalog.flatMap(g => g.rechte.map(r => r.key));
|
||||
|
||||
// Aktionen (aus den Inline-Handlern)
|
||||
function rrWaehlen(key){ rrAuswahl = key; rrLoeschenBestaetigen = ''; rrZeichnen(); }
|
||||
function rrVergleichSetzen(key){ rrVergleich = key; rrZeichnen(); }
|
||||
function rrNeuUmschalten(offen){
|
||||
rrNeuOffen = offen; rrZeichnen();
|
||||
if(offen){ const i = document.getElementById('rrNeuName'); if(i) i.focus(); }
|
||||
}
|
||||
function rrRechtSchalten(key, recht){
|
||||
const r = rrRolle(key); if(!r || r.fest) return;
|
||||
const an = !r.rechte.includes(recht);
|
||||
rrSenden('PUT', '/rollen/' + encodeURIComponent(key) + '/rechte', { rechte: [recht], an }, 'Gespeichert');
|
||||
}
|
||||
function rrGruppeSchalten(key, gi){
|
||||
const r = rrRolle(key); if(!r || r.fest) return;
|
||||
const keys = rrDaten.katalog[gi].rechte.map(x => x.key);
|
||||
const an = !keys.every(k => r.rechte.includes(k));
|
||||
rrSenden('PUT', '/rollen/' + encodeURIComponent(key) + '/rechte', { rechte: keys, an }, 'Gespeichert');
|
||||
}
|
||||
function rrAnlegen(){
|
||||
const name = (document.getElementById('rrNeuName') || {}).value || '';
|
||||
const von = (document.getElementById('rrNeuVon') || {}).value || '';
|
||||
if(!name.trim()){ rrMelden('Bitte einen Namen eingeben.'); return; }
|
||||
rrSenden('POST', '/rollen', { name: name.trim(), vonRolle: von }, 'Rolle „' + name.trim() + '“ angelegt.')
|
||||
.then(j => { if(j && j.neu){ rrAuswahl = j.neu; rrNeuOffen = false; rrZeichnen(); } });
|
||||
}
|
||||
function rrUmbenennen(key, name){
|
||||
const r = rrRolle(key); if(!r || r.fest || !name.trim() || name.trim() === r.name) return;
|
||||
rrSenden('PATCH', '/rollen/' + encodeURIComponent(key), { name: name.trim() }, 'Umbenannt.');
|
||||
}
|
||||
function rrKopieren(key){
|
||||
const r = rrRolle(key); if(!r) return;
|
||||
let name = r.name + ' (Kopie)', i = 2;
|
||||
while(rrDaten.rollen.some(x => x.name.toLowerCase() === name.toLowerCase())) name = r.name + ' (Kopie ' + (i++) + ')';
|
||||
rrSenden('POST', '/rollen', { name, vonRolle: key }, 'Kopie angelegt.')
|
||||
.then(j => { if(j && j.neu){ rrAuswahl = j.neu; rrZeichnen(); } });
|
||||
}
|
||||
// rrLoeschen: zweistufig im Knopf selbst (kein confirm()) — erster Klick
|
||||
// fragt nach, zweiter löscht.
|
||||
let rrLoeschenBestaetigen = '';
|
||||
function rrLoeschen(key){
|
||||
if(rrLoeschenBestaetigen !== key){ rrLoeschenBestaetigen = key; rrZeichnen(); return; }
|
||||
rrLoeschenBestaetigen = '';
|
||||
rrSenden('DELETE', '/rollen/' + encodeURIComponent(key), null, 'Rolle gelöscht.');
|
||||
}
|
||||
|
||||
// rrZeichnen: baut die ganze Maske. Fokus und Eingaben im Formular „Neue
|
||||
// Rolle“ bleiben erhalten.
|
||||
function rrZeichnen(){
|
||||
const el = document.getElementById('rollenRechte');
|
||||
if(!el || !rrDaten) return;
|
||||
const fokusId = document.activeElement && document.activeElement.id;
|
||||
const neuName = (document.getElementById('rrNeuName') || {}).value || '';
|
||||
const alle = rrAlleRechte();
|
||||
const r = rrRolle(rrAuswahl) || rrDaten.rollen[0];
|
||||
const v = rrVergleich && rrVergleich !== r.key ? rrRolle(rrVergleich) : null;
|
||||
const rechteVon = x => x.fest ? alle : x.rechte;
|
||||
|
||||
const liste = rrDaten.rollen.map(x => {
|
||||
const n = rechteVon(x).length;
|
||||
return `<button type="button" class="rr-rolle${x.key === r.key ? ' ist-aktiv' : ''}" onclick="rrWaehlen('${esc(x.key)}')">
|
||||
<b>${esc(x.name)}</b>
|
||||
<small>${x.konten} ${x.konten === 1 ? 'Konto' : 'Konten'} · ${n} von ${alle.length} Rechten${x.fest ? ' · fest' : ''}</small>
|
||||
<span class="rr-balken"><i style="width:${(n / alle.length * 100).toFixed(1)}%"></i></span>
|
||||
</button>`;
|
||||
}).join('');
|
||||
|
||||
const neu = rrNeuOffen
|
||||
? `<div class="rr-neu">
|
||||
<input id="rrNeuName" type="text" placeholder="Name, z. B. Werkstudent" aria-label="Name der neuen Rolle"
|
||||
onkeydown="if(event.key==='Enter') rrAnlegen(); if(event.key==='Escape') rrNeuUmschalten(false);">
|
||||
<label>Rechte übernehmen von
|
||||
<select id="rrNeuVon"><option value="">keiner (leer)</option>${rrDaten.rollen.map(x => `<option value="${esc(x.key)}">${esc(x.name)}</option>`).join('')}</select>
|
||||
</label>
|
||||
<div class="rr-neu-knoepfe">
|
||||
<button type="button" class="small" onclick="rrAnlegen()">Anlegen</button>
|
||||
<button type="button" class="secondary small" onclick="rrNeuUmschalten(false)">Abbrechen</button>
|
||||
</div>
|
||||
</div>`
|
||||
: `<button type="button" class="rr-plus" onclick="rrNeuUmschalten(true)">+ Neue Rolle</button>`;
|
||||
|
||||
const gruppen = rrDaten.katalog.map((g, gi) => {
|
||||
const rechteR = rechteVon(r);
|
||||
const n = g.rechte.filter(x => rechteR.includes(x.key)).length;
|
||||
const zeilen = g.rechte.map(x => {
|
||||
const an = rechteR.includes(x.key);
|
||||
const vAn = v ? rechteVon(v).includes(x.key) : null;
|
||||
return `<div class="rr-zeile">
|
||||
<button type="button" class="rr-schalter${an ? ' ist-an' : ''}" role="switch" aria-checked="${an}" aria-label="${esc(x.name)}"
|
||||
${r.fest ? 'disabled' : `onclick="rrRechtSchalten('${esc(r.key)}','${esc(x.key)}')"`}></button>
|
||||
<span class="rr-name">${esc(x.name)}${x.hinweis ? `<small>${esc(x.hinweis)}</small>` : ''}</span>
|
||||
${v ? `<span class="rr-vergleich${vAn !== an ? ' ist-anders' : ''}" title="${esc(v.name)}">${vAn ? 'ja' : 'nein'}</span>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
return `<div class="rr-gruppe">
|
||||
<div class="rr-gruppe-kopf"><b>${esc(g.gruppe)}</b><small>${n} von ${g.rechte.length}</small><span class="rr-luecke"></span>
|
||||
${r.fest ? '' : `<button type="button" class="secondary small" onclick="rrGruppeSchalten('${esc(r.key)}', ${gi})">${n === g.rechte.length ? 'keine' : 'alle'}</button>`}
|
||||
</div>
|
||||
${zeilen}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const loeschText = rrLoeschenBestaetigen === r.key ? 'Wirklich löschen?' : 'Löschen';
|
||||
el.innerHTML = `<div class="rr-layout">
|
||||
<div class="rr-rollen">${liste}${neu}</div>
|
||||
<div class="rr-detail">
|
||||
<div class="rr-detail-kopf">
|
||||
<input id="rrName" class="rr-titel" value="${esc(r.name)}" aria-label="Name der Rolle" ${r.fest ? 'disabled' : ''}
|
||||
onchange="rrUmbenennen('${esc(r.key)}', this.value)" onkeydown="if(event.key==='Enter') this.blur()">
|
||||
<label class="rr-vergleich-wahl">Vergleich mit
|
||||
<select id="rrVergleich" onchange="rrVergleichSetzen(this.value)">
|
||||
<option value="">keiner</option>
|
||||
${rrDaten.rollen.filter(x => x.key !== r.key).map(x => `<option value="${esc(x.key)}" ${v && x.key === v.key ? 'selected' : ''}>${esc(x.name)}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
${r.fest ? '<span class="hint" style="margin:0">Administrator hat immer alle Rechte.</span>'
|
||||
: `<button type="button" class="secondary small" onclick="rrKopieren('${esc(r.key)}')">Kopieren</button>
|
||||
<button type="button" class="danger small" onclick="rrLoeschen('${esc(r.key)}')">${loeschText}</button>`}
|
||||
<span class="rr-meldung" id="rrMeldung" role="status">${esc(rrMeldung)}</span>
|
||||
</div>
|
||||
<div class="rr-gruppen">${gruppen}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const ni = document.getElementById('rrNeuName');
|
||||
if(ni && neuName) ni.value = neuName;
|
||||
if(fokusId){ const f = document.getElementById(fokusId); if(f) f.focus(); }
|
||||
}
|
||||
Reference in new issue
Block a user