Files
projektuebersicht/public/js/board/board.js
T
erikandClaude Opus 5.5 a2266662ad Dashboard-Reihenfolge, Drei-Punkte-Menü, Mehrrollen-Kürzel, Login-Rechte
- Meine Aufgaben: Projekte in der Reihenfolge der Projektliste, je Konto
  per Ziehen umsortierbar (users.dashboard_reihenfolge)
- Projektliste: Öffnen/Löschen hinter einem "⋯"-Menü
- Avatare je Person statt je Rolle; mehrere Rollen als senkrechte
  Farbstreifen; Titelleiste zieht Rollenänderungen sofort nach
- Nach dem Anmelden vollständiges Konto über /whoami laden (Seitenleiste
  zeigte sonst erst nach Neuladen alle Einträge)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:21:29 +01:00

861 lines
46 KiB
JavaScript

// -----------------------------
// Projektübersicht — Board (Listenansicht)
// -----------------------------
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `App.html`) als
// schlanke, aufklappbare Liste statt der früheren breiten Matrix (eine Spalte
// je Person/Leistungsphase): jede Zeile zeigt auf einen Blick Kürzel-Avatare
// der zugeordneten Personen, die aktuelle Leistungsphase samt Prozentwert und
// eine einzeilige Status-Vorschau; ein Klick auf die Zeile klappt darunter das
// Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle
// LPH-Balken). Sie wird von `App.html` nach `js/weeks.js` (vor
// `js/people/people.js`) geladen und stellt globale Funktionen wie
// `renderBoard()`, `toggleBoardRow()`,
// und `editLph()` bereit. Sie nutzt `currentWeek()`/
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`,
// `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und
// `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus
// `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr),
// ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel
// auf/zu (toggleBoardRow()). editLph() ruft zusätzlich
// renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen
// Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem
// eigenen DOM-Container (#wochenberichtContent) liegen. Die Zuordnung der
// Beteiligten (p.assign) ist hier und im Wochenbericht nur noch Anzeige;
// editierbar ist sie allein über planCycleAssign() in
// js/plan/plan-settings.js — siehe renderBoardDrawer()s Kopfkommentar.
// boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt
// darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt
// außerhalb von renderBoard() und übersteht daher dessen Neuaufbauten (z. B.
// nach einer Zuordnungs- oder Prozent-Änderung im offenen Panel). Nicht
// persistiert — jede Sitzung startet mit eingeklappten Zeilen.
let boardRowOpen = {};
// berichtDisclosureState: merkt sich, ob die Anwesenheit im Wochenbericht
// (renderAttendance(), js/wochenbericht/wochenbericht.js) gerade auf- oder
// zugeklappt ist. Ursprünglich auch für Leistungsphasen/Beteiligte gedacht
// (damals beide ebenfalls Aufklapper, Fokus-Modus "C"); seit Eriks
// Rückmeldung zum Entwurfsvergleich ("Aktenregal") sind beide wieder
// dauerhaft sichtbar (renderBoardDrawer()s dichte Fassung weiter unten) und
// brauchen dieses Gedächtnis nicht mehr — nur die Anwesenheit bleibt ein
// Aufklapper. Ohne dieses Gedächtnis klappte sie bei jedem Neu-Rendern wieder
// auf ihren Vorgabewert zurück — und JEDE Änderung im Bericht (eine
// Zuordnung, ein LPH-Wert, ein Schritt im Vorwoche-Scrollrad) baut den ganzen
// Bericht neu auf. 'toggle' bubbelt nicht, ein Lauscher in der Capture-Phase
// sieht das Ereignis trotzdem auf dem Weg nach unten (gleiches Muster wie der
// scroll-Lauscher der Wandtafel).
const berichtDisclosureState = { anwesenheit: false };
document.addEventListener('toggle', function(e){
const key = e.target && e.target.dataset && e.target.dataset.berichtAbschnitt;
if(key) berichtDisclosureState[key] = e.target.open;
}, true);
// toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu.
// Parameter: pid (Projekt-ID). Keine Rückgabe.
function toggleBoardRow(pid){
boardRowOpen[pid] = !boardRowOpen[pid];
renderBoard();
// Der Wochenbericht und die Wandtafel rendern dieselben Projektzeilen in
// einem eigenen Container. Ohne diesen Aufruf merkten sie sich den Klick
// zwar, zeigten die Zeile aber erst nach einem Tab-Wechsel auf- bzw.
// zugeklappt.
if(typeof renderWochenbericht === 'function') renderWochenbericht();
if(typeof renderWandboard === 'function') renderWandboard();
}
// statusPreviewText: liefert eine einzeilige Klartext-Vorschau eines
// Status-HTML-Werts (Tags entfernt, überflüssiger Leerraum/Zeilenumbrüche zu
// einem Leerzeichen zusammengefasst) für die eingeklappte Zeile — die volle
// Formatierung bleibt dem aufgeklappten Editor vorbehalten. Parameter: html
// (String). Rückgabe: String.
function statusPreviewText(html){
if(!html) return '';
const tmp = document.createElement('div');
// Bereinigen, BEVOR der Wert ins DOM geht: Auch ein nicht eingehängtes
// Element lädt Ressourcen und führt deren Fehler-Handler aus, ungefiltertes
// innerHTML wäre hier also ein Einfallstor.
tmp.innerHTML = sanitizeStatusHtml(html);
// Zeilen- und Absatzgrenzen zuerst in Leerzeichen überführen: textContent
// ignoriert sie, sonst klebte das letzte Wort einer Zeile am ersten der
// nächsten ("… KW 40.Freigabe steht aus").
tmp.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode(' ')));
tmp.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode(' ')));
return (tmp.textContent || tmp.innerText || '').replace(/\s+/g, ' ').trim();
}
// HINWEIS: statusBlur() ist entfallen. Seit der Kommentar-Editor ein
// <textarea> ist (renderCommentEditor/saveBoardComment), gab es keinen Aufrufer
// mehr; die Funktion schrieb ungefiltertes innerHTML in p.status und wäre beim
// erneuten Verdrahten ein Einfallstor gewesen.
// 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,'-');
}
// renderBoardRow: baut die eingeklappte Zeile eines Projekts (Nr., Name +
// Tags, Status-Vorschau, Avatar-Stapel der Zugeordneten, Phasen-Badge) sowie
// bei Bedarf das aufgeklappte Detailpanel darunter. Parameter: p (Projekt),
// allPeople (Array `{person, role}`, alle Rollen), locked (Boolean).
// Rückgabe: HTML-String.
// BOARD_OPTS: die Einstellungen der Projektliste. Sie zeigt wie der
// Wochenbericht nur die beauftragten Leistungsphasen (Rückfall auf alle, wenn
// keine hinterlegt sind — p.beauftragteLP leer, siehe renderBoardDrawer()),
// nur in der ausführlichen statt der dichten Fassung, und nichts davon ist
// hier bearbeitbar (nurAnsicht).
const BOARD_OPTS = { nurAnsicht: true, nurBeauftragteLph: true };
const PROJEKT_PUNKTE_SVG = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.2" cy="8" r="1.4"/><circle cx="8" cy="8" r="1.4"/><circle cx="12.8" cy="8" r="1.4"/></svg>';
// ---------- Zeilenmenü der Projektliste ("⋯") ----------
// Öffnen und Löschen. Löschen fragt im Menü selbst nach (wie die
// Benutzerverwaltung), nicht über confirm(). Das Menü hängt an <body> mit
// position: fixed und klappt nach oben, wenn unten kein Platz ist.
let projektMenue = null; // { el, knopf, zeile }
function schliesseProjektMenue(fokusZurueck){
if(!projektMenue) return;
const { el, knopf, zeile } = projektMenue;
projektMenue = null;
el.remove();
knopf.setAttribute('aria-expanded', 'false');
if(zeile) zeile.classList.remove('is-menu-open');
if(fokusZurueck && document.contains(knopf)) knopf.focus();
}
function projektMenuePositionieren(){
if(!projektMenue) return;
const { el, knopf } = projektMenue;
const r = knopf.getBoundingClientRect();
const m = el.getBoundingClientRect();
const nachOben = (window.innerHeight - r.bottom) < m.height + 12 && r.top > m.height + 12;
el.classList.toggle('nach-oben', nachOben);
el.style.top = (nachOben ? r.top - m.height - 6 : r.bottom + 6) + 'px';
el.style.left = Math.max(8, Math.min(r.right - m.width, window.innerWidth - m.width - 8)) + 'px';
}
function oeffneProjektMenue(event, pid, locked){
event.stopPropagation();
const knopf = event.currentTarget;
if(projektMenue && projektMenue.knopf === knopf){ schliesseProjektMenue(true); return; }
schliesseProjektMenue(false);
const w = currentWeek();
const p = w && w.projects.find(x => x.id === pid);
const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : '';
const darfLoeschen = !locked && darfProjektlisteAendern();
const el = document.createElement('div');
el.className = 'user-menu';
el.setAttribute('role', 'menu');
document.body.appendChild(el);
const zeile = knopf.closest('.board-row');
projektMenue = { el, knopf, zeile };
knopf.setAttribute('aria-expanded', 'true');
if(zeile) zeile.classList.add('is-menu-open');
const eintraege = () => {
el.innerHTML = `<button type="button" role="menuitem" class="user-menu-item" data-aktion="oeffnen">
${icon('folder', 'user-menu-ic')}<span class="user-menu-text"><span>Öffnen</span><span class="user-menu-sub">Projektbereich</span></span></button>`
+ (darfLoeschen ? `<hr class="user-menu-trenner"><button type="button" role="menuitem" class="user-menu-item is-danger" data-aktion="loeschen">
${icon('trash', 'user-menu-ic')}<span class="user-menu-text"><span>Löschen</span><span class="user-menu-sub">30 Tage im Papierkorb</span></span></button>` : '');
projektMenuePositionieren();
};
eintraege();
el.addEventListener('click', e => {
e.stopPropagation();
const b = e.target.closest('button');
if(!b) return;
if(b.dataset.zurueck){ eintraege(); return; }
if(b.dataset.aktion === 'oeffnen'){ schliesseProjektMenue(false); openProjectPlan(pid); return; }
if(b.dataset.aktion === 'loeschen'){
el.innerHTML = `<div class="user-menu-confirm"><p>Projekt ${bez ? '<b>' + esc(bez) + '</b> ' : ''}löschen? Ein Administrator kann es 30 Tage lang wiederherstellen.</p>
<div class="user-menu-confirm-row"><button type="button" class="secondary small" data-zurueck="1">Abbrechen</button>
<button type="button" class="danger small" data-aktion="endgueltig">Löschen</button></div></div>`;
projektMenuePositionieren();
el.querySelector('[data-aktion="endgueltig"]').focus();
return;
}
if(b.dataset.aktion === 'endgueltig'){ schliesseProjektMenue(false); delProjectFromList(pid, true); }
});
if(event.detail === 0){ const erstes = el.querySelector('button'); if(erstes) erstes.focus(); }
}
document.addEventListener('click', () => schliesseProjektMenue(false));
document.addEventListener('keydown', e => {
if(!projektMenue) return;
const ziele = Array.from(projektMenue.el.querySelectorAll('button'));
const i = ziele.indexOf(document.activeElement);
if(e.key === 'Escape'){ e.preventDefault(); schliesseProjektMenue(true); }
else if(e.key === 'ArrowDown'){ e.preventDefault(); if(ziele.length) ziele[(i + 1) % ziele.length].focus(); }
else if(e.key === 'ArrowUp'){ e.preventDefault(); if(ziele.length) ziele[(i - 1 + ziele.length) % ziele.length].focus(); }
else if(e.key === 'Tab') schliesseProjektMenue(false);
});
window.addEventListener('resize', () => schliesseProjektMenue(false));
window.addEventListener('scroll', () => schliesseProjektMenue(false), true);
function renderBoardRow(p, allPeople, locked, weekLabel, opts){
const isOpen = !!boardRowOpen[p.id];
const tags = Array.isArray(p.tags) ? p.tags : [];
const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
const previewText = statusPreviewText(kommentarVon(p, kommentarGewerk()).status);
const isNew = p.statusState === 'new';
// Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete
// Personen (Zustand 1 = Haupt oder 2 = optional), volle Rollen-Matrix nur
// im aufgeklappten Detailpanel (siehe renderBoardDrawer).
// Eine Person mit mehreren Rollen erscheint nur einmal (beteiligteJePerson()).
const stackHtml = beteiligteJePerson(allPeople.map(entry => ({
person: entry.person, role: entry.role, state: p.assign[assignKey(entry.person.id, entry.role)] || 0
}))).map(b => beteiligtenAvatarHtml(b, roleClass(b.role))).join('');
const aktLp = lpActiveList(p)[0];
const aktPct = aktLp && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? p.lph[aktLp] : null;
const phaseHtml = aktLp
? `<span class="phase-pill">${esc(aktLp)}${aktPct !== null ? ' · ' + esc(aktPct) + '%' : ''}</span>`
: `<span class="phase-pill muted">Keine aktive LP</span>`;
// Ziehbar und mit Griff: Die Reihenfolge innerhalb einer Gruppe und der
// Wechsel zwischen Gruppen wurden zuvor im eigenen Tab "Projekte" gepflegt,
// der dieselben Projekte ein zweites Mal auflistete. Beides geschieht jetzt
// hier, in der einen Projektliste.
let html = `<div class="board-row${isOpen ? ' open' : ''}" data-project-id="${p.id}" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'}>`;
html += `<div class="board-row-main" onclick="toggleBoardRow('${p.id}')">`;
html += locked ? '' : `<span class="drag-handle" title="Verschieben" onclick="event.stopPropagation()">${icon('grip')}</span>`;
html += `<span class="board-chev">${icon('chevronRight')}</span>`;
html += `<span class="board-nr">${esc(p.nr) || '<span class="hint" style="margin:0">ohne Nr.</span>'}</span>`;
html += `<div class="board-name-wrap">
<span class="board-name" onclick="event.stopPropagation();openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</span>
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
${tagsHtml}
<div class="board-status-line">${isNew ? '<span class="board-newdot" title="Neuer Kommentar"></span>' : ''}${previewText ? esc(previewText) : '<span class="hint" style="margin:0">Kein Kommentar</span>'}</div>
</div>`;
html += `<div class="board-avatar-stack">${stackHtml}</div>`;
html += phaseHtml;
// Listenoperationen am Zeilenende (aus dem früheren Projekte-Tab). Das
// Bearbeiten der Projektangaben führt in den Projektbereich, wo sie ihren
// einen Ort haben — hier steht nur, was die LISTE betrifft.
// Öffnen/Löschen hinter einem Drei-Punkte-Menü (Eriks Wunsch), gebaut wie
// das Zeilenmenü der Benutzerverwaltung (.user-kebab/.user-menu).
html += `<div class="board-row-actions" onclick="event.stopPropagation()">
<button type="button" class="user-kebab" aria-haspopup="menu" aria-expanded="false"
aria-label="Aktionen für ${esc(p.name || 'Projekt')}" title="Aktionen"
onclick="oeffneProjektMenue(event, '${p.id}', ${locked ? 'true' : 'false'})">${PROJEKT_PUNKTE_SVG}</button>
</div>`;
html += `</div>`; // board-row-main
if(isOpen) html += renderBoardDrawer(p, allPeople, locked, weekLabel, opts);
html += `</div>`; // board-row
return html;
}
// ---------- Kommentar-Editor (Board-Schublade und Wochenbericht) ----------
// Der Kommentar eines Projekts zu einer Sitzung (p.status) wird nicht mehr in
// einem contenteditable-Feld mit document.execCommand-Leiste bearbeitet,
// sondern in einem normalen Textfeld mit ausdrücklichem Speichern. Dazu
// kommen Verfasser und Zeitstempel (p.statusAuthor/p.statusAt, Spalten
// status_author/status_at in der Tabelle projects) sowie der scrollbare
// Verlauf der vorangegangenen Sitzungen aus projectStatusHistory()
// (js/utils.js), der bisher nur auf der Projektplan-Seite sichtbar war.
//
// Zusammenspiel mit cloneWeek() (js/weeks.js): Beim Duplizieren einer Sitzung
// wird der Kommentartext NICHT mehr mitkopiert — man schreibt den Kommentar
// dieser Woche, statt den der Vorwoche zu überschreiben. Wenn sich nichts
// geändert hat, übernimmt der Knopf "übernehmen" am jeweiligen Verlaufs-
// Eintrag dessen Text in das Textfeld.
// boardCommentBaseline: der zuletzt gespeicherte Kommentartext je Projekt-ID,
// Vergleichsbasis für die "ungespeichert"-Anzeige und für "Verwerfen".
// Wird bei jedem Aufbau der Schublade neu gesetzt.
let boardCommentBaseline = {};
// boardCommentUebernommen: je Projekt-ID, ob der gespeicherte Kommentar die
// unveränderte Übernahme aus der Vorwoche ist (Sitzungswechsel kopiert ihn,
// ensureCurrentWeekRollover() in server.js). Dann leert "Verwerfen" das Feld.
let boardCommentUebernommen = {};
// kommentarAusVorwocheUebernommen: true, wenn der Kommentar des gewählten
// Gewerks wortgleich und mit demselben Zeitstempel schon in einer früheren
// Sitzung steht — dort der jüngste nicht leere (die einmalige Übernahme der
// Einführung kam nicht immer aus der direkten Vorwoche).
// Parameter: p (Projekt), weekLabel (String). Rückgabe: Boolean.
function kommentarAusVorwocheUebernommen(p, weekLabel){
const g = kommentarGewerk();
if(!g || !p.projectUid) return false;
const jetzt = kommentarVon(p, g);
if(!(jetzt.status || '').trim()) return false;
const versionen = projectVersionsForUid(p.projectUid);
const idx = versionen.findIndex(v => v.week.label === weekLabel);
for(let i = (idx < 0 ? versionen.length : idx) - 1; i >= 0; i--){
const k = kommentarVon(versionen[i].project, g);
if(!(k.status || '').trim()) continue;
return k.status === jetzt.status && (k.statusAt || '') === (jetzt.statusAt || '');
}
return false;
}
// commentAuthorLine: Kopfzeile des Editors — wer den gespeicherten Kommentar
// verfasst hat und wann. Bestandskommentare ohne Verfasser (vor der
// Einführung von status_author) werden als "unbekannt" ausgewiesen, statt
// stillschweigend die aktuell angemeldete Person zu behaupten.
// Parameter: p (Projekt). Rückgabe: HTML-String.
function commentAuthorLine(p){
const k = kommentarVon(p, kommentarGewerk());
const hasText = statusPreviewText(k.status) !== '';
if(!hasText){
const me = (currentUser && currentUser.displayName) || '';
return me
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(me))}</span>${esc(me)}</span><span class="comment-sep">·</span><span>neuer Eintrag</span>`
: `<span>neuer Eintrag</span>`;
}
const author = k.statusAuthor || '';
const at = k.statusAt ? formatCommentStamp(k.statusAt) : '';
const who = author
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(author))}</span>${esc(author)}</span>`
: `<span class="comment-who comment-unknown">Verfasser unbekannt</span>`;
return who + (at ? `<span class="comment-sep">·</span><span>${esc(at)}</span>` : '');
}
// formatCommentStamp: ISO-Zeitstempel als "TT.MM.JJJJ, HH:MM". Parameter:
// iso (String). Rückgabe: String (leer bei ungültigem Wert).
function formatCommentStamp(iso){
const d = new Date(iso);
if(isNaN(d.getTime())) return '';
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth()+1)}.${d.getFullYear()}, ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// renderCommentEditor: der vollständige Kommentarbereich der Schublade —
// Editor mit Kopf-/Fußzeile und darunter der eingeklappte, scrollbare
// Verlauf.
//
// In der dichten Fassung (dicht = true, nur Wochenbericht) entfallen zwei
// Elemente, die dort je Karte rund 60 px kosteten, ohne etwas beizutragen:
// * Die eigene KOPFZEILE mit Verfasser und Tastenkürzel. Der Verfasser
// rückt in die Fußzeile neben die Knöpfe — dieselbe Angabe, eine Zeile
// weniger. Die Tastenkürzel stehen als Titel am Textfeld.
// * Der Aufklapper "FRÜHERE SITZUNGEN". Er ist seit dem Verlauf eine
// Doppelung: Der zeigt dieselben Kommentare und mehr. Seine einzige
// eigene Funktion — einen früheren Text übernehmen — hat der Verlauf
// übernommen (renderProjektVerlauf(), js/utils.js).
// Parameter: p (Projekt), weekLabel (String), locked (Boolean),
// dicht (Boolean). Rückgabe: HTML-String.
function renderCommentEditor(p, weekLabel, locked, dicht){
// Kommentar des gewählten Gewerks (Wochenbericht), siehe kommentarVon().
const html = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || '');
boardCommentBaseline[p.id] = html;
boardCommentUebernommen[p.id] = kommentarAusVorwocheUebernommen(p, weekLabel);
if(locked){
return `<div class="comment-block" data-pid="${esc(p.id)}">
<div class="comment-head-line">${commentAuthorLine(p)}</div>
<div class="comment-readonly">${html || '<span class="hint">Kein Kommentar erfasst.</span>'}</div>
</div>`;
}
const kopf = dicht ? '' : `<div class="comment-editor-head">
${commentAuthorLine(p)}
<span class="comment-kbd">Strg+↵ speichern · Esc verwerfen</span>
</div>`;
const verfasserImFuss = dicht ? `<span class="comment-foot-author">${commentAuthorLine(p)}</span>` : '';
// Der frühere Aufklapper "Frühere Sitzungen" ist entfallen. Er zeigte nur
// den Kommentartext und auch den nur für Sitzungen mit tatsächlicher
// Änderung — eine dritte, engere Auswahl neben dem Verlauf im
// Wochenbericht und dem Dialog im Projektbereich. Alle drei Stellen nutzen
// jetzt renderProjektVerlauf() (js/utils.js) und zeigen damit dasselbe.
const verlaufAufklapper = '';
// richtextToolbarHtml() (js/utils.js): Fett/Kursiv/Farbe für den Kommentar
// (Eriks Wunsch) — anders als kopf (der Autor-/Tastenkürzel-Kopf) IMMER
// gezeigt, auch im dichten Modus: "dicht" ist hier die reguläre, zweispaltig
// kompakte Wochenbericht-Kartenansicht (nicht eine lange Liste vieler
// Karten), genau die Stelle, an der der Kommentar normalerweise bearbeitet
// wird — die Werkzeugleiste ist klein genug, um dort nicht zu stören.
const formatLeiste = richtextToolbarHtml('.comment-field');
const feldAttrs = `${dicht ? 'title="Strg+↵ speichern · Esc verwerfen" ' : ''}`
+ `oninput="markCommentDirty(this)" onkeydown="boardCommentKey(event, this)" onblur="boardCommentBlur(this)"`;
return `<div class="comment-block" data-pid="${esc(p.id)}">
<div class="comment-editor">
${kopf}
<div class="richtext-wrap">
${formatLeiste}
${richtextFeldHtml('comment-field', html, 'Was gibt es diese Woche zu diesem Projekt?', feldAttrs)}
</div>
<div class="comment-editor-foot">
<button type="button" class="small" onmousedown="event.preventDefault()" onclick="saveBoardComment(this)">Speichern</button>
<button type="button" class="secondary small"
onmousedown="event.preventDefault();discardBoardComment(this)">Verwerfen</button>
${verfasserImFuss}
<span class="comment-foot-spacer"></span>
<span class="comment-dirty" hidden>● ungespeichert</span>
</div>
</div>
${verlaufAufklapper}
</div>`;
}
// pendingCommentFocus: Projekt-ID des Kommentarfelds, in das der Nutzer gerade
// klickt. Hintergrund: Ein Klick in ein anderes Kommentarfeld löst zuerst den
// Fokusverlust des bisherigen aus; dessen Blur-Netz speichert und rendert
// Board UND Wochenbericht neu — das angeklickte Feld ist dann längst durch ein
// neues Element ersetzt, der Klick geht ins Leere und die nächsten
// Tastenanschläge landen nirgends. Die Absicht wird deshalb in der
// Capture-Phase festgehalten und nach dem Neu-Rendern wiederhergestellt.
let pendingCommentFocus = null;
document.addEventListener('mousedown', function(e){
const field = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
const block = field && field.closest('.comment-block');
pendingCommentFocus = block ? block.dataset.pid : null;
}, true);
// restorePendingCommentFocus: setzt den Fokus nach einem Neu-Rendern in das
// Kommentarfeld, das der Nutzer anklicken wollte, und stellt den Cursor ans
// Textende. Ohne gemerkte Absicht oder ohne Treffer passiert nichts.
// Keine Parameter, keine Rückgabe.
function restorePendingCommentFocus(){
const pid = pendingCommentFocus;
pendingCommentFocus = null;
if(!pid) return;
const sel = '.comment-block[data-pid="' + ((window.CSS && CSS.escape) ? CSS.escape(pid) : pid) + '"] .comment-field';
// Board und Wochenbericht rendern dieselben Projekte; das sichtbare Feld
// gewinnt (offsetParent ist null, solange der Tab ausgeblendet ist).
const kandidaten = Array.from(document.querySelectorAll(sel));
const field = kandidaten.find(f => f.offsetParent !== null) || kandidaten[0];
if(!field) return;
richtextCursorAnsEnde(field);
}
// commentParts: löst von einem beliebigen Bedienelement aus dessen
// Kommentar-Block auf und liefert Projekt-ID, Textfeld und Hinweis-Element.
//
// Warum nicht über Element-IDs: Projektboard und Wochenbericht rendern
// dieselben Projekte gleichzeitig (der Bericht nutzt renderBoardDrawer()
// wieder). Feste IDs wie "bc_text_<pid>" wären dann doppelt vergeben, und
// document.getElementById() liefert immer den ersten Treffer — im
// Wochenbericht getippter Text wurde dadurch beim Speichern durch den Inhalt
// des (unsichtbaren) Board-Felds ersetzt. Über closest() trifft jede Aktion
// garantiert den Block, in dem sie ausgelöst wurde.
// Parameter: el (Element innerhalb des Blocks). Rückgabe: Objekt oder null.
function commentParts(el){
if(!el || !el.closest) return null;
// Erst der eigene Block; findet sich keiner, der Block derselben Karte.
// Gebraucht seit der Verlauf (js/wochenbericht/wochenbericht.js) einen
// "übernehmen"-Knopf trägt: Er steht UNTER dem Spaltenraster und damit
// ausserhalb von .comment-block, gehört aber zum selben Projekt.
const block = el.closest('.comment-block')
|| (el.closest('.report-card') || document).querySelector('.comment-block');
if(!block) return null;
return {
block,
pid: block.dataset.pid,
field: block.querySelector('.comment-field'),
dirty: block.querySelector('.comment-dirty')
};
}
// markCommentDirty: blendet den "ungespeichert"-Hinweis ein bzw. aus, indem
// der Feldinhalt mit dem zuletzt gespeicherten Stand verglichen wird.
// Parameter: el (Element im Kommentar-Block). Keine Rückgabe.
function markCommentDirty(el){
const c = commentParts(el);
if(!c || !c.field || !c.dirty) return;
c.dirty.hidden = c.field.innerHTML === (boardCommentBaseline[c.pid] || '');
}
// saveBoardComment: schreibt den Feldinhalt als neuen Kommentar des Projekts,
// samt Verfasser (angemeldete Person) und Zeitstempel, und rendert neu.
// Wird nichts geändert, passiert nichts — ein unverändertes Feld soll weder
// den Zeitstempel noch den Verfasser überschreiben. Parameter: el (Element im
// Kommentar-Block). Rückgabe: keine.
function saveBoardComment(el){
if(guardWeekLocked()) return;
const c = commentParts(el);
if(!c || !c.field) return;
const html = sanitizeStatusHtml(c.field.innerHTML);
if(html === (boardCommentBaseline[c.pid] || '')) return;
const p = currentWeek().projects.find(x => x.id === c.pid);
if(!p) return;
// Geschrieben wird immer der Kommentar EINES Gewerks — der Editor steht
// nur im Wochenbericht, und der hat stets ein gewähltes Gewerk.
const gewerk = kommentarGewerk();
if(!gewerk) return;
const before = (kommentarVon(p, gewerk).status || '').trim();
if(before === '' && html !== '') p.statusState = 'new';
else if(html === '') delete p.statusState;
kommentarSetzen(p, gewerk, html === ''
? { status: '', statusAuthor: '', statusAt: '' }
: { status: html, statusAuthor: (currentUser && currentUser.displayName) || '', statusAt: new Date().toISOString() });
boardCommentBaseline[c.pid] = html;
save();
renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
// Beide Ansichten wurden komplett neu aufgebaut — den Fokus in das Feld
// zurückholen, das der Nutzer gerade anklicken wollte.
restorePendingCommentFocus();
}
// discardBoardComment: setzt das Textfeld auf den zuletzt gespeicherten Stand
// zurück. Parameter: el (Element im Kommentar-Block). Keine Rückgabe.
function discardBoardComment(el){
const c = commentParts(el);
if(!c || !c.field) return;
// Übernommener Vorwochen-Kommentar: Verwerfen leert das ganze Feld, damit
// der Kommentar dieser Woche neu geschrieben werden kann (Eriks Wunsch).
c.field.innerHTML = boardCommentUebernommen[c.pid] ? '' : (boardCommentBaseline[c.pid] || '');
markCommentDirty(c.field);
richtextCursorAnsEnde(c.field);
}
// boardCommentKey: Tastaturbedienung des Kommentarfelds — Strg/Cmd+Enter
// speichert, Escape verwirft. Ein einfaches Enter bleibt der Zeilenumbruch.
// Parameter: e (KeyboardEvent), el (das Textfeld). Keine Rückgabe.
function boardCommentKey(e, el){
if(e.key === 'Enter' && (e.ctrlKey || e.metaKey)){
saveBoardComment(el);
e.preventDefault();
} else if(e.key === 'Escape'){
discardBoardComment(el);
e.preventDefault();
} else {
// Enter: neue Zeile beginnt mit "- " (js/utils.js)
richtextNeueZeileMitStrich(e, el);
}
}
// boardCommentBlur: Sicherheitsnetz — wer tippt und dann woanders hin klickt,
// verliert seinen Text nicht. Entspricht dem früheren Verhalten von
// statusBlur(). Bedienelemente, bei denen der Fokusverlust stören würde
// ("Verwerfen", "übernehmen", die Anwesenheits-Kacheln), unterdrücken ihn per
// onmousedown/preventDefault. Parameter: el (das Textfeld). Keine Rückgabe.
function boardCommentBlur(el){
saveBoardComment(el);
}
// takeCommentFromHistory: übernimmt den Kommentartext einer früheren Sitzung
// in das Textfeld der aktuellen — für den häufigen Fall "gegenüber der
// Vorwoche unverändert". Gespeichert wird erst durch Speichern (oder das
// Blur-Netz), sodass der Text vorher noch angepasst werden kann. Parameter:
// el (der geklickte Knopf), weekLabel (Bezeichnung der Quell-Sitzung).
// Keine Rückgabe.
function takeCommentFromHistory(el, weekLabel){
if(guardWeekLocked()) return;
const c = commentParts(el);
if(!c || !c.field) return;
const p = currentWeek().projects.find(x => x.id === c.pid);
if(!p || !p.projectUid) return;
// Zwei Quellen, weil zwei Aufrufer: Der Aufklapper "Frühere Sitzungen"
// listet projectStatusHistory() — also nur Sitzungen, in denen sich der
// Text tatsächlich GEÄNDERT hat. Der Verlauf im Wochenbericht zeigt dagegen
// JEDE frühere Sitzung. Verwies der Knopf dort auf eine Woche ohne
// Textänderung, fand find() nichts und das Übernehmen blieb wirkungslos.
const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel);
const html = entry
? entry.html
: (v => v ? sanitizeStatusHtml(kommentarVon(v.project, kommentarGewerk()).status || '') : null)(
projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel));
if(html === null || html === undefined) return;
c.field.innerHTML = sanitizeStatusHtml(html);
kommentarVorsatzEntfernen(c.field);
markCommentDirty(c.field);
richtextCursorAnsEnde(c.field);
}
// kommentarVorsatzEntfernen: nimmt beim Übernehmen den Gewerke-Vorsatz am
// Anfang weg ("[ELT] ", "[ELT+HLS] " — Altbestand aus der Zeit vor den
// Kommentaren je Gewerk); im Kommentar eines Gewerks ist er überflüssig.
// Arbeitet auf dem ersten Textknoten, damit Formatierung erhalten bleibt.
// Parameter: feld (Element). Keine Rückgabe.
function kommentarVorsatzEntfernen(feld){
const walker = document.createTreeWalker(feld, NodeFilter.SHOW_TEXT);
let knoten;
while((knoten = walker.nextNode())){
if(!knoten.textContent.trim()) continue;
knoten.textContent = knoten.textContent.replace(/^\s*\[[^\]]{1,20}\]\s*/, '');
break;
}
}
// renderBoardDrawer: baut das aufgeklappte Detailpanel einer Projekt-Zeile —
// links der volle Status-Editor (unverändert gegenüber der früheren Matrix-
// Zelle) plus alle LPH-Balken, rechts die vollständige Rollen-/Personen-
// Zuordnung als klickbare Avatar-Chips. Parameter: p (Projekt), allPeople
// (Array `{person, role}`), locked (Boolean). Rückgabe: HTML-String.
function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
// opts.dicht: die dichte Fassung der Karte (Wochenbericht). Sie zeigt
// dieselben Angaben und dieselben Bedienelemente wie die normale, nur
// enger gesetzt: die Leistungsphasen stehen als EINE Leiste nebeneinander
// statt als bis zu zehn Zeilen untereinander, und die Beteiligten rücken
// darunter statt in eine eigene Spalte. Die Zeilenform bestimmte bisher die
// Höhe der ganzen Karte — bei 29 Projekten ergab das rund 17
// Bildschirmseiten, durch die man sich im Teammeeting hindurchscrollen
// musste. Im Projektboard, wo die Karte nur beim Aufklappen einer Zeile
// erscheint, bleibt es bei der ausführlichen Fassung.
//
// nurBeauftragteLph: sowohl im Wochenbericht als auch im Projektboard
// werden nur die Leistungsphasen gezeigt, für die das Projekt beauftragt
// ist (p.beauftragteLP, gesetzt beim Anlegen oder im Honorar-Dialog). Ist
// nichts hinterlegt — was auf ältere Bestandsprojekte zutrifft —, bleibt es
// bei der vollständigen Liste, sonst stünde dort gar nichts.
// opts.nurAnsicht: die Karte zeigt, sie erfasst nicht.
//
// Kommentar und Fortschritt je Leistungsphase entstehen in der
// wöchentlichen Besprechung und werden deshalb allein im Wochenbericht
// gepflegt. Zuvor waren dieselben Angaben an drei Stellen bearbeitbar
// (Projektboard, Wochenbericht, Projektbereich) — niemand wusste, welche
// die richtige ist, und zwei Personen konnten dasselbe Feld an
// verschiedenen Stellen ändern. Dieselbe Karte, dieselben Werte, nur ohne
// Eingabefelder und mit einem Verweis dorthin, wo sie gepflegt werden.
// Eine gesperrte Sitzung wirkt weiterhin zusätzlich.
//
// Die Zuordnung der Beteiligten (p.assign, roleRows unten) gehörte früher
// zu dieser Gruppe, ist aber auf Eriks Wunsch umgezogen: ein Stammdatum,
// nur noch in den Projekteinstellungen editierbar
// (planCycleAssign(), js/plan/plan-settings.js). Hier — sowohl im
// Projektboard als auch im Wochenbericht — ist sie deshalb IMMER nur
// Anzeige, unabhängig von opts.nurAnsicht; cycleAssign() ist damit ohne
// Aufrufer und entfallen.
const ansicht = !!(opts && opts.nurAnsicht);
const starr = ansicht || locked;
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0;
const aktLp = lpActiveList(p)[0];
// Die amtierende Phase (aktLp) IMMER mit anzeigen, auch wenn sie nicht in
// beauftragteLP steht: aktLp kommt unabhängig von beauftragteLP aus
// lpActiveList() (js/load/load.js) - beide Felder können auseinanderlaufen
// (z. B. ein Projekt auf LP 8, aber nur LP 1/2/3/6 als beauftragt
// gepflegt). Ohne diese Ergänzung behauptete die Kopfzeile dieses
// Abschnitts ("LP 8 aktiv"), während LP 8 in der aufgeklappten Liste
// darunter gar nicht auftauchte und ihr Fortschritt dort nicht pflegbar
// war - gefunden beim Live-Test mit echten Projektdaten.
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l) || l === aktLp) : LPHS;
// lphLeiste: die Leistungsphasen nebeneinander statt gestapelt — seit
// Variante A (Eriks Wahl aus dem Layout-Vergleich) die EINE Darstellung für
// Leistungsphasen in dieser Schublade, nicht mehr nur die dichte
// Wochenbericht-Karte. Jede Phase ist ein schmales Feld mit der
// Phasennummer und ihrem Eingabefeld; der Füllstand steckt als senkrechter
// Verlauf im Feld-Hintergrund. Die amtierende Phase bleibt hervorgehoben.
const lphLeiste = sichtbareLphs.map(l=>{
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp;
const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0;
// Deckkraft 4d (30 %): Bei 33 (20 %) war der Füllstand auf dem dunklen
// Feld-Hintergrund praktisch nicht zu sehen, und damit hätte die Leiste
// den Balken der Zeilenform ersatzlos verloren.
const fuellung = pct > 0
? ` style="background:linear-gradient(to top, ${getProgressColor(pct)}4d ${pct}%, transparent ${pct}%)"`
: '';
return `<div class="board-lph-cell${aktiv ? ' lp-aktiv' : ''}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}"${fuellung} title="${esc(l)}${aktiv ? ' · amtierende Leistungsphase' : ''}">
<span class="board-lph-cell-label">${esc(l.replace('LPH ', ''))}</span>
<input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">
</div>`;
}).join('');
const roleRows = ROLES.map(r=>{
const entries = allPeople.filter(e=>e.role===r);
const chips = entries.length ? entries.map(entry=>{
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
return `<span class="board-avatar ${roleClass(entry.role)} state-${state} is-ansicht${locked ? ' locked-cell' : ''}" title="${esc(entry.person.fullName || entry.person.name)}">${esc(entry.person.name)}</span>`;
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
return `<div class="board-role-row"><span class="board-role-label ${roleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
}).join('');
// Dichte Fassung (Wochenbericht), zweite Fassung (Eriks Rückmeldung zum
// Entwurfsvergleich "Aktenregal"): Leistungsphasen und Beteiligte stehen
// jetzt dauerhaft sichtbar nebeneinander statt hinter Aufklappern verborgen
// — "die Beteiligten sollen mit angezeigt werden". Die erste Fokus-Modus-
// Fassung hatte beide hinter <details> versteckt (Reaktion auf "zu
// überladen" bei allem gleichzeitig sichtbar); diesmal bleiben beide
// bewusst KOMPAKT, damit sie nebeneinander Platz finden, ohne den Kommentar
// ("im Vergleich zu groß") zu überragen: Leistungsphasen nutzen dieselbe
// Kästchen-Leiste wie das Projektboard (lphLeiste, oben in dieser Funktion
// berechnet — keine zweite, eigene LPH-Darstellung mehr für den
// Wochenbericht), Beteiligte ihre ohnehin schon kompakten Rollenzeilen
// (roleRows, s. u.).
//
// Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
// mehreren Rollen vorkommen, und ohne die Zeile wäre nicht erkennbar, in
// welcher Rolle genau jemand zugeordnet ist (dieselbe Zeilenform nutzt die
// jetzt editierbare Matrix der Projekteinstellungen, planCycleAssign(),
// js/plan/plan-settings.js).
if(opts && opts.dicht){
const lphInfo = aktLp
? esc(aktLp.replace('LPH ', 'LP ')) + ' aktiv' + (p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? ' · ' + esc(p.lph[aktLp]) + '%' : '')
: 'keine aktiv';
return `<div class="board-drawer board-drawer-dicht">
${renderCommentEditor(p, weekLabel, locked, true)}
<div class="bericht-meta-grid">
<div>
<div class="bericht-meta-label">${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'} · ${lphInfo}</div>
${lphLeiste ? `<div class="board-lph-leiste">${lphLeiste}</div>` : '<span class="hint" style="margin:0">Keine gültige Leistungsphase hinterlegt — bitte in den Projekteinstellungen prüfen.</span>'}
</div>
<div>
<div class="bericht-meta-label">Beteiligte</div>
<div class="board-role-mini">${roleRows}</div>
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
</div>
</div>
${(opts.ohneVerlauf || typeof renderProjektVerlauf !== 'function') ? '' : renderProjektVerlauf(p, weekLabel, locked)}
</div>`;
}
// Die drei Blöcke stehen als gleichrangige Geschwister im Raster, damit der
// aufrufende Kontext ihre Anordnung allein über CSS bestimmen kann: das
// Projektboard stellt Kommentar und Leistungsphasen übereinander in die
// linke Spalte (grid-template-areas in styles.css), der Wochenbericht
// nebeneinander in drei Spalten. Inhalt und Reihenfolge sind in beiden
// Fällen identisch.
return `<div class="board-drawer">
<div class="board-drawer-grid">
<div class="drawer-col drawer-col-comment">
<h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
${ansicht ? renderKommentarAnsicht(p, locked) : renderCommentEditor(p, weekLabel, locked)}
</div>
<div class="drawer-col drawer-col-lph">
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
<div class="board-lph-leiste">${lphLeiste}</div>
</div>
<div class="drawer-col drawer-col-people">
<h4>Beteiligte</h4>
<div class="board-role-mini">${roleRows}</div>
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
</div>
</div>
${ansicht ? renderBerichtVerweis(p, locked) : ''}
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
</div>`;
}
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderKommentarAnsicht(p, locked){
const text = sanitizeStatusHtml(kommentarVon(p, kommentarGewerk()).status || '');
return `<div class="status-edit is-ansicht locked-cell">${text ||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
}
// renderBerichtVerweis: der Weg zur Bearbeitung. Der Verweis springt in den
// Wochenbericht UND stellt dort dieses Projekt ein — ohne das müsste man es
// in einer Liste von dreißig wiederfinden.
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
function renderBerichtVerweis(p, locked){
if(locked) return '<div class="drawer-verweis"><span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span></div>';
const uid = esc(p.projectUid || '');
return `<div class="drawer-verweis">
<span class="hint" style="margin:0">Kommentar und Fortschritt werden in der Besprechung erfasst.</span>
<button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">Im Wochenbericht bearbeiten →</button>
</div>`;
}
// renderBoard: baut die Listenansicht des Boards auf (eine Bubble-Card je
// Projektgruppe mit den Projekten als Zeilen, siehe renderBoardRow()). Liest
// die aktuelle Woche, gruppiert Projekte nach Gruppe und sortiert sie nach
// `order`. 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 container = document.getElementById('boardTable');
// Ohne geladene Sitzung (Ladefehler, oder die letzte Sitzung wurde von
// jemand anderem gelöscht) griff der Code ungeprüft auf w.projects zu — der
// TypeError riss renderAll() mit und ließ die Oberfläche halb gerendert
// stehen. Alle Schwester-Renderer prüfen das bereits selbst.
if(!w){
if(container) container.innerHTML = '<div class="hint">Keine Sitzung geladen.</div>';
return;
}
// Ziehen zwischen Gruppen ändert Stammdaten (Rollen & Rechte).
const locked = isWeekLocked(w) || !hatRecht('proj.stamm');
const grouped = peopleByRole(w);
const allPeople = [];
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
// Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen.
//
// Alle vier Standardgruppen werden vorbelegt, auch wenn sie leer sind: Sonst
// entstünde für eine leer geräumte Gruppe keine Ablagefläche, und ein
// Projekt ließe sich nicht mehr dorthin ziehen. (Aus dem früheren
// Projekte-Tab übernommen, der hierin aufgegangen ist.)
const groups = {};
GROUP_ORDER.forEach(g => { groups[g] = []; });
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 html = "";
sortGroups(Object.keys(groups)).forEach(g=>{
html += `<div class="section-title">${esc(g)}</div>`;
// Klasse "projects-sortable": Die Ablage-Handler aus
// js/projects/projects.js (handleDrop/reorderProjects) finden ihre
// Container über diesen Selektor. Sie bleiben dort, weil sie auch die
// Reihenfolge-Logik für die Daten enthalten — hier wird nur dieselbe
// Ablagefläche aufgespannt.
html += `<div class="board-list projects-sortable" data-group="${esc(g)}" ${locked ? '' : 'ondragover="handleDragOver(event)" ondrop="handleDrop(event)"'}>`;
groups[g].forEach(p=>{ html += renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS); });
if(groups[g].length === 0) html += `<div class="hint" style="padding:6px 2px 12px">Keine Projekte — Projekt hierher ziehen.</div>`;
html += `</div>`;
});
// Sperr-Hinweis über der Projektliste. Er stand früher im Container der
// Kürzel-Legende unter dem Board; seit die Legende entfallen ist, gehört er
// dorthin, wo er gilt — über die Liste, die man nicht bearbeiten kann.
if(locked){
html = `<div class="lock-notice">
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>
</div>` + html;
}
container.innerHTML = html;
}
// 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);
if(!p) return;
if(!p.lph) p.lph = {};
val = String(val || '').trim();
if(val==="") delete p.lph[lph];
else {
// Nur Zahlen von 0 bis 100 — "abc" oder "250" hätten Auslastung und
// Leistungsstand mit NaN bzw. über 100 % verrechnet. Ungültiges wird
// verworfen (Neuzeichnen stellt den alten Wert wieder her).
const n = Number(val.replace('%','').replace(',','.').trim());
if(!Number.isFinite(n)){
renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
return;
}
p.lph[lph] = String(Math.max(0, Math.min(100, Math.round(n))));
}
save(); renderBoard();
if(typeof renderWochenbericht === 'function') renderWochenbericht();
}
// HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des
// Boards gibt es dort keinen Löschen-Knopf mehr. Projekte werden im
// Projekte-Tab entfernt (delProjectFromList(), js/projects/projects.js).