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
@@ -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 & 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;
|
||||
}
|
||||
Reference in new issue
Block a user