// ----------------------------- // Projektübersicht — Board (Haupttabelle) // ----------------------------- // Diese Datei rendert das Projektboard (Tab "Projektboard" in `Test.html`), // die zentrale Tabelle mit Projekten, Status-Kommentaren, Personen-Spalten // (Anwesenheit/Zuordnung) und Leistungsphasen in Prozent. Sie wird von // `Test.html` nach `js/weeks.js` (vor `js/people/people.js`) geladen und stellt // globale Funktionen wie `renderBoard()`, `statusFocus()`, `cycleAssign()`, // `editLph()` und `delProj()` bereit. Sie nutzt `currentWeek()`/ // `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`, // `assignKey()` aus `js/utils.js` und `openProjectPlan()` aus `js/gantt.js`. // currentStatusEditor: aktuell fokussiertes contenteditable-Element des // Status-Editors; wird für die Formatierungs-Toolbar (`formatStatus`) benötigt. let currentStatusEditor = null; // currentStatusPid: Projekt-ID der gerade fokussierten Status-Zelle; wird beim // Speichern des Status (`statusBlur`) verwendet. let currentStatusPid = null; // statusFocus: merkt sich das fokussierte Status-Element und die zugehörige // Projekt-ID (`el`, `pid`), damit die Toolbar und das Blur-Speichern sie nutzen. function statusFocus(el, pid){ currentStatusEditor = el; currentStatusPid = pid; } // statusBlur: übernimmt den Inhalt des Status-Editors (`el`) als neuen Status // des Projekts `pid` und räumt die Editor-Merker wieder ab, wenn `el` das // zuletzt fokussierte Element war. function statusBlur(el, pid){ const val = statusValueFromHtml(el.innerHTML); editProj(pid, 'status', val); if(currentStatusEditor === el){ currentStatusEditor = null; currentStatusPid = null; } } // statusValueFromHtml: normalisiert den HTML-Inhalt eines Status-Editors. // Liefert bei reinem Leerraum '' zurück, sonst den unveränderten HTML-String // (Formatierung bleibt erhalten). Parameter: `html`, Rückgabe: String. function statusValueFromHtml(html){ const tmp = document.createElement('div'); tmp.innerHTML = html; const text = (tmp.textContent || tmp.innerText || '').replace(/\u00A0/g, ' '); return text.trim() === '' ? '' : html; } // formatStatus: wendet einen document.execCommand-Befehl (z.B. 'bold', // 'foreColor') auf das fokussierte Status-Element an. Parameter: `command`, // optionaler `value`; keine Rückgabe. function formatStatus(command, value){ if(!currentStatusEditor) return; currentStatusEditor.focus(); document.execCommand(command, false, value || null); } // peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch // die Projektplan-Seite, die board.js nicht lädt). // roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array // (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role` // (String oder Array). Rückgabe: Klassen-String, leer bei undefined. function roleClass(role){ if(Array.isArray(role)){ return role.map(r=>'role-'+r.toLowerCase().replace(/\s+/g,'-')).join(' '); } return 'role-' + role.toLowerCase().replace(/\s+/g,'-'); } // renderBoard: baut die HTML-Tabelle für das Board auf (Kopf, Zeilen, Zellen). // Liest die aktuelle Woche, gruppiert Projekte nach Gruppe, sortiert sie nach // `order` und erzeugt die Status-, Personen- und LPH-Zellen. Bei gesperrten // Sitzungen werden alle Bearbeitungs-Handler deaktiviert und ein Hinweis samt // Entsperren-Button angezeigt. Aktualisiert außerdem die Legende mit den // vollständigen Namen und Anwesenheiten. Keine Parameter, keine Rückgabe. function renderBoard(){ const w = currentWeek(); const locked = isWeekLocked(w); const table = document.getElementById('boardTable'); const grouped = peopleByRole(w); // Kopfzeilen: Rollen und Leistungsphasen let h1 = `Pr.-Nr.BezeichnungStatus / Kommentar`; ROLES.forEach(r=>{ h1 += `${r}`; }); h1 += `Leistungsphasen (%)`; let h2 = ``; ROLES.forEach(r=>{ if(grouped[r].length===0) h2 += `–`; grouped[r].forEach(entry=> h2 += `${esc(entry.person.name)}`); }); LPHS.forEach(l=> h2 += `${l}`); h2 += ``; // Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen const groups = {}; w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); }); // Projekte nach ihrer Reihenfolge sortieren Object.keys(groups).forEach(g=>{ groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)); }); let body = ""; const allPeople = []; ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry))); // Jede Gruppe als Abschnitt, dann Zeilen für Projekte sortGroups(Object.keys(groups)).forEach(g=>{ const colspan = 3 + allPeople.length + LPHS.length + 1; // Spaltenanzahl für Gruppentitel body += `${esc(g)}`; groups[g].forEach(p=>{ body += ``; body += `${esc(p.nr)}`; body += `${esc(p.name)}`; // Status-Editor: contenteditable mit kleiner Toolbar (Fett, Farben, Absätze mit Enter) const statusClass = p.statusState === 'new' ? 'status-new' : p.statusState === 'duplicated' ? 'status-duplicated' : ''; const statusEditable = locked ? 'false' : 'true'; const statusToolbar = locked ? '' : `
`; const statusClick = locked ? '' : `onfocus="statusFocus(this,'${p.id}')" onblur="statusBlur(this,'${p.id}')"`; const safeStatus = sanitizeStatusHtml(p.status || ''); body += ` ${statusToolbar}
${safeStatus}
`; // Personen-Spalten: Klick wechselt Zustand (nicht, optional, Hauptverantwortung) allPeople.forEach(entry=>{ const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; // 0=keine,1=Haupt,2=optional const cls = state===1?'on':state===2?'optional':''; const txt = state===1?'X':state===2?'(X)':''; const click = locked ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`; body += `${txt}`; }); // LPH-Spalten mit Prozentwerten und Fortschrittsbalken; // die amtierende Leistungsphase (Tab "Projekte") wird farblich markiert. const aktLp = lpActiveList(p)[0]; LPHS.forEach(l=>{ const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : ""; const aktiv = l === aktLp; const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`; body += ``; if(v!=""){ const pct = Math.min(100, parseFloat(v)||0); body += `
`; } body += ``; }); body += ``; }); }); let lockNotice = ''; if(locked){ lockNotice = `
Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich.
`; } table.innerHTML = `${h1}${h2}${body}`; // Legende mit vollständigen Namen und Anwesenheitstracking let legendeHtml = lockNotice + `
Legende Projektkürzel${locked ? '' : ' (klick für Anwesenheit)'}:
`; const weekAttendance = w?.attendance || {}; const legendeItems = []; allPeople.forEach(entry=>{ if(!legendeItems.some(item=>item.id===entry.person.id)){ legendeItems.push({id:entry.person.id, name:entry.person.name, fullName:entry.person.fullName||entry.person.name}); } }); legendeHtml += `
` + legendeItems.map(item=>{ const isPresent = weekAttendance[item.id]; const cursor = locked ? 'not-allowed' : 'pointer'; const click = locked ? '' : `onclick="toggleAttendance('${item.id}')"`; return `
${esc(item.name)} = ${esc(item.fullName)}
`; }).join('') + `
`; document.getElementById('boardLegend').innerHTML = legendeHtml; adjustStickyOffsets(); } // sanitizeStatusHtml: bereinigt übergebenen Status-HTML. Erlaubt nur eine kleine // Whitelist an HTML-Tags/Attributen (Formatierung), damit keine Skripte // eingeschleust werden können; im style-Attribut sind nur color/font-weight // erlaubt. Parameter: `html` (String). Rückgabe: bereinigter HTML-String ('' bei // leerem Eingabewert). function sanitizeStatusHtml(html){ if(!html) return ''; const div = document.createElement('div'); div.innerHTML = html; const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']); const allowedAttrs = new Set(['style']); const walk = (node) => { for(let i = node.childNodes.length - 1; i >= 0; i--){ const child = node.childNodes[i]; if(child.nodeType === 3) continue; if(child.nodeType === 1){ if(!allowedTags.has(child.tagName.toUpperCase())){ while(child.firstChild) node.insertBefore(child.firstChild, node); node.removeChild(child); continue; } for(let j = child.attributes.length - 1; j >= 0; j--){ const attr = child.attributes[j].name; if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr); } if(child.hasAttribute('style')){ // Erlaubt nur color und font-weight im style-Attribut const style = child.getAttribute('style'); const safe = (style || '').split(';').map(s => s.trim()).filter(s => { const prop = s.split(':')[0].trim().toLowerCase(); return prop === 'color' || prop === 'font-weight'; }).join('; '); if(safe) child.setAttribute('style', safe); else child.removeAttribute('style'); } walk(child); } } }; walk(div); return div.innerHTML; } // getProgressColor: erzeugt eine HSL-Farbe basierend auf Prozentwert // (grün → gelb → rot). Parameter `pct`: Zahl 0–100 (wird geklemmt). // Rückgabe: Farb-String für den Fortschrittsbalken. function getProgressColor(pct){ const hue = Math.round(Math.max(0, Math.min(100, pct)) * 1.2); return `hsl(${hue},70%,40%)`; } // adjustStickyOffsets: berechnet die reale Höhe der ersten Kopfzeile // der Board-Tabelle und setzt den top-Wert der zweiten sticky-Kopfzeile // entsprechend, damit beide Zeilen exakt aneinander anschließen // (keine Lücke/Überlappung beim Scrollen). Keine Parameter, keine Rückgabe. function adjustStickyOffsets(){ const firstRow = document.querySelector('#boardTable thead tr:nth-child(1)'); if(!firstRow) return; const h = firstRow.getBoundingClientRect().height; document.querySelectorAll('#boardTable thead tr:nth-child(2) th').forEach(th=>{ th.style.top = h + 'px'; }); } // toggleAttendance: togglet den Anwesenheitsstatus (boolean in `w.attendance`) // eines Projektbeteiligten für die aktuelle Sitzung und rendert das Board neu. // Parameter `personId`: stabile Personen-ID. Keine Rückgabe; bei gesperrter // Woche über `guardWeekLocked()` abgebrochen. function toggleAttendance(personId){ if(guardWeekLocked()) return; const w = currentWeek(); if(!w) return; if(!w.attendance) w.attendance = {}; w.attendance[personId] = !w.attendance[personId]; save(); renderBoard(); } // cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt zyklisch // durch die Zustände 0=keine → 1=Haupt → 2=optional → 0. Parameter `pid` // (Projekt-ID), `personId`, `role`; bei Zustand 0 wird der Eintrag gelöscht. // Keine Rückgabe. function cycleAssign(pid, personId, role){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); const key = assignKey(personId, role); const cur = p.assign[key]||0; const next = (cur+1)%3; if(next===0) delete p.assign[key]; else p.assign[key]=next; save(); renderBoard(); } // editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein // Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel), // `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe. function editLph(pid,lph,val){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); val = val.trim(); if(val==="") delete p.lph[lph]; else p.lph[lph]=val.replace('%',''); save(); renderBoard(); } // editProj: aktualisiert ein Feld eines Projekts; für Status wird zusätzlich // ein flag `statusState` gesetzt (='new' beim Anlegen eines neuen Kommentars), // wenn zuvor noch kein Status existierte. Parameter: `pid` (Projekt-ID), // `field` (Feldname), `val` (neuer Wert). Keine Rückgabe. function editProj(pid,field,val){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); if(!p) return; if(field==='status'){ const before = (p.status||"").trim(); const after = val.trim(); if(before === "" && after !== ""){ p.statusState = 'new'; } else if(after === ""){ delete p.statusState; } } p[field]=val; save(); } // delProj: löscht ein Projekt (per Bestätigungsdialog) aus der aktuellen // Sitzung. Parameter `pid`: Projekt-ID. Keine Rückgabe; bei gesperrter Woche // abgebrochen. function delProj(pid){ if(guardWeekLocked()) return; if(!confirm("Projekt aus dieser Sitzung löschen?")) return; const w = currentWeek(); w.projects = w.projects.filter(x=>x.id!==pid); save(); renderBoard(); }