// -----------------------------
// 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: gruppiert Personen nach Rolle zurück in ein Objekt.
// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle
// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung.
function peopleByRole(target){
const m = {}; ROLES.forEach(r=>m[r]=[]);
const people = [];
if(Array.isArray(target)){
target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); });
} else if(target && Array.isArray(target.people)){
people.push(...target.people);
} else if(Array.isArray(currentWeek()?.people)){
people.push(...currentWeek().people);
}
const seen = new Set();
people.forEach(p=>{
const personRoles = Array.isArray(p.role) ? p.role : [p.role];
personRoles.forEach(role=>{
if(!m[role]) m[role]=[];
const key = `${p.id}|${role}`;
if(!seen.has(key)){
seen.add(key);
m[role].push({person:p, role});
}
});
});
return m;
}
// 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 = `
`;
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();
}