von single Page hin zur mehreren Seiten geändert
This commit is contained in:
1 parent
8c94fc91f6
commit
31c4bd47cd
36 files changed
+207
-97
No files matched your search
@@ -0,0 +1,347 @@
|
||||
// -----------------------------
|
||||
// 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 = `<tr><th rowspan="2">Pr.-Nr.</th><th rowspan="2">Bezeichnung</th><th rowspan="2">Status / Kommentar</th>`;
|
||||
ROLES.forEach(r=>{ h1 += `<th class="${roleClass(r)}" colspan="${Math.max(1,grouped[r].length)}">${r}</th>`; });
|
||||
h1 += `<th colspan="${LPHS.length}">Leistungsphasen (%)</th><th rowspan="2"></th></tr>`;
|
||||
|
||||
let h2 = `<tr>`;
|
||||
ROLES.forEach(r=>{
|
||||
if(grouped[r].length===0) h2 += `<th class="rot ${roleClass(r)}">–</th>`;
|
||||
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${entry.person.name}</th>`);
|
||||
});
|
||||
LPHS.forEach(l=> h2 += `<th class="rot">${l}</th>`);
|
||||
h2 += `</tr>`;
|
||||
|
||||
// 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 += `<tr class="group-header"><td colspan="${colspan}">${esc(g)}</td></tr>`;
|
||||
groups[g].forEach(p=>{
|
||||
body += `<tr>`;
|
||||
body += `<td class="left">${esc(p.nr)}</td>`;
|
||||
body += `<td class="left proj-name" style="cursor:pointer" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</td>`;
|
||||
// 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 ? '' : `
|
||||
<div class="status-toolbar" tabindex="-1">
|
||||
<button type="button" title="Fett" onmousedown="event.preventDefault();formatStatus('bold')"><b>B</b></button>
|
||||
<button type="button" title="Rot" class="color-red" onmousedown="event.preventDefault();formatStatus('foreColor','#c53030')">A</button>
|
||||
<button type="button" title="Grün" class="color-green" onmousedown="event.preventDefault();formatStatus('foreColor','#2f855a')">A</button>
|
||||
<button type="button" title="Blau" class="color-blue" onmousedown="event.preventDefault();formatStatus('foreColor','#2b6cb0')">A</button>
|
||||
<button type="button" title="Schwarz" class="color-black" onmousedown="event.preventDefault();formatStatus('foreColor','#1a1a1a')">A</button>
|
||||
<button type="button" title="Absatz" onmousedown="event.preventDefault();formatStatus('formatBlock','P')">¶</button>
|
||||
</div>`;
|
||||
const statusClick = locked ? '' : `onfocus="statusFocus(this,'${p.id}')" onblur="statusBlur(this,'${p.id}')"`;
|
||||
const safeStatus = sanitizeStatusHtml(p.status || '');
|
||||
body += `<td class="status ${locked ? 'locked-cell' : ''}">
|
||||
${statusToolbar}
|
||||
<div class="status-edit ${statusClass}" contenteditable="${statusEditable}" ${statusClick}>${safeStatus}</div>
|
||||
</td>`;
|
||||
|
||||
// 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 += `<td class="person-cell ${roleClass(entry.role)} ${cls} ${locked ? 'locked-cell' : ''}" ${click}>${txt}</td>`;
|
||||
});
|
||||
|
||||
// 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 += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
|
||||
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
|
||||
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
|
||||
body += `</td>`;
|
||||
});
|
||||
body += `</tr>`;
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
let lockNotice = '';
|
||||
if(locked){
|
||||
lockNotice = `<div class="lock-notice" style="margin-bottom:10px;padding:8px 12px;background:#fff5f5;border:1px solid #fc8181;border-radius:6px;color:#c53030;font-weight:bold;font-size:12px;">
|
||||
Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich.
|
||||
<button type="button" class="secondary small" style="margin-left:12px;" onclick="unlockCurrentWeek()">Entsperren</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
table.innerHTML = `<thead>${h1}${h2}</thead><tbody>${body}</tbody>`;
|
||||
|
||||
// Legende mit vollständigen Namen und Anwesenheitstracking
|
||||
let legendeHtml = lockNotice + `<div style="margin-bottom:8px; font-weight:bold; color:var(--accent);">Legende Projektkürzel${locked ? '' : ' (klick für Anwesenheit)'}:</div>`;
|
||||
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 += `<div class="legend-list">` + legendeItems.map(item=>{
|
||||
const isPresent = weekAttendance[item.id];
|
||||
const cursor = locked ? 'not-allowed' : 'pointer';
|
||||
const click = locked ? '' : `onclick="toggleAttendance('${item.id}')"`;
|
||||
return `<div class="legend-item${isPresent?' present':''}${locked ? ' locked-legend-item' : ''}" ${click}><span>${esc(item.name)}</span> = ${esc(item.fullName)}</div>`;
|
||||
}).join('') + `</div>`;
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user