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

+213
View File
@@ -0,0 +1,213 @@
// -----------------------------
// Einführung — geführter Rundgang
// -----------------------------
// Eriks Wunsch: Jeder Nutzer, auch bestehende, macht einmal einen geführten
// Rundgang durch die Anwendung; unter Einstellungen → Mein Konto lässt er
// sich jederzeit neu starten. Der Rundgang hebt den jeweiligen Eintrag der
// Seitenleiste hervor (Aussparung in einer abgedunkelten Ebene), wechselt in
// den Bereich und erklärt ihn in einer Sprechblase.
//
// Gezeigt wird nur, was die Rolle darf (hatRecht(), Rollen & Rechte) — ein
// Konto ohne „Anwendung nutzen“ (Wandtafel) bekommt keinen Rundgang.
// Abgeschlossen oder übersprungen → POST /einfuehrung (users.einfuehrung_am),
// danach startet er nicht mehr von selbst. Esc bzw. „Später“ schließt nur
// für diesen Seitenaufruf (sessionStorage) — beim nächsten Laden kommt er
// wieder.
//
// Ein Schritt: { id, recht?, wenn?(), tab?, ziel? (CSS-Selektor), titel,
// text (HTML, aus festen Texten) }. Ohne ziel steht die Blase in der Mitte.
const EINFUEHRUNG_SCHRITTE = [
{ id: 'start', titel: 'Willkommen in der Projektübersicht',
text: () => `Hallo ${esc((currentUser && (currentUser.displayName || '').split(' ')[0]) || '')}! In gut zwei Minuten zeigen wir dir, wo du was findest. Mit den Pfeiltasten oder „Weiter“ geht es durch, „Später“ schließt den Rundgang für jetzt.` },
{ id: 'dashboard', recht: 'app', tab: 'dashboard', ziel: '.sidebar-nav [data-tab="dashboard"]', titel: 'Dashboard',
text: () => 'Deine Startseite: die Aufgaben, die dir zugewiesen sind, die nächsten Termine deiner Projekte und ein Kalender mit Meilensteinen und Abwesenheiten. Überfälliges steht oben.' },
{ id: 'glocke', recht: 'app', ziel: '#glockeBtn', titel: 'Benachrichtigungen',
text: () => 'Die Glocke zeigt, wenn dir jemand eine neue Aufgabe zugewiesen hat. Ein Klick springt direkt in die Aufgaben des Projekts.' },
{ id: 'projekte', recht: 'app', tab: 'board', ziel: '.sidebar-nav [data-tab="board"]', titel: 'Projekte',
text: () => 'Alle Projekte der laufenden Sitzung, nach Gruppen.' + (hatRecht('proj.anlegen') ? ' Über „+ Projekt“ legst du ein neues an — ein Assistent fragt Gewerke, Auftrag, Team und Terminplan ab.' : '') },
{ id: 'projektbereich', recht: 'app', tab: 'board', ziel: '#board .board-name', titel: 'Der Projektbereich',
text: () => 'Ein Klick auf den Projektnamen öffnet den Bereich des Projekts. Oben stehen die Reiter: <b>Dashboard</b>, <b>Terminplan</b>, <b>Leistungsphasen</b>, <b>Aufgaben</b>, <b>Leistung</b>, <b>Dateien</b>, <b>Protokolle</b> und <b>Einstellungen</b>.' },
{ id: 'terminplan', recht: 'proj.plan', tab: 'board', titel: 'Terminplan und Vorlagen',
text: () => 'Im Reiter <b>Terminplan</b> legst du über „+ Neu“ Leistungsphasen, Aufgaben und Meilensteine an. „+ Vorlage“ fügt die Grundleistungen der HOAI für dein Gewerk ein — die Dauer verteilt sich nach den Siemon-Anteilen über den Phasenbalken. Gib Aufgaben einen <b>Aufwand in Stunden</b>, dann zählen sie in der Auslastung.' },
{ id: 'wochenbericht', recht: 'app', tab: 'wochenbericht', ziel: '.sidebar-nav [data-tab="wochenbericht"]', titel: 'Wochenbericht',
text: () => 'Die Tagesordnung der wöchentlichen Besprechung, je Gewerk. Links die Projekte, rechts das gewählte.' + (hatRecht('wb.kommentar') ? ' Hier schreibst du den Kommentar der Woche und den Fortschritt der Leistungsphasen; „Besprochen &amp; weiter“ führt zum nächsten Projekt.' : '') + ' „Als PDF ablegen“ speichert den Bericht.' },
{ id: 'auslastung', recht: 'app', tab: 'load', ziel: '.sidebar-nav [data-tab="load"]', titel: 'Auslastung',
text: () => 'Wie voll die nächsten zwölf Wochen für jede Person sind. Die Stunden kommen aus den Aufgaben mit Aufwand — eine Aufgabe für zwei Personen zählt für jede zur Hälfte.' },
{ id: 'abwesenheiten', wenn: () => typeof darfIrgendeineAbwesenheit === 'function' && darfIrgendeineAbwesenheit(), tab: 'abwesenheit', ziel: '.sidebar-nav [data-tab="abwesenheit"]', titel: 'Abwesenheiten',
text: () => 'Hier trägst du Urlaub, Krankheit und Fortbildung ein. Unten siehst du das Team im Monat — wer außerhalb deines Bereichs liegt, erscheint nur als „abwesend“.' + (hatRecht('team.abwAlle') || hatRecht('team.abwGewerk') || hatRecht('team.abwProjekte') ? ' Ein Klick auf einen Tag in der Zeile einer Person trägt für sie ein.' : '') },
{ id: 'planung', recht: 'wz.planung', tab: 'planung', ziel: '.sidebar-nav [data-tab="planung"]', titel: 'Planung → Terminplan',
text: () => 'Terminpläne ohne echtes Projekt — zum Ausprobieren, für Angebote oder um Kapazität abzuschätzen. Aus einem Honorar errechnet oder frei angelegt.' },
{ id: 'planchecker', recht: 'wz.checker', tab: 'planchecker', ziel: '.sidebar-nav [data-tab="planchecker"]', titel: 'Plan Checker',
text: () => 'Lädt einen Plan als PDF hoch und lässt ihn von einer KI gegen eine Liste von Merkmalen prüfen. Dafür brauchst du einen eigenen KI-Schlüssel unter Einstellungen.' },
{ id: 'einstellungen', tab: 'settings', ziel: '.sidebar-nav [data-tab="settings"]', titel: 'Einstellungen',
text: () => 'Dein Konto: Passwort, E-Mail-Adresse, deine KI-Schlüssel und die Darstellung. Hier kannst du diesen <b>Rundgang jederzeit neu starten</b>.' },
{ id: 'admin', wenn: () => typeof hatAdminRecht === 'function' && hatAdminRecht(), ziel: '#adminAccordionToggle', titel: 'Admin',
text: () => 'Du darfst Teile der Verwaltung pflegen: ' + esc(typeof ADMIN_SECTIONS !== 'undefined' ? ADMIN_SECTIONS.filter(adminAbschnittErlaubt).map(s => s.title).join(', ') : '') + '.' },
{ id: 'ende', tab: 'dashboard', titel: 'Fertig!',
text: () => {
const hinweise = [];
if(currentUser && !currentUser.personId){
hinweise.push('Dein Konto ist noch keiner Person zugeordnet — „Meine Aufgaben“ bleibt deshalb leer. Bitte einen Administrator, das in der Benutzerverwaltung nachzutragen.');
}
return 'Das war der Überblick. Bei Fragen hilft dir ein Kollege — oder du startest den Rundgang unter Einstellungen noch einmal.'
+ (hinweise.length ? '<ul class="tour-hinweise">' + hinweise.map(h => '<li>' + h + '</li>').join('') + '</ul>' : '');
} },
];
let tourSchritte = [];
let tourIndex = 0;
let tourAktiv = false;
let tourVorTab = null;
// einfuehrungBeiBedarf: startet den Rundgang, wenn er für dieses Konto noch
// aussteht — sobald die Daten geladen sind und kein Dialog (Passwort) offen
// ist. Von afterAuthenticated() und nach dem Pflicht-Passwortwechsel gerufen.
function einfuehrungBeiBedarf(){
if(!currentUser || !currentUser.einfuehrungFaellig || !hatRecht('app')) return;
try { if(sessionStorage.getItem('projuebersicht-tour-spaeter')) return; } catch(e){}
let versuche = 0;
const pruefen = () => {
if(tourAktiv) return;
const geladen = typeof currentWeek === 'function' && currentWeek();
const dialogOffen = [...document.querySelectorAll('.modal-bg')].some(m => m.style.display && m.style.display !== 'none');
const login = document.getElementById('loginOverlay');
const loginOffen = login && login.style.display !== 'none' && !login.hidden && login.offsetParent !== null;
if(geladen && !dialogOffen && !loginOffen) return einfuehrungStarten();
if(++versuche < 120) setTimeout(pruefen, 500);
};
setTimeout(pruefen, 600);
}
// einfuehrungStarten: Rundgang von vorn (auch aus Einstellungen).
function einfuehrungStarten(){
tourSchritte = EINFUEHRUNG_SCHRITTE.filter(s => (!s.recht || hatRecht(s.recht)) && (!s.wenn || s.wenn()));
tourIndex = 0;
tourAktiv = true;
tourVorTab = typeof currentTab !== 'undefined' ? currentTab : null;
let el = document.getElementById('tourEbene');
if(!el){
el = document.createElement('div');
el.id = 'tourEbene';
el.className = 'tour';
el.innerHTML = '<div class="tour-loch"></div><div class="tour-blase" role="dialog" aria-modal="true" aria-labelledby="tourTitel" tabindex="-1"></div>';
document.body.appendChild(el);
window.addEventListener('resize', () => { if(tourAktiv) tourPositionieren(); });
document.addEventListener('keydown', tourTaste, true);
}
el.hidden = false;
tourZeigen();
}
function tourTaste(e){
if(!tourAktiv) return;
if(e.key === 'Escape'){ e.preventDefault(); e.stopPropagation(); tourSpaeter(); }
else if(e.key === 'ArrowRight' || (e.key === 'Enter' && !e.target.closest('button'))){ e.preventDefault(); tourWeiter(); }
else if(e.key === 'ArrowLeft'){ e.preventDefault(); tourZurueck(); }
}
// tourZeigen: aktuellen Schritt aufbauen (Bereich wechseln, Blase füllen,
// danach Aussparung und Blase an das Ziel legen).
function tourZeigen(){
const s = tourSchritte[tourIndex];
if(!s) return tourBeenden(true);
if(s.tab && typeof showTab === 'function' && currentTab !== s.tab) showTab(s.tab);
const blase = document.querySelector('#tourEbene .tour-blase');
const letzter = tourIndex === tourSchritte.length - 1;
blase.innerHTML = `
<div class="tour-kopf"><span class="tour-schritt">${tourIndex + 1} von ${tourSchritte.length}</span>
<button type="button" class="tour-x" onclick="tourSpaeter()" title="Später (Esc)" aria-label="Rundgang schließen">×</button></div>
<h3 id="tourTitel">${esc(s.titel)}</h3>
<div class="tour-text">${s.text()}</div>
<div class="tour-punkte" aria-hidden="true">${tourSchritte.map((_, i) => `<i class="${i === tourIndex ? 'ist-aktiv' : i < tourIndex ? 'ist-erledigt' : ''}"></i>`).join('')}</div>
<div class="tour-knoepfe">
${letzter ? '' : '<button type="button" class="tour-skip" onclick="tourBeenden(true)">Überspringen</button>'}
<span class="tour-luecke"></span>
${tourIndex > 0 ? '<button type="button" class="secondary small" onclick="tourZurueck()">Zurück</button>' : '<button type="button" class="secondary small" onclick="tourSpaeter()">Später</button>'}
<button type="button" class="small" id="tourWeiter" onclick="tourWeiter()">${letzter ? 'Fertig' : (tourIndex === 0 ? 'Los geht\'s' : 'Weiter')}</button>
</div>`;
// Nach dem Bereichswechsel kurz warten, bis die Seite steht.
requestAnimationFrame(() => { tourPositionieren(); setTimeout(tourPositionieren, 260); });
const knopf = document.getElementById('tourWeiter');
if(knopf) knopf.focus({ preventScroll: true });
}
// tourZiel: sichtbares Ziel-Element des Schritts oder null (dann Mitte).
function tourZiel(s){
if(!s || !s.ziel) return null;
const el = document.querySelector(s.ziel);
if(!el || el.hidden || el.offsetParent === null) return null;
const r = el.getBoundingClientRect();
if(r.width < 2 || r.height < 2 || r.right < 0 || r.left > innerWidth) return null;
return el;
}
function tourPositionieren(){
const ebene = document.getElementById('tourEbene');
if(!ebene || !tourAktiv) return;
const s = tourSchritte[tourIndex];
const loch = ebene.querySelector('.tour-loch');
const blase = ebene.querySelector('.tour-blase');
const ziel = tourZiel(s);
const schmal = innerWidth < 640;
if(!ziel){
ebene.classList.add('ohne-ziel');
blase.style.left = Math.max(12, (innerWidth - blase.offsetWidth) / 2) + 'px';
blase.style.top = schmal ? '' : Math.max(12, (innerHeight - blase.offsetHeight) / 2) + 'px';
blase.classList.toggle('ist-unten', schmal);
return;
}
ebene.classList.remove('ohne-ziel');
ziel.scrollIntoView({ block: 'nearest', inline: 'nearest' });
const r = ziel.getBoundingClientRect();
const pad = 6;
Object.assign(loch.style, { left: (r.left - pad) + 'px', top: (r.top - pad) + 'px', width: (r.width + 2 * pad) + 'px', height: (r.height + 2 * pad) + 'px' });
blase.classList.toggle('ist-unten', schmal);
if(schmal){ blase.style.left = '12px'; blase.style.top = ''; return; }
const bw = blase.offsetWidth, bh = blase.offsetHeight, abstand = 16;
// Bevorzugt rechts vom Ziel (Seitenleiste links), sonst darunter/darüber.
let left, top;
if(r.right + abstand + bw < innerWidth - 12){
left = r.right + abstand; top = r.top + r.height / 2 - bh / 2;
} else if(r.bottom + abstand + bh < innerHeight - 12){
left = r.left + r.width / 2 - bw / 2; top = r.bottom + abstand;
} else {
left = r.left + r.width / 2 - bw / 2; top = r.top - abstand - bh;
}
blase.style.left = Math.min(Math.max(12, left), innerWidth - bw - 12) + 'px';
blase.style.top = Math.min(Math.max(12, top), innerHeight - bh - 12) + 'px';
}
function tourWeiter(){
if(tourIndex >= tourSchritte.length - 1) return tourBeenden(true);
tourIndex++; tourZeigen();
}
function tourZurueck(){
if(tourIndex === 0) return;
tourIndex--; tourZeigen();
}
// tourSpaeter: schließt nur für diesen Seitenaufruf (kommt beim nächsten
// Laden wieder, solange er nicht abgeschlossen ist).
function tourSpaeter(){
try { sessionStorage.setItem('projuebersicht-tour-spaeter', '1'); } catch(e){}
tourSchliessen();
if(tourVorTab && typeof showTab === 'function') showTab(tourVorTab);
}
// tourBeenden: abgeschlossen oder übersprungen — am Server vermerken.
function tourBeenden(){
tourSchliessen();
if(currentUser) currentUser.einfuehrungFaellig = false;
fetch(baseUrl() + '/einfuehrung', {
method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ erledigt: true })
}).catch(err => console.error('Einführung konnte nicht vermerkt werden:', err));
if(typeof showTab === 'function') showTab('dashboard');
}
function tourSchliessen(){
tourAktiv = false;
const el = document.getElementById('tourEbene');
if(el) el.hidden = true;
}