- 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>
892 lines
47 KiB
JavaScript
892 lines
47 KiB
JavaScript
// -----------------------------
|
|
// Projektübersicht — Wandtafel (Touch-Dashboard)
|
|
// -----------------------------
|
|
// Modul für den Tab "Wandtafel" (App.html, Ladereihenfolge: ... → load →
|
|
// dashboard → wand → settings → ...). Ersetzt den früheren Show Mode: der
|
|
// klonte nur die gerade gerenderte Board- bzw. Auslastungs-Ansicht der EINEN
|
|
// angemeldeten Sitzung (kein eigener Datenpfad, keine Aggregation über
|
|
// Gewerke oder Nutzer hinweg). Gedacht für ein dauerhaft laufendes
|
|
// Touch-Display mit einem eigens dafür angelegten Kiosk-Konto — siehe
|
|
// CLAUDE.md "Wandtafel".
|
|
//
|
|
// Aufbau: aus einem Vergleich mehrerer komplett unterschiedlicher
|
|
// Gesamtentwürfe wählte Erik die "Leitstand"-Struktur (Kopf → eine
|
|
// Kennzahlen-Reihe aus vier Kacheln → eine Reihe aus drei Panels), dann in
|
|
// einem zweiten Durchgang die konkrete Gestaltung ("Pastell Freundlich"),
|
|
// zuletzt umgefärbt in die zwei Farben des Firmenlogos (aus public/logo.ico
|
|
// ausgelesen) statt einer generischen Pastellmischung — siehe die
|
|
// --dash-*-Eigenschaften in styles.css. Kopf: Marke "Dashboard" + Sitzung/
|
|
// Uhrzeit + wer heute abwesend ist (renderWandDashKopfHtml()). Kennzahlen-
|
|
// Reihe: Auslastung gesamt, Besprochen gesamt, offene Aufgaben, überfällig
|
|
// (wandKpiReiheHtml()/wandGesamtKennzahlen()). Panel-Reihe: Auslastung je
|
|
// Gewerk, Besprochen je Gewerk, Aktuell — ein chronologisch sortierter
|
|
// Ereignis-Feed (wandAuslastungInhalt()/wandBesprochenInhalt()/
|
|
// wandAktuellInhalt(), gemeinsam gerahmt von wandPanel()). Die Kacheln/
|
|
// Panels selbst sind NICHT antippbar (kein Auf-/Zuklappen, auf Eriks
|
|
// Wunsch) — stattdessen ist jedes einzelne darin dargestellte Element für
|
|
// sich antippbar (eine Gewerk-Zeile, eine Kennzahl, ein Kürzel, ein
|
|
// Feed-Eintrag) und führt in eine Vollbild-Übersicht bzw. direkt ins
|
|
// betroffene Projekt. Jede Kachel/jedes Ereignis ist eine reine Projektion
|
|
// bereits geladener Daten (dasselbe db aus GET /load-all, das jeder andere
|
|
// Tab auch nutzt) — keine eigene Server-Route, keine schreibenden Aktionen.
|
|
// Wiederverwendet werden aufgabenVerlaufJePerson()/loadAmpelColor()/
|
|
// kapazitaetsFaktor()/stundenFormat()/lpActiveList()/schwelleGelb()/
|
|
// schwelleRot() aus js/load/load.js,
|
|
// classifyProjectChange()/findPreviousWeek()/springZumWochenbericht() aus
|
|
// js/wochenbericht/wochenbericht.js bzw. js/plan/plan-info.js,
|
|
// statusPreviewText()/renderBoardRow()/renderBoardDrawer()/BOARD_OPTS aus
|
|
// js/board/board.js, relativerTag()/tageBis()/initialsFor()/
|
|
// formatWeekDate() aus js/dashboard/dashboard.js bzw. js/utils.js,
|
|
// GROUP_ORDER/ROLES aus js/globals.js, sortGroups()/peopleByRole() aus
|
|
// js/utils.js, esc()/heuteIso() aus js/utils.js, currentWeek() aus
|
|
// js/weeks.js, load()/baseUrl() aus js/store.js/js/server.js,
|
|
// showLoginOverlay() aus js/auth.js, openProjectPlan() aus js/gantt.js
|
|
// (jeder Sprung ins Projekt verlässt die Wandtafel bewusst in den normalen
|
|
// Projektbereich, Seitenleiste und Kopfzeile kommen dabei zurück — wie bei
|
|
// jedem anderen Tab-Wechsel auch).
|
|
//
|
|
// Die Normalansicht ist höher als ein Bildschirm: unterhalb von Kopf,
|
|
// Kennzahlen- und Panel-Reihe folgt die vollständige Projektliste
|
|
// (wandProjektlisteHtml()) — auf Eriks ausdrücklichen Wunsch optisch
|
|
// identisch mit der echten Projektliste (Tab "Projekte"), nicht eine eigene,
|
|
// vereinfachte Zeilenform: Jede Gruppe/Zeile entsteht über denselben
|
|
// renderBoardRow()/BOARD_OPTS wie dort, nur mit locked FEST auf true (siehe
|
|
// wandProjektlisteHtml()) — die Wandtafel schreibt nie, also gibt es hier
|
|
// nie Ziehgriffe oder einen Löschen-Knopf, unabhängig vom echten Sperrstatus
|
|
// der Sitzung. Das Display ist ein echter Touch-Monitor — Erik kann
|
|
// davortreten und darin scrollen; ohne Berührung kehrt die Ansicht nach
|
|
// WAND_IDLE_MS von selbst an den Anfang zurück (wandAktivitaetErkannt()/
|
|
// wandBeiUntaetigkeitZuruecksetzen()), damit ein unbeaufsichtigtes Display
|
|
// nicht dauerhaft im Zustand der letzten vorbeigehenden Person hängen
|
|
// bleibt. Erik wollte diesen Rücksprung nicht nur erleben, sondern vorher
|
|
// sehen können: #wandIdleCountdown (neben "← Zur Übersicht", App.html) zeigt
|
|
// laufend die verbleibende Zeit (wandCountdownTick(), ein eigenes
|
|
// 1-Sekunden-Intervall, siehe startWandboard()/stopWandboard()).
|
|
// Dasselbe gilt auch AUSSERHALB der Wandtafel: Ein Klick auf eine
|
|
// Projekt-/Gewerk-Fläche verlässt sie ja absichtlich (Projektbereich bzw.
|
|
// Wochenbericht) — ein document-weiter, capturing Klick-Lauscher auf
|
|
// #wandContent setzt dabei wandUnterwegs, worüber dieselbe globale
|
|
// Aktivitätserkennung (wandAktivitaetErkannt()) statt der Untätigkeits- die
|
|
// Rückkehr-Uhr bedient; nach WAND_IDLE_MS ohne Berührung dort kehrt
|
|
// wandKehreZurueck() von selbst auf die Wandtafel zurück. Der bewusste
|
|
// Ausstieg über "← Zur Übersicht" (außerhalb von #wandContent) setzt
|
|
// wandUnterwegs NICHT — eine Vorschau durch Personal soll nicht nach einer
|
|
// Minute unterbrochen werden.
|
|
|
|
// WAND_GEWERKE: die drei Fachrichtungen, in dieser Reihenfolge überall auf
|
|
// der Wandtafel. Dieselben Werte wie VALID_DEPARTMENTS/project_gewerke.
|
|
const WAND_GEWERKE = ['ELT', 'HLS', 'ARCH'];
|
|
|
|
const WAND_REFRESH_MS = 60000; // Hintergrund-Aktualisierung
|
|
// WAND_IDLE_MS: Erik hat einen echten Touch-Monitor — er darf davortreten und
|
|
// in der Projektliste unterhalb der Kacheln scrollen ("ich kann mich vor die
|
|
// Tafel stellen und scrollen"). Nach dieser Zeit ohne jede Berührung kehrt
|
|
// die Ansicht von selbst in den Normalzustand zurück (oben, keine offene
|
|
// Übersicht) — ein unbeaufsichtigtes Display darf nicht dauerhaft in einem
|
|
// Zustand hängen bleiben, den zufällig die letzte vorbeigehende Person
|
|
// hinterlassen hat.
|
|
const WAND_IDLE_MS = 60000;
|
|
|
|
let wandRefreshTimer = null;
|
|
let wandIdleTimer = null;
|
|
let wandCountdownTimer = null;
|
|
let wandIdleDeadline = 0; // Date.now()-Zeitpunkt, zu dem die Untätigkeits-Uhr auslöst
|
|
let wandRueckkehrTimer = null;
|
|
// wandUnterwegs: true zwischen einem Klick auf eine Projekt-/Gewerk-Fläche
|
|
// DER WANDTAFEL (gesetzt vom Klick-Lauscher auf #wandContent weiter unten)
|
|
// und der Rückkehr dorthin — nicht beim Verlassen über "← Zur Übersicht"
|
|
// (ein bewusster Ausstieg für eine Vorschau, kein versehentliches Antippen,
|
|
// und außerhalb von #wandContent). Nur während dieser Zeit treibt
|
|
// wandAktivitaetErkannt() die Rückkehr-Uhr statt der Untätigkeits-Uhr.
|
|
let wandUnterwegs = false;
|
|
let wandAktuelleWoche = null; // für Übersicht-Klicks gemerkt (re-render ohne erneutes Laden)
|
|
// wandUebersicht: null (zweispaltige Normalansicht) oder {typ, gewerk?} —
|
|
// welche Vollbild-Übersicht gerade anstelle der Normalansicht steht. gewerk
|
|
// ist nur bei typ 'auslastung'/'besprochen' gesetzt (eine Übersicht je
|
|
// Gewerk).
|
|
let wandUebersicht = null;
|
|
|
|
// startWandboard: startet die Hintergrund-Aktualisierung, falls nicht schon
|
|
// aktiv. Der Schon-aktiv-Schutz ist kein Komfort, sondern notwendig: load()
|
|
// ruft an seinem Ende selbst showTab(currentTab) auf — showTab('wand') riefe
|
|
// ohne diesen Schutz bei JEDEM eigenen 60s-Takt erneut startWandboard() auf
|
|
// und setzte damit sofort einen weiteren, ungeduldigen Takt in Gang (eine
|
|
// Rückkopplungsschleife, die praktisch ohne Pause neu lädt). Nach einer
|
|
// erneuten Anmeldung (stopWandboard() setzt wandRefreshTimer zuvor auf null,
|
|
// siehe wandRefreshTick()) greift der Schutz dagegen nicht und die
|
|
// Aktualisierung startet korrekt neu. Keine Parameter, keine Rückgabe.
|
|
function startWandboard(){
|
|
if(wandRefreshTimer) return;
|
|
// Ein frisches Betreten beginnt immer bei der normalen Ansicht, nie in
|
|
// einer Übersicht hängen — die läuft nur innerhalb EINES Aufenthalts auf
|
|
// der Wandtafel weiter (der 60s-Hintergrund-Takt ruft startWandboard()
|
|
// zwar erneut auf, der Schutz oben greift dann aber schon vorher, sodass
|
|
// dieser Reset NICHT bei jedem Takt läuft). Eine evtl. noch laufende
|
|
// Rückkehr-Uhr endet hier ebenfalls — sie ist erfüllt, sobald die
|
|
// Wandtafel wieder aktiv ist, egal ob durch sie selbst ausgelöst oder
|
|
// durch eine andere Navigation dorthin.
|
|
wandUebersicht = null;
|
|
wandUnterwegs = false;
|
|
clearTimeout(wandRueckkehrTimer); wandRueckkehrTimer = null;
|
|
wandRefreshTick();
|
|
wandRefreshTimer = setInterval(wandRefreshTick, WAND_REFRESH_MS);
|
|
wandAktivitaetErkannt(); // startet die Untätigkeits-Uhr für diesen Aufenthalt
|
|
wandCountdownTick();
|
|
wandCountdownTimer = setInterval(wandCountdownTick, 1000);
|
|
}
|
|
|
|
// stopWandboard: beendet den Hintergrund-Takt der Wandtafel. Wird von
|
|
// showTab() beim Verlassen des Tabs aufgerufen (NACH dem Entfernen von
|
|
// body.wandtafel-aktiv, siehe tabs-auth.js). Wenn das Verlassen durch einen
|
|
// Klick INNERHALB von #wandContent ausgelöst wurde (wandUnterwegs, siehe der
|
|
// document-weite Klick-Lauscher unten), wird hier sofort die Rückkehr-Uhr
|
|
// scharf gestellt — wandAktivitaetErkannt() nimmt wegen des bereits
|
|
// entfernten wandtafel-aktiv automatisch den passenden Zweig. Ein Verlassen
|
|
// OHNE vorherigen Klick (etwa stopWandboard() aus wandRefreshTick() bei
|
|
// abgelaufener Sitzung) lässt wandUnterwegs unberührt — dort gibt es nichts,
|
|
// wohin zurückzukehren wäre. Keine Parameter, keine Rückgabe.
|
|
function stopWandboard(){
|
|
clearInterval(wandRefreshTimer); wandRefreshTimer = null;
|
|
clearTimeout(wandIdleTimer); wandIdleTimer = null;
|
|
clearInterval(wandCountdownTimer); wandCountdownTimer = null;
|
|
const el = document.getElementById('wandIdleCountdown');
|
|
if(el) el.textContent = '';
|
|
if(wandUnterwegs) wandAktivitaetErkannt();
|
|
}
|
|
|
|
// wandAktivitaetErkannt: setzt je nach Lage entweder die Untätigkeits-Uhr
|
|
// (auf der Wandtafel selbst) oder die Rückkehr-Uhr (unterwegs, siehe
|
|
// wandUnterwegs) zurück. Horcht global auf Berührung/Maus/Tastatur/Scroll —
|
|
// so kann der Listener einmalig beim Laden des Skripts registriert werden,
|
|
// statt bei jedem Betreten/Verlassen an- und abgemeldet zu werden, und ist
|
|
// überall sonst (kein wandtafel-aktiv, nicht wandUnterwegs) ein reines
|
|
// No-op. Keine Parameter, keine Rückgabe.
|
|
function wandAktivitaetErkannt(){
|
|
if(document.body.classList.contains('wandtafel-aktiv')){
|
|
clearTimeout(wandIdleTimer);
|
|
wandIdleTimer = setTimeout(wandBeiUntaetigkeitZuruecksetzen, WAND_IDLE_MS);
|
|
wandIdleDeadline = Date.now() + WAND_IDLE_MS;
|
|
wandCountdownTick();
|
|
} else if(wandUnterwegs){
|
|
clearTimeout(wandRueckkehrTimer);
|
|
wandRueckkehrTimer = setTimeout(wandKehreZurueck, WAND_IDLE_MS);
|
|
}
|
|
}
|
|
|
|
// wandKehreZurueck: nach WAND_IDLE_MS ohne Berührung AUSSERHALB der
|
|
// Wandtafel — ausgelöst durch einen Klick auf eine ihrer eigenen
|
|
// Projekt-/Gewerk-Flächen (wandUnterwegs, siehe dort) — zurück zur
|
|
// Wandtafel. Erik: "auch wenn ich eine der Flächen/Projekte auf der Tafel
|
|
// anklicke soll nach 1 min das Bild wieder zurückspringen" — ein kurzer
|
|
// Blick ins Projekt darf das unbeaufsichtigte Display nicht dauerhaft
|
|
// woanders hinterlassen. Keine Parameter, keine Rückgabe.
|
|
function wandKehreZurueck(){
|
|
wandRueckkehrTimer = null;
|
|
showTab('wand');
|
|
}
|
|
|
|
// wandCountdownTick: schreibt die verbleibende Zeit bis zum automatischen
|
|
// Rücksprung in #wandIdleCountdown (Kopfzeile neben "← Zur Übersicht", siehe
|
|
// App.html) — Erik wollte sehen können, wann das passiert, statt vom Sprung
|
|
// überrascht zu werden. Läuft über ein eigenes 1-Sekunden-Intervall statt
|
|
// über wandIdleTimer selbst, da der nur EIN MAL (beim Auslösen) feuert.
|
|
// Keine Parameter, keine Rückgabe.
|
|
// wandImGrundzustand: ganz oben gescrollt und keine Übersicht offen — dann
|
|
// gibt es nichts zurückzusetzen, und der Rücksprung-Zähler ruht (Eriks
|
|
// Wunsch: "Der Rücksprung-Timer muss nur laufen, wenn die Seite nicht nach
|
|
// ganz oben gescrollt ist"). Rückgabe: Boolean.
|
|
function wandImGrundzustand(){
|
|
const c = document.getElementById('wandContent');
|
|
return !wandUebersicht && (!c || c.scrollTop <= 2);
|
|
}
|
|
|
|
function wandCountdownTick(){
|
|
const uhr = document.getElementById('wtUhr');
|
|
if(uhr) uhr.textContent = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
|
const el = document.getElementById('wandIdleCountdown');
|
|
if(!el) return;
|
|
// Im Grundzustand ruht die Uhr: ausblenden und die volle Minute
|
|
// vorhalten — sobald jemand scrollt oder eine Übersicht öffnet, beginnt
|
|
// sie dort bei 60 s (das Scrollen meldet wandAktivitaetErkannt() ohnehin).
|
|
if(document.body.classList.contains('wandtafel-aktiv') && wandImGrundzustand()){
|
|
clearTimeout(wandIdleTimer);
|
|
wandIdleTimer = setTimeout(wandBeiUntaetigkeitZuruecksetzen, WAND_IDLE_MS);
|
|
wandIdleDeadline = Date.now() + WAND_IDLE_MS;
|
|
el.textContent = '';
|
|
return;
|
|
}
|
|
const rest = Math.max(0, Math.ceil((wandIdleDeadline - Date.now()) / 1000));
|
|
el.textContent = rest > 0 ? ('Rücksprung in ' + rest + ' s') : '';
|
|
}
|
|
|
|
// wandBeiUntaetigkeitZuruecksetzen: nach WAND_IDLE_MS ohne Berührung zurück in
|
|
// den Normalzustand — eine offene Übersicht schließt sich, und die
|
|
// Projektliste scrollt an den Anfang zurück. Unbedingt (nicht nur wenn gerade
|
|
// eine Übersicht offen war): Auch ein bloßes Herunterscrollen in der
|
|
// Projektliste soll sich nach einer Minute von selbst zurücksetzen.
|
|
// Startet die Uhr am Ende selbst neu (wandAktivitaetErkannt()), statt sie
|
|
// auslaufen zu lassen — die Anzeige in #wandIdleCountdown soll durchgehend
|
|
// laufen, nicht nach dem ersten Rücksprung stehen bleiben, solange niemand
|
|
// die Tafel berührt. Keine Parameter, keine Rückgabe.
|
|
function wandBeiUntaetigkeitZuruecksetzen(){
|
|
if(wandImGrundzustand()){ wandAktivitaetErkannt(); return; }
|
|
wandUebersicht = null;
|
|
renderWandboard({ resetScroll: true });
|
|
wandAktivitaetErkannt();
|
|
}
|
|
|
|
['pointerdown', 'touchstart', 'wheel', 'keydown'].forEach(evt =>
|
|
document.addEventListener(evt, wandAktivitaetErkannt, { passive: true }));
|
|
// 'scroll' bubbelt bei Elementen (anders als bei window/document) nicht —
|
|
// ein Listener in der CAPTURING-Phase an document bekommt ihn trotzdem mit,
|
|
// da die Capturing-Phase unabhängig vom Bubbling jedes Element auf dem Weg
|
|
// zum Ziel durchläuft.
|
|
document.addEventListener('scroll', wandAktivitaetErkannt, { passive: true, capture: true });
|
|
|
|
// Jeder Klick INNERHALB von #wandContent markiert "unterwegs" — unabhängig
|
|
// davon, über welche konkrete Funktion er die Wandtafel verlässt
|
|
// (openProjectPlan() direkt aus renderBoardRow() in der Projektliste,
|
|
// springZumWochenbericht() aus der Besprochen-Übersicht, wandSpringeZuProjekt()
|
|
// aus den übrigen Übersichten/dem Feed). Ein einzelner, allgemeiner Lauscher
|
|
// statt eigener Wrapper um jede dieser Navigationsfunktionen: Mehrere davon
|
|
// werden auch von ganz anderen, nicht-Wandtafel-Stellen der App aufgerufen
|
|
// (board.js, plan-info.js) und dürfen dort keine Rückkehr auslösen — hier
|
|
// entscheidet allein, WO der Klick stattfand, nicht WELCHE Funktion er
|
|
// aufruft. Läuft in der CAPTURING-Phase: Mehrere Klick-Handler in der
|
|
// Projektliste rufen event.stopPropagation() auf (z. B. der Projektname in
|
|
// renderBoardRow()), was einen Lauscher in der normalen Bubbling-Phase an
|
|
// #wandContent gar nicht erst erreichen würde — die Capturing-Phase läuft
|
|
// davon unabhängig schon auf dem Weg zum eigentlichen Ziel. Das bewusste
|
|
// Verlassen über "← Zur Übersicht" (außerhalb von #wandContent, siehe
|
|
// App.html) setzt wandUnterwegs ABSICHTLICH NICHT — eine Vorschau durch
|
|
// Personal soll nicht nach einer Minute unterbrochen werden. Die eigentliche
|
|
// Uhr wird erst in stopWandboard() scharf gestellt, sobald die Navigation
|
|
// tatsächlich stattgefunden hat.
|
|
document.addEventListener('click', function(ev){
|
|
const wandContent = document.getElementById('wandContent');
|
|
if(wandContent && wandContent.contains(ev.target)) wandUnterwegs = true;
|
|
}, { capture: true });
|
|
|
|
// wandRefreshTick: der äußere Hintergrund-Takt. Prüft zuerst GET /whoami statt
|
|
// blind load() aufzurufen: Ist die Sitzung abgelaufen, zeigt er das
|
|
// bestehende Login-Overlay an Ort und Stelle (dasselbe Formular ist seit dem
|
|
// ersten Seitenaufruf schon verdrahtet, siehe checkAuthAndInit(),
|
|
// js/auth.js) — eine erneute Anmeldung setzt die Wandtafel über denselben
|
|
// onAuthenticated()-Weg fort, ohne dass die Seite neu geladen werden muss.
|
|
// Keine Parameter, keine Rückgabe.
|
|
async function wandRefreshTick(){
|
|
let data;
|
|
try {
|
|
const res = await fetch(baseUrl() + '/whoami');
|
|
data = await res.json();
|
|
} catch(err){
|
|
console.error('Wandtafel: Anmeldestatus konnte nicht geprüft werden:', err);
|
|
return; // nächster Takt versucht es erneut
|
|
}
|
|
if(!data || !data.user){
|
|
stopWandboard();
|
|
showLoginOverlay();
|
|
return;
|
|
}
|
|
load(true); // silent — ruft am Ende renderAll()+showTab(currentTab) auf
|
|
}
|
|
|
|
// renderWandboard: baut entweder die Normalansicht (Kopf + Kennzahlen-Reihe +
|
|
// Panel-Reihe, mit der vollständigen Projektliste darunter) oder eine offene
|
|
// Vollbild-Übersicht auf. Wird von showTab('wand') sowie nach jedem load()
|
|
// (über showTab(currentTab)) aufgerufen.
|
|
//
|
|
// Die Normalansicht ist höher als ein Bildschirm — Erik hat einen echten
|
|
// Touch-Monitor und wollte "unterhalb der Wandtafel... weitere Informationen
|
|
// verstauen, z. B. die gesamte Projektliste". Nur dieser Teil scrollt
|
|
// (`.wand-scrollable` auf #wandContent); eine offene Übersicht bleibt wie
|
|
// zuvor genau einen Bildschirm hoch mit ihrer eigenen inneren Liste. Der
|
|
// Scroll-Stand bleibt über den 60-Sekunden-Hintergrund-Takt hinweg erhalten
|
|
// (sonst risse ein laufender Refresh jemanden, der gerade in der Liste
|
|
// scrollt, zurück an den Anfang) — nur wandBeiUntaetigkeitZuruecksetzen()
|
|
// verlangt ausdrücklich resetScroll:true. Parameter: opts.resetScroll
|
|
// (optional). Keine Rückgabe.
|
|
function renderWandboard(opts){
|
|
const resetScroll = !!(opts && opts.resetScroll);
|
|
const container = document.getElementById('wandContent');
|
|
if(!container) return;
|
|
const w = currentWeek();
|
|
if(!w){
|
|
container.innerHTML = '<div class="hint">Keine Sitzung vorhanden.</div>';
|
|
return;
|
|
}
|
|
wandAktuelleWoche = w;
|
|
|
|
const vorherigerScroll = resetScroll ? 0 : container.scrollTop;
|
|
|
|
if(wandUebersicht){
|
|
container.classList.remove('wand-scrollable');
|
|
container.innerHTML = wandUebersichtHtml(w);
|
|
return;
|
|
}
|
|
|
|
container.classList.add('wand-scrollable');
|
|
// Entwurf "Wandtafel in Glas" (Eriks Wahl): Kopf → vier Kennzahlen →
|
|
// links die Gewerke (Auslastung + Besprochen je Zeile), rechts die
|
|
// nächsten fünf Arbeitstage → Hinweis auf die Projektliste darunter.
|
|
container.innerHTML = '<div class="wand-ambient wt">'
|
|
+ wtKopfHtml(w)
|
|
+ wtKpiHtml(w)
|
|
+ '<div class="wt-felder">' + wtGewerkeHtml(w) + wtTageHtml(w) + '</div>'
|
|
+ wtFussHtml(w)
|
|
+ '</div>'
|
|
+ wandProjektlisteHtml(w);
|
|
container.scrollTop = vorherigerScroll;
|
|
wtSpaltenEinpassen();
|
|
}
|
|
|
|
// wandGesamtKennzahlen: zwei über alle Gewerke hinweg zusammengefasste
|
|
// Kennzahlen für die Kennzahlen-Reihe oben — dieselbe Rechnung wie die
|
|
// Gewerk-Aufschlüsselung darunter (wandAuslastungJeGewerk()/
|
|
// wandBesprochenDaten()), nur aufsummiert statt aufgeschlüsselt. Die
|
|
// bewusste Mehrfach-Gewerke-Dopplung (ein Projekt mit mehreren Gewerken
|
|
// zählt seinen vollen Beitrag bei jedem Gewerk) gilt hier automatisch mit,
|
|
// da beide Quellfunktionen sie bereits je Gewerk anwenden. Parameter: w.
|
|
// Rückgabe: {auslastung: Zahl|null, besprochen: Zahl|null} — null, wenn sich
|
|
// die Quote mangels Kapazität bzw. Projekten nicht bilden lässt.
|
|
function wandGesamtKennzahlen(w){
|
|
const ausl = wandAuslastungJeGewerk(w);
|
|
let stunden = 0, kapazitaet = 0;
|
|
WAND_GEWERKE.forEach(g => { stunden += ausl[g].stunden; kapazitaet += ausl[g].kapazitaet; });
|
|
|
|
const bespr = wandBesprochenDaten(w);
|
|
let besprochen = 0, gesamt = 0;
|
|
WAND_GEWERKE.forEach(g => { besprochen += bespr[g].besprochen; gesamt += bespr[g].gesamt; });
|
|
|
|
return {
|
|
auslastung: kapazitaet > 0 ? stunden / kapazitaet : null,
|
|
besprochen: gesamt > 0 ? besprochen / gesamt : null,
|
|
};
|
|
}
|
|
|
|
// wandProjektlisteHtml: die vollständige Projektliste unterhalb der
|
|
// Kennzahl-Kacheln — auf Eriks ausdrücklichen Wunsch ("Die Projektliste soll
|
|
// so aussehen wie die Originale") keine eigene, vereinfachte Darstellung,
|
|
// sondern dieselbe Zeile wie im echten Tab "Projekte": gleiche Gruppen
|
|
// (GROUP_ORDER/sortGroups()), gleiche Sortierung (p.order) und jede Zeile
|
|
// über denselben renderBoardRow()+BOARD_OPTS wie renderBoard() selbst
|
|
// (js/board/board.js) — Nr., Name, Auftragsstatus-Kennzeichen, Status-
|
|
// Vorschau, Avatar-Stapel und Phasen-Badge, aufklappbar zum selben
|
|
// Detailpanel (renderBoardDrawer()). Anders als die Auslastung/Besprochen-
|
|
// Kacheln zählt hier bewusst auch "In Anbahnung" mit, da es um
|
|
// Vollständigkeit geht, nicht um bewertbare Arbeitslast.
|
|
//
|
|
// `locked` wird FEST auf true übergeben, unabhängig vom tatsächlichen
|
|
// Sperrstatus der Sitzung: Die Wandtafel schreibt nie (siehe Dateikopf), und
|
|
// renderBoardRow()/renderBoardDrawer() blenden bei locked=true genau die
|
|
// Bedienelemente aus, die einen Schreibzugriff auslösen würden (Ziehgriff,
|
|
// "Löschen", editierbarer Kommentar) — ein Projekt antippen bleibt möglich
|
|
// (öffnet/schließt die Zeile, "Öffnen" springt ins Projekt), ein Kiosk-Konto
|
|
// bekäme auf jeden Schreibversuch ohnehin ein serverseitiges 403.
|
|
// toggleBoardRow() (board.js) ruft nach jedem Auf-/Zuklappen zusätzlich
|
|
// renderWandboard() auf, damit eine hier aufgeklappte Zeile sofort sichtbar
|
|
// wird, genau wie es das für renderBoard()/renderWochenbericht() schon tut.
|
|
// Parameter: w. Rückgabe: HTML-String.
|
|
function wandProjektlisteHtml(w){
|
|
const locked = true;
|
|
const grouped = peopleByRole(w);
|
|
const allPeople = [];
|
|
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
|
|
|
|
const groups = {};
|
|
GROUP_ORDER.forEach(g => { groups[g] = []; });
|
|
(Array.isArray(w.projects) ? w.projects : []).forEach(p => {
|
|
(groups[p.group] || (groups[p.group] = [])).push(p);
|
|
});
|
|
Object.keys(groups).forEach(g => groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)));
|
|
|
|
const abschnitte = sortGroups(Object.keys(groups)).map(g => {
|
|
if(groups[g].length === 0) return '';
|
|
const zeilen = groups[g].map(p => renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS)).join('');
|
|
return '<div class="section-title">' + esc(g) + '</div><div class="board-list">' + zeilen + '</div>';
|
|
}).join('');
|
|
|
|
return '<div class="wand-projektliste">'
|
|
+ '<h3 class="dash-panel-titel">Alle Projekte</h3>'
|
|
+ abschnitte
|
|
+ '</div>';
|
|
}
|
|
|
|
// wandSpringeZuProjekt: verlässt die Wandtafel und öffnet den Projektbereich
|
|
// eines Projekts, optional direkt in einem bestimmten Abschnitt — derselbe
|
|
// Weg (Hash VOR dem Tab-Wechsel setzen, showTab('plan') übernimmt ihn über
|
|
// updateRouteUrl()) wie die Termin-Links im Dashboard
|
|
// (js/dashboard/dashboard.js), nur als unmittelbarer Bereichswechsel statt
|
|
// eines neuen Tabs — die Wandtafel hat nur die eine Ansicht. Der Hash wird
|
|
// IMMER gesetzt (auch leer->'dashboard'), damit ein von einem früheren Sprung
|
|
// übrig gebliebener Hash nicht fälschlich übernommen wird. (Die Rückkehr zur
|
|
// Wandtafel nach einer Minute Untätigkeit — wandUnterwegs — wird nicht hier,
|
|
// sondern generisch über den Klick-Lauscher auf #wandContent ausgelöst, da
|
|
// die Projektliste über renderBoardRow() auch ganz ohne diese Funktion direkt
|
|
// openProjectPlan() aufruft; siehe dort.) Parameter: pid (Projekt-ID),
|
|
// abschnitt (Bereichs-Schlüssel, z. B. 'aufgaben'). Keine Rückgabe.
|
|
function wandSpringeZuProjekt(pid, abschnitt){
|
|
location.hash = abschnitt || 'dashboard';
|
|
openProjectPlan(pid);
|
|
}
|
|
|
|
// ---------- Vollbild-Übersichten (gemeinsamer Rahmen) ----------
|
|
|
|
// wandUebersichtRahmen: Kopfzeile (Titel + Schließen) plus Inhalt — von
|
|
// jeder Übersicht genutzt. Parameter: titel, inhalt (HTML). Rückgabe:
|
|
// HTML-String.
|
|
function wandUebersichtRahmen(titel, inhalt){
|
|
return '<div class="wand-uebersicht">'
|
|
+ '<div class="wand-uebersicht-kopf"><h2>' + esc(titel) + '</h2>'
|
|
+ '<button type="button" class="secondary small" onclick="wandSchliesseUebersicht()">← Zurück zur Wandtafel</button></div>'
|
|
+ inhalt
|
|
+ '</div>';
|
|
}
|
|
|
|
// wandUebersichtZeile: eine Zeile im gemeinsamen Dreispalten-Format
|
|
// (links/mitte/rechts). onclick ist ein fertiger JS-Ausdruck als String oder
|
|
// null für eine nicht anklickbare Zeile (z. B. eine Abwesenheit, die auf kein
|
|
// Projekt verweist). Parameter: links, mitte, rechts (bereits escapte
|
|
// HTML-Strings), onclick, rechtsKlasse (optional, z. B. 'ist-rot').
|
|
// Rückgabe: HTML-String.
|
|
function wandUebersichtZeile(links, mitte, rechts, onclick, rechtsKlasse){
|
|
return '<div class="wand-uebersicht-zeile' + (onclick ? ' ist-klickbar' : '') + '"'
|
|
+ (onclick ? ' onclick="' + onclick + '"' : '') + '>'
|
|
+ '<span class="wand-uebersicht-projekt">' + links + '</span>'
|
|
+ '<span class="wand-uebersicht-titel">' + mitte + '</span>'
|
|
+ '<span class="wand-uebersicht-seit' + (rechtsKlasse ? ' ' + rechtsKlasse : '') + '">' + rechts + '</span>'
|
|
+ '</div>';
|
|
}
|
|
|
|
// wandZeigeUebersicht: öffnet eine Vollbild-Übersicht anstelle der
|
|
// zweispaltigen Ansicht. Parameter: typ (String), gewerk (optional, nur für
|
|
// 'auslastung'/'besprochen'). Keine Rückgabe.
|
|
function wandZeigeUebersicht(typ, gewerk){
|
|
wandUebersicht = { typ, gewerk: gewerk || null };
|
|
renderWandboard();
|
|
}
|
|
|
|
// wandSchliesseUebersicht: zurück zur zweispaltigen Ansicht. Keine
|
|
// Parameter, keine Rückgabe.
|
|
function wandSchliesseUebersicht(){
|
|
wandUebersicht = null;
|
|
renderWandboard();
|
|
}
|
|
|
|
// wandUebersichtHtml: baut die gerade angeforderte Übersicht. Parameter: w.
|
|
// Rückgabe: HTML-String.
|
|
function wandUebersichtHtml(w){
|
|
const u = wandUebersicht;
|
|
if(u.typ === 'ueberfaellig') return wandUebersichtUeberfaellig(w);
|
|
if(u.typ === 'offen') return wandUebersichtOffeneAufgaben(w);
|
|
if(u.typ === 'abwesend') return wandUebersichtAbwesend(w);
|
|
if(u.typ === 'auslastung') return wandUebersichtAuslastung(w, u.gewerk);
|
|
if(u.typ === 'besprochen') return wandUebersichtBesprochen(w, u.gewerk);
|
|
return '';
|
|
}
|
|
|
|
// ---------- Kachel 1: Auslastung je Gewerk ----------
|
|
|
|
// wandAuslastungJeGewerk: Wochenstunden, Kapazität UND die beitragenden
|
|
// Projekte je Gewerk der aktuellen Sitzung. Rechnet mit denselben Funktionen
|
|
// wie renderLoad() (js/load/load.js), nur nach Gewerk statt nach Person
|
|
// summiert — dieselbe Stelle im Projekt-Durchlauf, an der auch renderLoad()
|
|
// aggregiert. Die Projektliste je Gewerk ist der Rohstoff für die
|
|
// Gewerk-Übersicht (wandUebersichtAuslastung()).
|
|
//
|
|
// Ein Projekt mit MEHREREN Gewerken zählt seinen vollen Beitrag bei JEDEM
|
|
// seiner Gewerke — eine bewusste Dopplung, keine Aufteilung: Es gibt keine
|
|
// Regel, wie die Stunden zwischen den Gewerken aufzuteilen wären, und eine
|
|
// erfundene Aufteilung wäre irreführender als eine offen sichtbare Dopplung.
|
|
// Parameter: w (Wochen-Objekt). Rückgabe: { GEWERK: {stunden, kapazitaet,
|
|
// bewertbar, gesamt, projekte:[{id,name,nr,stunden}]} }.
|
|
function wandAuslastungJeGewerk(w){
|
|
const ergebnis = {};
|
|
WAND_GEWERKE.forEach(g => { ergebnis[g] = { stunden: 0, kapazitaet: 0, bewertbar: 0, gesamt: 0, projekte: [] }; });
|
|
|
|
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
|
(Array.isArray(w.people) ? w.people : []).forEach(p => {
|
|
const global = (db.globalPeople || []).find(x => x.id === p.id)
|
|
|| (p.fullName ? (db.globalPeople || []).find(x => x.fullName === p.fullName) : null);
|
|
const gewerk = global && global.gewerk;
|
|
if(!gewerk || !ergebnis[gewerk]) return;
|
|
ergebnis[gewerk].kapazitaet += kapazitaetsFaktor(p) * vollzeit;
|
|
});
|
|
|
|
(Array.isArray(w.projects) ? w.projects : []).forEach(pr => {
|
|
if(pr.group === 'In Anbahnung') return;
|
|
const gewerke = (Array.isArray(pr.gewerke) ? pr.gewerke : []).filter(g => ergebnis[g]);
|
|
if(gewerke.length === 0) return;
|
|
gewerke.forEach(g => { ergebnis[g].gesamt++; });
|
|
|
|
// Stunden dieser Woche allein aus Aufgaben mit Aufwand (load.js).
|
|
const av = aufgabenVerlaufJePerson(pr);
|
|
if(offeneAufwandsAufgaben(pr).length) gewerke.forEach(g => { ergebnis[g].bewertbar++; });
|
|
const projektStunden = Object.values(av).reduce((s, v) => s + (v[0] || 0), 0);
|
|
gewerke.forEach(g => {
|
|
ergebnis[g].stunden += projektStunden;
|
|
if(projektStunden > 0) ergebnis[g].projekte.push({ id: pr.id, name: pr.name, nr: pr.nr, stunden: projektStunden });
|
|
});
|
|
});
|
|
|
|
return ergebnis;
|
|
}
|
|
|
|
// wandUebersichtAuslastung: alle Projekte, die zu einem Gewerk beitragen,
|
|
// nach Stunden absteigend — Antippen springt ins Projekt (Abschnitt
|
|
// "Leistung", der dort ohnehin schon errechnete Honorar/Fortschritt-Bezug
|
|
// passt inhaltlich am ehesten zur Auslastungs-Frage). Parameter: w, gewerk.
|
|
// Rückgabe: HTML-String.
|
|
function wandUebersichtAuslastung(w, gewerk){
|
|
const daten = wandAuslastungJeGewerk(w)[gewerk];
|
|
const label = (DEPARTMENTS[gewerk] && DEPARTMENTS[gewerk].label) || gewerk;
|
|
if(!daten || daten.projekte.length === 0){
|
|
return wandUebersichtRahmen('Auslastung ' + label, '<div class="wand-leer">Keine Projekte mit Stunden in diesem Gewerk.</div>');
|
|
}
|
|
const sortiert = daten.projekte.slice().sort((a, b) => b.stunden - a.stunden);
|
|
const zeilen = sortiert.map(p =>
|
|
wandUebersichtZeile(esc(p.nr || ''), esc(p.name), stundenFormat(p.stunden) + ' h',
|
|
'wandSpringeZuProjekt(\'' + esc(p.id) + '\',\'leistung\')')
|
|
).join('');
|
|
return wandUebersichtRahmen('Auslastung ' + label, '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
|
}
|
|
|
|
// ---------- Kachel 2: Wochenbericht besprochen ----------
|
|
|
|
// wandBesprochenDaten: "X von Y besprochen" je Gewerk plus die noch NICHT
|
|
// besprochenen Projekte (Rohstoff der Gewerk-Übersicht). "In Anbahnung"
|
|
// zählt nicht mit (dieselbe Ausnahme wie in renderLoad()). Parameter: w.
|
|
// Rückgabe: { GEWERK: {besprochen, gesamt, offeneProjekte:
|
|
// [{id,uid,name,nr}]} }.
|
|
function wandBesprochenDaten(w){
|
|
const ergebnis = {};
|
|
WAND_GEWERKE.forEach(g => {
|
|
const relevante = (w.projects || []).filter(p =>
|
|
Array.isArray(p.gewerke) && p.gewerke.includes(g) && p.group !== 'In Anbahnung');
|
|
// Je Gewerk sein eigener Haken (kommentarVon(), js/utils.js).
|
|
const offeneProjekte = relevante.filter(p => !kommentarVon(p, g).besprochen);
|
|
ergebnis[g] = {
|
|
besprochen: relevante.length - offeneProjekte.length,
|
|
gesamt: relevante.length,
|
|
offeneProjekte: offeneProjekte.map(p => ({ id: p.id, uid: p.projectUid, name: p.name, nr: p.nr })),
|
|
};
|
|
});
|
|
return ergebnis;
|
|
}
|
|
|
|
// wandUebersichtBesprochen: die noch nicht besprochenen Projekte eines
|
|
// Gewerks — Antippen springt NICHT in den Projektbereich, sondern in den
|
|
// Wochenbericht (springZumWochenbericht(), js/plan/plan-info.js): Der
|
|
// Kommentar/besprochen-Haken wird ausschließlich dort gepflegt ("Ein Ort je
|
|
// Angabe", CLAUDE.md), ein Sprung ins Projekt selbst würde nur auf den
|
|
// Verweis-Hinweis dorthin treffen. Parameter: w, gewerk. Rückgabe:
|
|
// HTML-String.
|
|
function wandUebersichtBesprochen(w, gewerk){
|
|
const daten = wandBesprochenDaten(w)[gewerk];
|
|
const label = (DEPARTMENTS[gewerk] && DEPARTMENTS[gewerk].label) || gewerk;
|
|
if(!daten || daten.offeneProjekte.length === 0){
|
|
return wandUebersichtRahmen('Noch offen — ' + label, '<div class="wand-leer">Alle Projekte dieses Gewerks sind besprochen.</div>');
|
|
}
|
|
const zeilen = daten.offeneProjekte.map(p =>
|
|
wandUebersichtZeile(esc(p.nr || ''), esc(p.name), 'noch offen',
|
|
p.uid ? 'springZumWochenbericht(\'' + esc(p.uid) + '\')' : null)
|
|
).join('');
|
|
return wandUebersichtRahmen('Noch offen — ' + label, '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
|
}
|
|
|
|
// ---------- Kachel 3: Heute (Aufgaben + Abwesenheiten) ----------
|
|
|
|
// wandHeuteDaten: offene/überfällige Aufgaben UND die heute abwesenden
|
|
// Personen in einer Kachel — beides beantwortet dieselbe Frage ("was ist
|
|
// heute los"), getrennte Kacheln dafür wären zwei dünne Kacheln nebeneinander
|
|
// gewesen. Unternehmensweit über plan.todos ALLER Projekte, anders als GET
|
|
// /my-dashboard (nur die eigenen Aufgaben); alle Personen, nicht "außer mir"
|
|
// wie bei /my-dashboard — auf der Wandtafel gibt es kein "mich".
|
|
// Parameter: w. Rückgabe: {offen, offeneListe:[{titel,projekt,pid,datum}],
|
|
// ueberfaelligeListe:[{titel,projekt,pid,datum}],
|
|
// abwesend:[{name,typ,bis}]}.
|
|
function wandHeuteDaten(w){
|
|
const heute = heuteIso();
|
|
const offeneListe = [];
|
|
const ueberfaelligeListe = [];
|
|
(w.projects || []).forEach(pr => {
|
|
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
|
|
todos.forEach(t => {
|
|
if(t.done) return;
|
|
const eintrag = {
|
|
titel: t.title || 'Aufgabe',
|
|
projekt: (pr.nr ? pr.nr + ' ' : '') + pr.name,
|
|
datum: t.end || '',
|
|
pid: pr.id,
|
|
};
|
|
offeneListe.push(eintrag);
|
|
if(t.end && t.end < heute) ueberfaelligeListe.push(eintrag);
|
|
});
|
|
});
|
|
// Überfällig: am längsten überfällig zuerst. Offen: nächste Frist zuerst,
|
|
// Aufgaben ohne Termin ans Ende (dieselbe Reihenfolge wie "Meine Aufgaben"
|
|
// im Dashboard).
|
|
ueberfaelligeListe.sort((a, b) => a.datum.localeCompare(b.datum));
|
|
offeneListe.sort((a, b) => (a.datum || '9999') < (b.datum || '9999') ? -1 : (a.datum || '9999') > (b.datum || '9999') ? 1 : 0);
|
|
|
|
const abwesend = (db.globalPeople || []).filter(p =>
|
|
Array.isArray(p.absences) && p.absences.some(a => a.start <= heute && heute <= a.end)
|
|
).map(p => {
|
|
const a = p.absences.find(a => a.start <= heute && heute <= a.end);
|
|
return { name: p.fullName || p.name, typ: (a && a.type) || 'Abwesend', bis: a && a.end };
|
|
});
|
|
|
|
return { offen: offeneListe.length, offeneListe, ueberfaelligeListe, abwesend };
|
|
}
|
|
|
|
// wandTileHeute ist entfallen — "offene Aufgaben"/"überfällig" stehen jetzt
|
|
// in der Kennzahlen-Reihe (wandKpiReiheHtml()), das Abwesenheits-Kürzel im
|
|
// Kopf (wandDashKopfHtml()); beide lesen weiterhin aus wandHeuteDaten().
|
|
|
|
// wandUebersichtOffeneAufgaben: ALLE offenen Aufgaben (nicht nur
|
|
// überfällige), nächste Frist zuerst. Parameter: w. Rückgabe: HTML-String.
|
|
function wandUebersichtOffeneAufgaben(w){
|
|
const d = wandHeuteDaten(w);
|
|
if(d.offeneListe.length === 0){
|
|
return wandUebersichtRahmen('Offene Aufgaben', '<div class="wand-leer">Keine offenen Aufgaben.</div>');
|
|
}
|
|
const zeilen = d.offeneListe.map(t =>
|
|
wandUebersichtZeile(esc(t.projekt), '„' + esc(t.titel) + '"', t.datum ? esc(relativerTag(t.datum)) : 'ohne Termin',
|
|
'wandSpringeZuProjekt(\'' + esc(t.pid) + '\',\'aufgaben\')')
|
|
).join('');
|
|
return wandUebersichtRahmen('Offene Aufgaben (' + d.offeneListe.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
|
}
|
|
|
|
// wandUebersichtUeberfaellig: alle überfälligen Aufgaben, am längsten
|
|
// überfällig zuerst — jede Zeile springt ins Projekt, Abschnitt "Aufgaben".
|
|
// Parameter: w. Rückgabe: HTML-String.
|
|
function wandUebersichtUeberfaellig(w){
|
|
const d = wandHeuteDaten(w);
|
|
if(d.ueberfaelligeListe.length === 0){
|
|
return wandUebersichtRahmen('Überfällige Aufgaben', '<div class="wand-leer">Keine überfälligen Aufgaben.</div>');
|
|
}
|
|
const zeilen = d.ueberfaelligeListe.map(t =>
|
|
wandUebersichtZeile(esc(t.projekt), '„' + esc(t.titel) + '"', esc(relativerTag(t.datum)),
|
|
'wandSpringeZuProjekt(\'' + esc(t.pid) + '\',\'aufgaben\')', 'ist-rot')
|
|
).join('');
|
|
return wandUebersichtRahmen('Überfällige Aufgaben (' + d.ueberfaelligeListe.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
|
}
|
|
|
|
// wandUebersichtAbwesend: alle heute abwesenden Personen mit Art und bis
|
|
// wann — nicht anklickbar (eine Abwesenheit gehört zu keinem Projekt, in das
|
|
// man springen könnte). Parameter: w. Rückgabe: HTML-String.
|
|
function wandUebersichtAbwesend(w){
|
|
const d = wandHeuteDaten(w);
|
|
if(d.abwesend.length === 0){
|
|
return wandUebersichtRahmen('Abwesend heute', '<div class="wand-leer">Niemand abwesend.</div>');
|
|
}
|
|
const zeilen = d.abwesend.map(p =>
|
|
wandUebersichtZeile(esc(p.name), esc(p.typ), p.bis ? 'bis ' + esc(formatWeekDate(p.bis)) : '', null)
|
|
).join('');
|
|
return wandUebersichtRahmen('Abwesend heute (' + d.abwesend.length + ')', '<div class="wand-uebersicht-liste">' + zeilen + '</div>');
|
|
}
|
|
|
|
// ---------- Ereignis-Feed (Panel "Aktuell") ----------
|
|
|
|
// ===========================================================================
|
|
// Normalansicht "Wandtafel in Glas" (Eriks Wahl aus dem Entwurf): gleicher
|
|
// Hintergrund wie die App (Lichtflächen + Logo, styles.css, Block
|
|
// "WANDTAFEL IN GLAS"), Schrift Outfit wie die Kalenderwoche der Login-Seite.
|
|
// Jedes Element ist für sich antippbar und führt in dieselben Übersichten
|
|
// bzw. Projekte wie zuvor.
|
|
// ===========================================================================
|
|
|
|
// wtKopfHtml: Logo + "Dashboard", Sitzung, heute Abwesende (antippbar →
|
|
// Übersicht "abwesend"), große Uhr mit Datum (#wtUhr, sekündlich von
|
|
// wandCountdownTick() nachgeführt). Parameter: w. Rückgabe: HTML.
|
|
function wtKopfHtml(w){
|
|
const d = wandHeuteDaten(w);
|
|
const jetzt = new Date();
|
|
const abw = d.abwesend.length
|
|
? '<div class="wt-abw" onclick="wandZeigeUebersicht(\'abwesend\')" title="Heute abwesend"><span>Heute abwesend</span>'
|
|
+ d.abwesend.slice(0, 6).map(p => '<span class="wt-ava">' + esc(initialsFor(p.name)) + '</span>').join('')
|
|
+ (d.abwesend.length > 6 ? '<span class="wt-ava">+' + (d.abwesend.length - 6) + '</span>' : '') + '</div>'
|
|
: '<div class="wt-abw ist-leer">Heute alle da</div>';
|
|
return '<header class="wt-feld wt-kopf">'
|
|
+ '<div class="wt-marke"><img src="/logo.svg" alt=""><b>Dashboard</b></div>'
|
|
+ '<span class="wt-sitzung">' + esc(w.label) + '</span>'
|
|
+ abw
|
|
+ '<div class="wt-uhr"><span id="wtUhr">' + esc(jetzt.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })) + '</span>'
|
|
+ '<small>' + esc(jetzt.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })) + '</small></div>'
|
|
+ '</header>';
|
|
}
|
|
|
|
// wtAmpel: Farbe zur Auslastungsquote — dieselben Schwellen wie überall
|
|
// (schwelleGelb()/schwelleRot(), js/load/load.js).
|
|
function wtAmpel(quote){
|
|
// Eine Null bekommt KEINE Ampelfarbe (dieselbe Regel wie im Auslastungs-
|
|
// Tab): Grün hieße "gut ausgelastet", die Null heißt "nichts erfasst".
|
|
if(quote === null || quote === undefined || quote <= 0) return 'var(--wt-tinte-3)';
|
|
return quote >= schwelleRot() ? 'var(--wt-rot)' : quote >= schwelleGelb() ? 'var(--wt-gelb)' : 'var(--wt-gruen)';
|
|
}
|
|
|
|
// wtKpiHtml: Auslastung gesamt und Besprochen mit Ring, offene Aufgaben
|
|
// und Überfälliges (beide antippbar wie bisher). Parameter: w. Rückgabe: HTML.
|
|
function wtKpiHtml(w){
|
|
const g = wandGesamtKennzahlen(w);
|
|
const heute = wandHeuteDaten(w);
|
|
const bespr = wandBesprochenDaten(w);
|
|
let ja = 0, n = 0;
|
|
WAND_GEWERKE.forEach(x => { ja += bespr[x].besprochen; n += bespr[x].gesamt; });
|
|
const ring = (pct, farbe) => '<span class="wt-ring" style="--p:' + Math.max(0, Math.min(100, pct)) + ';--c:' + farbe + '"></span>';
|
|
const ausl = g.auslastung === null ? '—' : Math.round(g.auslastung * 100) + '<small>%</small>';
|
|
const ueber = heute.ueberfaelligeListe.length;
|
|
return '<section class="wt-kpis">'
|
|
+ '<div class="wt-feld wt-kpi">' + ring(g.auslastung === null ? 0 : g.auslastung * 100, wtAmpel(g.auslastung))
|
|
+ '<div><div class="wt-wert">' + ausl + '</div><div class="wt-lab"><b>Auslastung</b>alle Gewerke</div></div></div>'
|
|
+ '<div class="wt-feld wt-kpi">' + ring(n ? ja / n * 100 : 0, 'var(--wt-gruen)')
|
|
+ '<div><div class="wt-wert">' + ja + '<small>/' + n + '</small></div><div class="wt-lab"><b>Besprochen</b>diese Woche</div></div></div>'
|
|
+ '<div class="wt-feld wt-kpi ist-klickbar" onclick="wandZeigeUebersicht(\'offen\')">'
|
|
+ '<div><div class="wt-wert">' + heute.offen + '</div><div class="wt-lab"><b>Offene Aufgaben</b>in allen Projekten</div></div></div>'
|
|
+ '<div class="wt-feld wt-kpi ist-klickbar' + (ueber ? ' ist-rot' : '') + '" onclick="wandZeigeUebersicht(\'ueberfaellig\')">'
|
|
+ '<div><div class="wt-wert">' + ueber + '</div><div class="wt-lab"><b>Überfällig</b>' + (ueber ? 'Frist gerissen' : 'nichts gerissen') + '</div></div></div>'
|
|
+ '</section>';
|
|
}
|
|
|
|
// wtGewerkeHtml: je Gewerk eine Zeile mit Auslastung (Balken + Prozent,
|
|
// antippbar → Übersicht "auslastung") und Besprochen-Stand (ein Kästchen je
|
|
// Projekt, antippbar → Übersicht "besprochen"). Parameter: w. Rückgabe: HTML.
|
|
function wtGewerkeHtml(w){
|
|
const ausl = wandAuslastungJeGewerk(w);
|
|
const bespr = wandBesprochenDaten(w);
|
|
const personen = {};
|
|
(Array.isArray(w.people) ? w.people : []).forEach(p => {
|
|
const g = typeof personGewerk === 'function' ? personGewerk(p) : '';
|
|
if(g) personen[g] = (personen[g] || 0) + 1;
|
|
});
|
|
const zeilen = WAND_GEWERKE.map(g => {
|
|
const a = ausl[g], b = bespr[g];
|
|
const quote = (a.bewertbar > 0 && a.kapazitaet > 0) ? a.stunden / a.kapazitaet : null;
|
|
const farbe = wtAmpel(quote);
|
|
const auslWert = quote === null ? '—' : Math.round(quote * 100) + ' %';
|
|
const kaestchen = b.gesamt
|
|
? Array.from({ length: b.gesamt }, (_, i) => '<i class="' + (i < b.besprochen ? 'ja' : '') + '"></i>').join('')
|
|
: '<span class="wt-leer">keine Projekte</span>';
|
|
return '<div class="wt-gw">'
|
|
+ '<span class="wt-gew">' + esc(g) + '<small>' + (personen[g] || 0) + ' Personen</small></span>'
|
|
+ '<div class="wt-gw-werte">'
|
|
+ '<div class="wt-gw-zeile ist-klickbar" onclick="wandZeigeUebersicht(\'auslastung\',\'' + g + '\')">'
|
|
+ '<span class="wt-gw-lab">Auslastung</span>'
|
|
+ '<span class="wt-balken"><i style="width:' + (quote === null ? 0 : Math.min(100, quote * 100)) + '%;background:' + farbe + '"></i></span>'
|
|
+ '<span class="wt-zahl" style="color:' + farbe + '">' + auslWert + '</span></div>'
|
|
+ '<div class="wt-gw-zeile ist-klickbar" onclick="wandZeigeUebersicht(\'besprochen\',\'' + g + '\')">'
|
|
+ '<span class="wt-gw-lab">Besprochen</span>'
|
|
+ '<span class="wt-punkte">' + kaestchen + '</span>'
|
|
+ '<span class="wt-zahl klein">' + (b.gesamt ? b.besprochen + '/' + b.gesamt : '—') + '</span></div>'
|
|
+ '</div></div>';
|
|
}).join('');
|
|
return '<div class="wt-feld wt-panel wt-gewerke"><h2>Gewerke <span>Auslastung und Wochenbericht</span></h2>' + zeilen + '</div>';
|
|
}
|
|
|
|
// WT_TAGE / WT_JE_TAG: wie viele Arbeitstage die Tagesansicht zeigt und wie
|
|
// viele Einträge je Tag offen dastehen (der Rest als "+ n weitere").
|
|
const WT_TAGE = 5;
|
|
const WT_JE_TAG = 8;
|
|
|
|
// wtTageDaten: die nächsten WT_TAGE Arbeitstage ab heute (Wochenenden und
|
|
// gepflegte Feiertage übersprungen, isWorkday()) mit den Meilensteinen aller
|
|
// Projekte der Sitzung.
|
|
// Parameter: w. Rückgabe: Array von { iso, datum: Date, heute, eintraege }.
|
|
function wtTageDaten(w){
|
|
const heute = heuteIso();
|
|
const tage = [];
|
|
let d = parseDateISO(heute);
|
|
if(!isWorkday(d)) { while(!isWorkday(d)) d = addDays(d, 1); }
|
|
while(tage.length < WT_TAGE){
|
|
if(isWorkday(d)) tage.push({ iso: formatDateISO(d), datum: new Date(d), heute: formatDateISO(d) === heute, eintraege: [] });
|
|
d = addDays(d, 1);
|
|
}
|
|
const jeTag = {}; tage.forEach(t => { jeTag[t.iso] = t; });
|
|
const erster = tage[0];
|
|
// Termine an Wochenenden und Feiertagen zwischen den Spalten gehören zum
|
|
// Arbeitstag DAVOR (ein Termin am Samstag ist praktisch einer am Freitag).
|
|
// Ohne diese Zuordnung fiel ein solcher Eintrag aus der Tafel heraus, weil
|
|
// es für sein Datum keine Spalte gibt. Der Eintrag nennt dann das
|
|
// eigentliche Datum.
|
|
const spalteFuer = {};
|
|
const WT = ['So','Mo','Di','Mi','Do','Fr','Sa'];
|
|
for(let x = parseDateISO(erster.iso), ende = parseDateISO(tage[tage.length - 1].iso); x <= ende; x = addDays(x, 1)){
|
|
const iso = formatDateISO(x);
|
|
spalteFuer[iso] = jeTag[iso] || tage.filter(t => t.iso < iso).pop() || erster;
|
|
}
|
|
const hinweisFuer = iso => jeTag[iso] ? '' : 'am ' + WT[parseDateISO(iso).getDay()] + ' ' + parseDateISO(iso).getDate() + '.' + (parseDateISO(iso).getMonth() + 1) + '. · ';
|
|
(Array.isArray(w.projects) ? w.projects : []).forEach(pr => {
|
|
const projekt = (pr.nr ? pr.nr + ' ' : '') + (pr.name || '');
|
|
const plan = pr.plan || {};
|
|
// Eriks Wunsch: In den Tagesspalten stehen NUR Meilensteine. Aufgaben
|
|
// (offen/überfällig) sind über die Kennzahlen oben erreichbar, die heute
|
|
// Abwesenden über die Kopfleiste.
|
|
(plan.milestones || []).forEach(m => {
|
|
if(m.date && m.date >= heute && spalteFuer[m.date]) spalteFuer[m.date].eintraege.push({ art: 'ms', titel: m.name || 'Meilenstein', projekt: hinweisFuer(m.date) + projekt, pid: pr.id, abschnitt: 'terminplan', sort: m.date });
|
|
});
|
|
});
|
|
tage.forEach(t => t.eintraege.sort((a, b) => String(a.sort).localeCompare(String(b.sort))));
|
|
return tage;
|
|
}
|
|
|
|
// wtTageHtml: die Spalten der nächsten Tage, nur mit Meilensteinen (Eriks
|
|
// Wunsch). Ein Eintrag springt in den Terminplan seines Projekts.
|
|
// Parameter: w. Rückgabe: HTML.
|
|
function wtTageHtml(w){
|
|
const tage = wtTageDaten(w);
|
|
const WT = ['So','Mo','Di','Mi','Do','Fr','Sa'];
|
|
const gruppe = {};
|
|
const spalten = tage.map(t => {
|
|
const sichtbar = t.eintraege.slice(0, WT_JE_TAG);
|
|
const rest = t.eintraege.length - sichtbar.length;
|
|
let letzteGruppe = null;
|
|
const eintraege = sichtbar.map(e => {
|
|
let kopf = '';
|
|
if(t.heute && gruppe[e.art] && e.art !== letzteGruppe){ kopf = '<div class="wt-gruppe wt-' + e.art + '">' + gruppe[e.art] + '</div>'; letzteGruppe = e.art; }
|
|
const klick = e.pid ? ' onclick="wandSpringeZuProjekt(\'' + esc(e.pid) + '\',\'' + e.abschnitt + '\')"' : '';
|
|
return kopf + '<div class="wt-ein wt-' + e.art + (e.pid ? ' ist-klickbar' : '') + '"' + klick + '><i></i><div><b>' + esc(e.titel) + '</b><span>' + esc(e.projekt) + '</span></div></div>';
|
|
}).join('');
|
|
// "+ n weitere" steht fest am unteren Rand; wtSpaltenEinpassen() zählt
|
|
// nach dem Zeichnen noch die Einträge hinzu, die nicht in die Höhe passen.
|
|
const mehr = '<div class="wt-mehr" data-rest="' + rest + '"' + (t.heute ? ' onclick="wandZeigeUebersicht(\'ueberfaellig\')"' : '') + (rest > 0 ? '' : ' hidden') + '>+ ' + rest + ' weitere</div>';
|
|
const titel = t.heute ? 'Heute' : WT[t.datum.getDay()];
|
|
const datum = (t.heute ? WT[t.datum.getDay()] + ' ' : '') + t.datum.getDate() + '.' + (t.datum.getMonth() + 1) + '.';
|
|
return '<div class="wt-tag' + (t.heute ? ' ist-heute' : '') + '"><div class="wt-tag-kopf"><b>' + titel + '</b><span>' + datum + '</span></div>'
|
|
+ '<div class="wt-tag-liste">' + (eintraege || '<div class="wt-leer">Kein Meilenstein.</div>') + '</div>' + mehr + '</div>';
|
|
}).join('');
|
|
return '<div class="wt-feld wt-panel wt-tage"><h2>Die nächsten Tage <span>Meilensteine aller Projekte</span></h2>'
|
|
+ '<div class="wt-tage-raster">' + spalten + '</div>'
|
|
+ '</div>';
|
|
}
|
|
|
|
// wtSpaltenEinpassen: blendet in jeder Tagesspalte die Einträge aus, die
|
|
// nicht mehr in die Höhe passen (die Spaltenhöhe hängt vom Bildschirm ab,
|
|
// zweizeilige Titel brauchen mehr Platz), und zählt sie zu "+ n weitere".
|
|
// Läuft nach jedem Zeichnen der Normalansicht. Keine Parameter.
|
|
function wtSpaltenEinpassen(){
|
|
document.querySelectorAll('#wandContent .wt-tag').forEach(tag => {
|
|
const liste = tag.querySelector('.wt-tag-liste');
|
|
const mehr = tag.querySelector('.wt-mehr');
|
|
if(!liste || !mehr) return;
|
|
let versteckt = 0;
|
|
const eintraege = [...liste.querySelectorAll('.wt-ein')];
|
|
while(liste.scrollHeight > liste.clientHeight + 1 && eintraege.length){
|
|
const e = eintraege.pop();
|
|
e.hidden = true; versteckt++;
|
|
// Eine Gruppenüberschrift ohne sichtbaren Eintrag danach mit ausblenden.
|
|
const vor = e.previousElementSibling;
|
|
if(vor && vor.classList.contains('wt-gruppe') && (!vor.nextElementSibling || vor.nextElementSibling.hidden)) vor.hidden = true;
|
|
}
|
|
const rest = (Number(mehr.dataset.rest) || 0) + versteckt;
|
|
mehr.textContent = '+ ' + rest + ' weitere';
|
|
mehr.hidden = rest === 0;
|
|
});
|
|
}
|
|
|
|
// wtFussHtml: Hinweis auf die Projektliste darunter (zum Wischen).
|
|
function wtFussHtml(w){
|
|
const n = (w.projects || []).length;
|
|
return '<div class="wt-feld wt-fuss"><span class="wt-pfeil">↓</span><span>Wischen für alle ' + n + ' Projekte</span></div>';
|
|
}
|
|
|