Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- 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>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+365 -12
View File
@@ -309,6 +309,104 @@ function statusFocus(el, pid){
currentStatusPid = pid;
}
// ---------- Kommentar und "besprochen" je Gewerk ----------
//
// ELT, HLS und ARCH halten ihre Besprechung getrennt (Eriks Wunsch: "Die
// Kommentare und Anwesenheiten von ELT brauchen nicht in HLS oder Arch zu
// sein" — und "auch wenn das Projekt in ELT besprochen ist, heißt das nicht,
// dass HLS das Projekt auch besprochen hat"). Ein Projekt mit mehreren
// Gewerken trägt deshalb je Gewerk einen eigenen Sitzungskommentar samt
// Verfasser/Zeitstempel und einen eigenen Besprochen-Haken:
// p.kommentare = { ELT: { status, statusAuthor, statusAt, besprochen }, … }
// (Tabelle project_kommentare, server.js). Die früheren Felder
// p.status/statusAuthor/statusAt/besprochen werden nicht mehr gelesen oder
// geschrieben — alles läuft über die drei Helfer unten.
// kommentarGewerk: das Gewerk, in dessen Zusammenhang gerade gelesen bzw.
// geschrieben wird — im Wochenbericht das dort gewählte, überall sonst null
// (= alle Gewerke zusammen, nur zur Anzeige). Keine Parameter.
function kommentarGewerk(){
if(typeof currentTab !== 'undefined' && currentTab === 'wochenbericht'
&& typeof wochenberichtGewerk !== 'undefined' && wochenberichtGewerk) return wochenberichtGewerk;
return null;
}
// kommentarVon: der Kommentar eines Projekts für ein Gewerk. Ohne Gewerk die
// gewerkübergreifende Anzeige: die Kommentare aller Gewerke untereinander,
// jeweils mit dem Gewerk davor (bei nur einem Gewerk ohne Beschriftung),
// Verfasser/Zeit des jüngsten, besprochen nur, wenn ALLE Gewerke des
// Projekts es abgehakt haben. Parameter: p (Projekt), g (Gewerk oder
// null/undefined). Rückgabe: { status, statusAuthor, statusAt, besprochen }.
function kommentarVon(p, g){
const alle = (p && p.kommentare) || {};
if(g){
const e = alle[g] || {};
return { status: e.status || '', statusAuthor: e.statusAuthor || '', statusAt: e.statusAt || '', besprochen: !!e.besprochen };
}
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : [];
let status = '';
if(mitText.length === 1 && gewerke.length <= 1) status = alle[mitText[0]].status;
// Jeder Kommentar in einem eigenen Block: Die Klartext-Vorschau
// (statusPlainText/statusPreviewText) trennt nur an Blockgrenzen — ohne
// eigenen Block klebten die Gewerke dort aneinander ("…fettHLS: …").
else status = mitText.map(k => `<div><b>${esc(k)}:</b></div><div>${alle[k].status}</div>`).join('');
const juengster = mitText.map(k => alle[k]).sort((a, b) => String(b.statusAt || '').localeCompare(String(a.statusAt || '')))[0] || {};
const pruefen = gewerke.length ? gewerke : Object.keys(alle);
const besprochen = pruefen.length > 0 && pruefen.every(k => alle[k] && alle[k].besprochen);
return { status, statusAuthor: juengster.statusAuthor || '', statusAt: juengster.statusAt || '', besprochen };
}
// kommentarAnzeigeHtml: der Kommentar zur ANZEIGE, bereinigt. Mit Gewerk g
// nur dessen Text; ohne Gewerk die Texte aller Gewerke, bei mehreren je mit
// dem Gewerk als eigener, dezenter Beschriftung (.kommentar-gewerk) —
// Systemtext, nicht Teil des Kommentars (anders als die fette "ELT:"-Zeile,
// die kommentarVon() für Klartext-Vorschauen einfügt). Rückgabe: HTML ('' ohne Text).
function kommentarAnzeigeHtml(p, g){
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
const alle = (p && p.kommentare) || {};
const reihenfolge = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
Object.keys(alle).forEach(k => { if(!reihenfolge.includes(k)) reihenfolge.push(k); });
const mitText = reihenfolge.filter(k => alle[k] && statusPlainText(alle[k].status) !== '');
if(mitText.length === 0) return '';
if(mitText.length === 1) return sanitizeStatusHtml(alle[mitText[0]].status);
return mitText.map(k => `<div class="kommentar-gewerk">${esc(k)}</div><div>${sanitizeStatusHtml(alle[k].status)}</div>`).join('');
}
// kommentarSetzen: ändert den Kommentar-Eintrag eines Gewerks (nur die in
// patch genannten Felder). Parameter: p (Projekt), g (Gewerk), patch
// (Objekt). Keine Rückgabe.
function kommentarSetzen(p, g, patch){
if(!p || !g) return;
p.kommentare = p.kommentare || {};
p.kommentare[g] = Object.assign(kommentarVon(p, g), patch || {});
}
// personGewerk: das Gewerk einer Person aus dem Teilnehmerkreis einer
// Sitzung. week.people kennt es selbst nicht; es kommt aus dem Konto
// (db.globalPeople, users.department) — gesucht über die Kennung, ersatzweise
// über den vollen Namen (Altbestand mit abweichender Kennung, siehe
// kapazitaetsFaktor()). Parameter: person. Rückgabe: Gewerk oder ''.
function personGewerk(person){
const liste = (typeof db !== 'undefined' && Array.isArray(db.globalPeople)) ? db.globalPeople : [];
const treffer = liste.find(x => x.id === person.id)
|| (person.fullName ? liste.find(x => x.fullName === person.fullName) : null);
return (treffer && treffer.gewerk) || '';
}
// personenDesGewerks: der Teilnehmerkreis einer Sitzung, eingeschränkt auf
// ein Gewerk — für die Anwesenheit, die je Gewerk getrennt geführt wird.
// Personen ohne bekanntes Gewerk bleiben überall sichtbar, damit niemand
// unauffindbar wird. Ohne Gewerk alle. Parameter: w (Woche), g.
// Rückgabe: Array.
function personenDesGewerks(w, g){
const leute = Array.isArray(w && w.people) ? w.people : [];
if(!g) return leute;
return leute.filter(person => { const pg = personGewerk(person); return !pg || pg === g; });
}
// 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.
@@ -345,6 +443,17 @@ function formatStatus(command, value){
// der Sitzung aller anderen ausführen. parseFromString() erzeugt dagegen ein
// inertes Dokument: kein Laden, kein Handler. Nachgemessen — mit innerHTML
// feuerte der Handler, mit DOMParser nicht, bei identischem Ergebnisbaum.
// fontFarbeErlaubt: nur echte Hex-Farben, und keine Grau-/Schwarz-/Weißtöne —
// eingefügter Text bringt oft color="#1a1a1a" mit, das im Dunkelmodus
// unsichtbar wäre und ohnehin nichts hervorhebt. Rückgabe: Boolean.
function fontFarbeErlaubt(wert){
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(wert || '').trim());
if(!m) return false;
const h = m[1].length === 3 ? m[1].replace(/./g, c => c + c) : m[1];
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
return Math.max(r, g, b) - Math.min(r, g, b) > 40;
}
function sanitizeStatusHtml(html){
if(!html) return '';
// Die Bereinigung läuft vollständig IM inerten Dokument ab: Würden die
@@ -386,6 +495,10 @@ function sanitizeStatusHtml(html){
}
for(let j = child.attributes.length - 1; j >= 0; j--){
const attr = child.attributes[j].name;
// <font color="…"> (ältere Kommentare, Browser-Farbbefehl) behält
// seine Farbe — vorher fiel sie hier still weg, und farbige
// Bestandskommentare erschienen überall einfarbig.
if(tag === 'FONT' && attr.toLowerCase() === 'color' && fontFarbeErlaubt(child.getAttribute(attr))) continue;
if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr);
}
if(child.hasAttribute('style')){
@@ -474,9 +587,239 @@ function richtextWickeln(feld, tagName, styleAttr){
sel.addRange(neueRange);
feld.dispatchEvent(new Event('input', { bubbles: true }));
}
function richtextFett(feld){ richtextWickeln(feld, 'b'); }
function richtextKursiv(feld){ richtextWickeln(feld, 'i'); }
function richtextFarbe(feld, farbe){ richtextWickeln(feld, 'span', 'color:' + farbe); }
// ---------- Auszeichnung wieder entfernen (Umschalten) ----------
//
// Eriks Wunsch: Ein zweiter Klick auf denselben Knopf nimmt die Auszeichnung
// wieder zurück — rot markierter Text wird durch erneutes Klicken auf Rot
// wieder schwarz, ebenso Fett/Kursiv und die übrigen Farben. Vorher wickelte
// jeder Klick nur eine weitere Hülle um den Text, rückgängig machen ließ sich
// nichts.
//
// Eine Auszeichnungsart ist ein kleines Objekt { trifft(el), entferne(el) }:
// trifft() erkennt ein Element dieser Art, entferne() nimmt NUR diese Art
// heraus — ein <span style="color:red;font-weight:bold"> verliert beim
// Entfernen der Farbe nicht auch das Fett.
// richtextFarbWert: normalisiert eine Farbangabe auf die Schreibweise des
// Browsers ("rgb(215, 0, 21)"), damit '#d70015' und der gespeicherte Wert
// vergleichbar sind. Parameter: farbe (String). Rückgabe: String.
function richtextFarbWert(farbe){
const t = document.createElement('span');
t.style.color = farbe || '';
return t.style.color;
}
// richtextAuspacken: ersetzt ein Element durch seine Kinder.
function richtextAuspacken(el){
if(el.parentNode) el.replaceWith(...Array.from(el.childNodes));
}
// richtextStilEntfernen: nimmt eine CSS-Eigenschaft aus dem style-Attribut
// und packt ein dann bedeutungsloses <span>/<font> aus.
function richtextStilEntfernen(el, prop){
el.style.removeProperty(prop);
if(!el.getAttribute('style')) el.removeAttribute('style');
const tag = el.tagName;
if((tag === 'SPAN' || tag === 'FONT') && el.attributes.length === 0) richtextAuspacken(el);
}
const RICHTEXT_ART_FETT = {
trifft: el => el.tagName === 'B' || el.tagName === 'STRONG'
|| /^(bold|[6-9]00)$/.test((el.style && el.style.fontWeight) || ''),
entferne: el => (el.tagName === 'B' || el.tagName === 'STRONG') ? richtextAuspacken(el) : richtextStilEntfernen(el, 'font-weight')
};
const RICHTEXT_ART_KURSIV = {
trifft: el => el.tagName === 'I' || el.tagName === 'EM',
entferne: el => richtextAuspacken(el)
};
// richtextArtFarbe: eine bestimmte Farbe (farbe gesetzt) oder jede Farbe
// (farbe leer — zum Umfärben: erst die alte Farbe entfernen, dann die neue
// setzen, statt Hüllen ineinander zu schachteln).
function richtextArtFarbe(farbe){
const ziel = farbe ? richtextFarbWert(farbe) : '';
const farbeVon = el => (el.style && el.style.color) || (el.tagName === 'FONT' ? richtextFarbWert(el.getAttribute('color')) : '');
return {
trifft: el => { const f = farbeVon(el); return !!f && (!ziel || f === ziel); },
entferne: el => {
if(el.tagName === 'FONT') el.removeAttribute('color');
richtextStilEntfernen(el, 'color');
}
};
}
// richtextHatArt: steht in einem Element (zwischen node und feld) eine
// Auszeichnung dieser Art?
function richtextHatArt(node, feld, art){
for(let n = node.nodeType === 1 ? node : node.parentNode; n && n !== feld; n = n.parentNode){
if(n.nodeType === 1 && art.trifft(n)) return true;
}
return false;
}
// richtextAuswahl: die aktuelle, nicht leere Markierung innerhalb von feld
// oder null.
function richtextAuswahl(feld){
if(!feld) return null;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
if(range.collapsed || !feld.contains(range.commonAncestorContainer)) return null;
return range;
}
// richtextIstAktiv: trägt JEDER markierte (sichtbare) Text die Auszeichnung?
// Nur dann schaltet ein Klick sie ab; ist sie nur teilweise vorhanden, wird
// sie auf die ganze Markierung ausgedehnt — wie in jeder Textverarbeitung.
function richtextIstAktiv(feld, range, art){
const wurzel = range.commonAncestorContainer;
const texte = [];
if(wurzel.nodeType === 3) texte.push(wurzel);
else {
const walker = document.createTreeWalker(wurzel, NodeFilter.SHOW_TEXT);
while(walker.nextNode()) if(range.intersectsNode(walker.currentNode)) texte.push(walker.currentNode);
}
const relevant = texte.filter(t => t.textContent.trim() !== '');
return relevant.length > 0 && relevant.every(t => richtextHatArt(t, feld, art));
}
// richtextLeereHuellenEntfernen: Beim Aufteilen bleiben leere <b>/<span>
// zurück — unsichtbar, aber sie würden mitgespeichert.
function richtextLeereHuellenEntfernen(feld){
feld.querySelectorAll('b, strong, i, em, u, span, font').forEach(el => {
if(el.textContent === '' && !el.querySelector('br')) el.remove();
});
}
// richtextArtEntfernen: entfernt eine Auszeichnungsart von der Markierung.
// Der markierte Teil wird herausgelöst und von dieser Art befreit; liegt er
// INNERHALB eines solchen Elements (rot markiertes Wort mitten in einem roten
// Satz), wird dieses Element um ihn herum aufgeteilt, sodass Text davor und
// dahinter seine Auszeichnung behält. Die Markierung bleibt danach auf
// demselben Text stehen.
function richtextArtEntfernen(feld, range, art){
const mitte = document.createElement('span');
mitte.appendChild(range.extractContents());
// Auszeichnungen dieser Art INNERHALB der Markierung
Array.from(mitte.querySelectorAll('*')).reverse().forEach(el => { if(art.trifft(el)) art.entferne(el); });
range.insertNode(mitte);
// Umgebende Elemente dieser Art um die Markierung herum aufteilen
for(;;){
let huelle = null;
for(let n = mitte.parentNode; n && n !== feld; n = n.parentNode){
if(n.nodeType === 1 && art.trifft(n)){ huelle = n; break; }
}
if(!huelle) break;
const vorher = document.createRange();
vorher.setStart(huelle, 0); vorher.setEndBefore(mitte);
const vorderteil = vorher.extractContents();
const nachher = document.createRange();
nachher.setStartAfter(mitte); nachher.setEnd(huelle, huelle.childNodes.length);
const hinterteil = nachher.extractContents();
if(vorderteil.textContent !== '' || vorderteil.querySelector && vorderteil.querySelector('br')){
const k = huelle.cloneNode(false); k.appendChild(vorderteil); huelle.before(k);
}
if(hinterteil.textContent !== '' || hinterteil.querySelector && hinterteil.querySelector('br')){
const k = huelle.cloneNode(false); k.appendChild(hinterteil); huelle.after(k);
}
art.entferne(huelle);
}
const kinder = Array.from(mitte.childNodes);
richtextAuspacken(mitte);
richtextLeereHuellenEntfernen(feld);
const sel = window.getSelection();
const neu = document.createRange();
const erstes = kinder.find(k => k.parentNode);
const letztes = kinder.slice().reverse().find(k => k.parentNode);
if(erstes && letztes){
neu.setStartBefore(erstes); neu.setEndAfter(letztes);
sel.removeAllRanges(); sel.addRange(neu);
}
}
// richtextUmschalten: setzt die Auszeichnung, oder nimmt sie zurück, wenn die
// ganze Markierung sie schon trägt. ersetzt (optional) ist eine Art, die
// vorher entfernt wird — für Farben: Blau auf rotem Text färbt um, statt eine
// blaue Hülle in die rote zu legen.
function richtextUmschalten(feld, art, wickeln, ersetzt){
const range = richtextAuswahl(feld);
if(!range) return;
if(richtextIstAktiv(feld, range, art)){
richtextArtEntfernen(feld, range, art);
} else {
if(ersetzt) richtextArtEntfernen(feld, range, ersetzt);
else richtextArtEntfernen(feld, range, art); // keine doppelten Hüllen
wickeln();
}
feld.dispatchEvent(new Event('input', { bubbles: true }));
}
function richtextFett(feld){
richtextUmschalten(feld, RICHTEXT_ART_FETT, () => richtextWickeln(feld, 'b'));
}
function richtextKursiv(feld){
richtextUmschalten(feld, RICHTEXT_ART_KURSIV, () => richtextWickeln(feld, 'i'));
}
function richtextFarbe(feld, farbe){
richtextUmschalten(feld, richtextArtFarbe(farbe),
() => richtextWickeln(feld, 'span', 'color:' + farbe), richtextArtFarbe(''));
}
// richtextFormatLoeschen: setzt Text auf das Standardformat zurück — kein
// Fett, kein Kursiv, keine Farbe. Mit Markierung nur diese, ohne Markierung
// das ganze Feld. Zeilenumbrüche und Absätze bleiben erhalten, nur die
// Auszeichnung fällt weg. Parameter: feld (Element). Keine Rückgabe.
function richtextFormatLoeschen(feld){
if(!feld || feld.getAttribute('contenteditable') !== 'true') return;
const saeubern = wurzel => {
Array.from(wurzel.querySelectorAll('b, strong, i, em, u, span, font')).reverse().forEach(richtextAuspacken);
wurzel.querySelectorAll('[style]').forEach(el => el.removeAttribute('style'));
};
const range = richtextAuswahl(feld);
if(range){
// Alle Arten nacheinander von der Markierung nehmen — teilt umgebende
// Hüllen sauber auf, statt den Text davor/dahinter mit zu entformatieren.
[RICHTEXT_ART_FETT, RICHTEXT_ART_KURSIV, richtextArtFarbe(''),
{ trifft: el => el.tagName === 'U', entferne: richtextAuspacken }]
.forEach(art => { const r = richtextAuswahl(feld); if(r) richtextArtEntfernen(feld, r, art); });
} else {
saeubern(feld);
}
feld.normalize();
feld.dispatchEvent(new Event('input', { bubbles: true }));
}
// richtextNeueZeileMitStrich: Enter beginnt die neue Zeile mit "- " (Eriks
// Wunsch für den Wochenbericht-Kommentar — Stichpunkte sind dort die Regel).
// Der Strich ist gewöhnlicher Text: Wer eine Zeile ohne ihn will, löscht ihn
// mit der Rücktaste. Umschalt+Enter bleibt beim Browser und fügt eine Zeile
// ganz ohne Strich ein.
// Parameter: e (keydown-Event), feld (Element). Rückgabe: true, wenn das
// Ereignis behandelt wurde.
function richtextNeueZeileMitStrich(e, feld){
if(e.key !== 'Enter' || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return false;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0 || !feld.contains(sel.getRangeAt(0).commonAncestorContainer)) return false;
e.preventDefault();
const range = sel.getRangeAt(0);
range.deleteContents();
const br = document.createElement('br');
// Nach dem <br> steht immer Text (der Strich) — ein <br> am Feldende
// würde der Browser sonst nicht als neue Zeile anzeigen.
// Geschütztes Leerzeichen: Ein gewöhnliches am Zeilenende verwirft der
// Browser beim Weitertippen, aus "- Text" würde "-Text".
const text = document.createTextNode('-\u00a0');
range.insertNode(text);
range.insertNode(br);
const neu = document.createRange();
neu.setStart(text, text.textContent.length);
neu.collapse(true);
sel.removeAllRanges(); sel.addRange(neu);
feld.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
// richtextFeldHtml: liefert ein contenteditable-Feld mit Platzhaltertext
// (:empty:before + data-placeholder, siehe styles.css) als Ersatz für ein
@@ -579,7 +922,7 @@ document.addEventListener('selectionchange', function(){
const markiert = !!(bereich && feld.contains(bereich.commonAncestorContainer));
leiste.classList.toggle('hat-auswahl', markiert);
const hinweis = leiste.querySelector('.richtext-hinweis');
if(hinweis) hinweis.textContent = markiert ? '✓ Markiert — jetzt formatieren' : 'Erst Text markieren';
if(hinweis) hinweis.textContent = markiert ? '✓ Markiert — jetzt formatieren' : 'Erst Text markieren · erneut klicken hebt auf';
});
});
@@ -619,7 +962,10 @@ function richtextToolbarHtml(targetSelector){
</span>
<span class="richtext-divider"></span>
<span class="richtext-farben">${farbButtons}</span>
<span class="richtext-hinweis">Erst Text markieren</span>
<span class="richtext-divider"></span>
<button type="button" class="richtext-btn richtext-btn-loeschen" title="Formatierung entfernen — die Markierung, ohne Markierung den ganzen Text"
onmousedown="event.preventDefault()" onclick="richtextFormatLoeschen(${ziel})">Standard</button>
<span class="richtext-hinweis">Erst Text markieren · erneut klicken hebt auf</span>
</div>`;
}
@@ -672,18 +1018,22 @@ function statusPlainText(html){
// wurden, tragen den Text der Vorwoche unverändert mit. Parameter: uid
// (String). Rückgabe: Array von { week, text, html, author, at, isFirst },
// neueste zuerst.
function projectStatusHistory(uid){
function projectStatusHistory(uid, g){
// Gewerk: ausdrücklich übergeben, sonst das des Zusammenhangs (im
// Wochenbericht das gewählte, siehe kommentarGewerk()).
const gewerk = g === undefined ? kommentarGewerk() : g;
const versions = projectVersionsForUid(uid);
const entries = [];
let lastText = null;
versions.forEach((v, idx) => {
const text = statusPlainText(v.project.status);
const k = kommentarVon(v.project, gewerk);
const text = statusPlainText(k.status);
const entry = {
week: v.week,
text,
html: sanitizeStatusHtml(v.project.status || ''),
author: v.project.statusAuthor || '',
at: v.project.statusAt || '',
html: sanitizeStatusHtml(k.status || ''),
author: k.statusAuthor || '',
at: k.statusAt || '',
isFirst: idx === 0
};
if(idx === 0){
@@ -829,7 +1179,10 @@ function renderProjektVerlauf(p, weekLabel, locked){
</div>`;
}).join('');
const text = statusPlainText(v.project.status);
const text = statusPlainText(kommentarVon(v.project, kommentarGewerk()).status);
// Angezeigt wird der Kommentar MIT Formatierung (Farben, fett, Absätze),
// das Gewerk als eigene Beschriftung; text dient nur Tooltip und Prüfung.
const html = text ? kommentarAnzeigeHtml(v.project, kommentarGewerk()) : '';
const termine = verlaufTermine(v.project);
const termineDavor = davor ? verlaufTermine(davor.project) : { ende:'', meilenstein:'' };
const beteiligte = verlaufBeteiligte(v);
@@ -863,7 +1216,7 @@ function renderProjektVerlauf(p, weekLabel, locked){
<span class="timeline-kw">${esc(v.week.label)}</span>
<span class="timeline-datum">${esc(v.week.date ? formatWeekDate(v.week.date) : '')}</span>
</header>
<div class="timeline-text${text ? '' : ' timeline-leer'}" title="${esc(text)}">${text ? esc(text) : 'Kein Kommentar'}</div>
<div class="timeline-text${text ? '' : ' timeline-leer'}" title="${esc(text)}">${text ? html : 'Kein Kommentar'}</div>
${uebernehmen}
<div class="timeline-lph-list">${lphHtml}</div>
${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''}