Files
erikandClaude Opus 5.5 e291ca8438 Neues Design „Bauplan“, Dashboard, Notizen-Bereich, Seitenleiste
- 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: „&…“ 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>
2026-11-02 15:07:32 +01:00

214 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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: '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;
}