- Gestaltung „Bauplan“: ruhigere Farben, Schriftskala, Abstände im 4-px-Raster, Kalenderwochen und Nummern in Mono-Ziffern, Glas beruhigt - Dashboard „Meine Aufgaben“: Kopf mit Kalenderwoche, Band „Diese Woche“ (Mo–Fr), je Projekt Meine Rolle / Team / Notizen; offene Termine blau hervorgehoben; Monatskalender unverändert - Neuer Bereich „Notizen“ in der Seitenleiste: Notizen aller eigenen Projekte und eigene Notizen ohne Projekt, später einem Projekt zuordenbar - Seitenleiste neu gegliedert (Projekte, Team, Notizen, Studenten, Einstellungen/Admin unten), Admin-Punkte in Personen/Vorgaben/System - Reiter im Projektkopf wie die Seitenleiste hervorgehoben - Behoben: „&amp;…“ in Kommentaren wuchs bei jedem Speichern; bestehende Kommentare werden beim Start repariert - Behoben: Lösch-Rückfrage bei Notizen unlesbar bzw. blieb stehen, Cursor im Notiztitel verdeckt, Dropdown im Dunkelmodus gekachelt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
13 KiB
JavaScript
214 lines
13 KiB
JavaScript
// -----------------------------
|
||
// 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: 'Rundgang abgeschlossen',
|
||
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;
|
||
}
|