Files
erikandClaude Opus 5.5 4c2e04e78b Kleine Verbesserungen und Teams, Outlook Integration
- Outlook: Kalender-Abo je Konto (eigene Aufgaben, Meilensteine,
  Abwesenheiten als .ics), einzurichten unter Einstellungen
- Teams: Nachricht in einen Kanal bei neuen Aufgaben, mit Link direkt
  auf die Aufgabe; einzurichten unter Admin → Teams
- Sitzungen nach Bedarf laden: beim Start nur die aktuelle und drei
  davor, im Verlauf „Weitere laden“ in Fünferschritten
- Termin aus Mail: Wahl Aufgabe/Meilenstein, Notiz auch bei
  Meilensteinen, nur aktiv mit eingerichteter KI
- Kalender zeigt die KW, auch statt der Browser-Datumsfelder
- Pfeile bei den Abwesenheiten, kleinere Server-Korrekturen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 08:23:02 +01:00

1304 lines
64 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// Projektübersicht — allgemeine Helferfunktionen
// -----------------------------
// Diese Datei bündelt kleine, allgemein verwendbare Hilfsfunktionen (Escape-,
// ID-, Sortier- und Datumsfunktionen) und wird nach js/globals.js von beiden
// HTML-Einstiegspunkten geladen. Sie stellt ausschließlich globale Funktionen
// ohne eigenen Zustand bereit; genutzt wird sie praktisch von allen Modulen,
// u. a. js/board/board.js, js/load/load.js sowie den Plan-Modulen unter js/plan/*
// (z. B. für ISO-Datumsformate und Werktage) und von js/gantt.js.
// esc: einfache Escape-Funktion für Ausgabe in HTML
// Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, ", ' ersetzt).
// Das einfache Anführungszeichen MUSS mit maskiert werden: An mehreren Stellen
// landet der Rückgabewert in einem einfach gequoteten JS-String innerhalb eines
// onclick-Attributs (z. B. takeCommentFromHistory in js/board/board.js,
// setPlanAssign in js/plan/plan-info.js). Ein Apostroph im Sitzungslabel oder
// in einer Personen-Kennung brach dort sonst den Handler auf.
function esc(s){
// String() statt (s||""): Leistungsphasen-Prozentwerte und andere Zahlen
// kommen je nach Herkunft als Number an (Beispieldaten, ältere Datensätze),
// und (100).replace ist keine Funktion — der Aufruf riss bisher das ganze
// Rendern mit. 0 und false sollen als "0"/"false" erscheinen, nur null und
// undefined werden zu "".
return (s === null || s === undefined ? '' : String(s))
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// uid: generiert eine einfache, ausreichend eindeutige ID
// Parameter: keine. Rückgabe: String aus Zeitstempel und Zufallsteil.
function uid(){ return Date.now().toString(36)+Math.random().toString(36).slice(2,7); }
// assignKey: Schlüssel für Projektzuordnungen (Person + Rolle getrennt)
// Parameter: personId (String), role (String). Rückgabe: kombinierter Schlüssel 'personId|role'.
function assignKey(personId, role){ return personId + '|' + role; }
// sortGroups: sortiert Gruppen-Namen nach GROUP_ORDER, unbekannte ans Ende
// Parameter: arr (Array von Gruppennamen, wird in-place sortiert). Rückgabe: dasselbe Array.
function sortGroups(arr){
return arr.sort((a,b)=>{
const ia=GROUP_ORDER.indexOf(a), ib=GROUP_ORDER.indexOf(b);
return (ia===-1?999:ia)-(ib===-1?999:ib);
});
}
// 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.
// Liegt im gemeinsamen Modul, weil board/load/pdf auf sie zugreifen.
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;
}
// weekSortKey: liefert einen nach Jahr und Kalenderwoche sortierbaren Schlüssel,
// sodass z.B. "KW 9 / 2026" vor "KW 10 / 2026" kommt.
// Parameter: label (String). Rückgabe: String (Jahr-KW) oder kleingeschriebenes Label.
function weekSortKey(label){
const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i);
if(m){
const kw = m[1].padStart(2,'0');
return `${m[2]}-${kw}`;
}
return String(label || '').toLowerCase();
}
// Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und
// Zuordnungen auch nach einem Neuladen stabil bleiben.
// Parameter: name (String). Rückgabe: String wie 'p_ab'.
// beteiligteJePerson: fasst die Zuordnungen eines Projekts ({person, role,
// state}; state 1 = Haupt, 2 = Unterstützung) zu EINEM Eintrag je Person
// zusammen — wer Planung und Projektleitung macht, erscheint im
// Avatar-Stapel nur einmal (Eriks Wunsch). Die Farbe folgt der wichtigsten
// Rolle (Haupt vor Unterstützung, dann Reihenfolge ROLES), der Tooltip nennt
// alle. Rückgabe: [{person, role, state, title}] in der Reihenfolge des
// ersten Auftretens.
function beteiligteJePerson(eintraege){
const jePerson = new Map();
(eintraege || []).forEach(e => {
if(!e || !e.person || !(e.state > 0)) return;
const key = e.person.id;
if(!jePerson.has(key)) jePerson.set(key, { person: e.person, rollen: [] });
jePerson.get(key).rollen.push({ role: e.role, state: Number(e.state) });
});
const rang = r => (r.state === 1 ? 0 : 10) + Math.max(0, ROLES.indexOf(r.role));
return Array.from(jePerson.values()).map(({ person, rollen }) => {
const haupt = rollen.slice().sort((a, b) => rang(a) - rang(b))[0];
const liste = rollen.slice().sort((a, b) => ROLES.indexOf(a.role) - ROLES.indexOf(b.role))
.map(r => r.role + (r.state === 2 ? ' (Unterstützung)' : '')).join(', ');
const sortiert = rollen.slice().sort((a, b) => ROLES.indexOf(a.role) - ROLES.indexOf(b.role));
return { person, role: haupt.role, state: haupt.state, rollen: sortiert, title: (person.fullName || person.name) + ' · ' + liste };
});
}
// ROLLEN_FARBE: dieselben Farben wie .board-avatar.role-* in styles.css.
const ROLLEN_FARBE = { 'Planung': 'var(--green)', 'Projektleitung': 'var(--red)', 'Bauüberwachung': 'var(--accent)' };
// beteiligtenAvatarHtml: Kürzel-Kreis eines Eintrags aus beteiligteJePerson().
// Bei mehreren Rollen (Eriks Wunsch) ist der Kreis in Segmente geteilt — je
// Rolle eines in ihrer Farbe. Parameter: b, klasse
// (Rollen-Klasse, z. B. roleClass(b.role)). Rückgabe: HTML-String.
function beteiligtenAvatarHtml(b, klasse){
const rollen = (b.rollen || []).filter(r => ROLLEN_FARBE[r.role]);
if(rollen.length < 2){
return `<span class="board-avatar ${klasse} state-${b.state}" title="${esc(b.title)}">${esc(b.person.name)}</span>`;
}
const teil = 100 / rollen.length;
const stopps = rollen.map((r, i) => {
// Immer die volle Rollenfarbe — eine blassere Abstufung für
// Unterstützung wurde im Dunkelmodus zu einem eigenen, dunklen Ton.
// Ob Haupt oder Unterstützung, steht im Tooltip.
const farbe = ROLLEN_FARBE[r.role];
return `${farbe} ${(i * teil).toFixed(2)}% ${((i + 1) * teil).toFixed(2)}%`;
}).join(', ');
return `<span class="board-avatar ist-mehrfach" style="background-image: linear-gradient(to right, ${stopps})" title="${esc(b.title)}">${esc(b.person.name)}</span>`;
}
function personIdForName(name){
return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_');
}
// Schlägt aus einem Namen ein kurzes Kürzel vor (für schmale Board-Spalten-
// köpfe, 2-4 Zeichen). Bei mehreren durch Leerzeichen getrennten Wörtern die
// Anfangsbuchstaben der ersten vier Wörter, bei einem einzelnen Wort dessen
// erste drei Buchstaben. Nur ein Vorschlag — bleibt manuell überschreibbar
// (siehe personIdManuallyEdited-Muster in admin-users.js).
// Parameter: name (String). Rückgabe: String wie 'MM' oder ''.
function initialsForName(name){
const words = String(name || '').trim().split(/\s+/).filter(Boolean);
if(words.length === 0) return '';
if(words.length === 1) return words[0].slice(0, 3).toUpperCase();
return words.slice(0, 4).map(w => w[0]).join('').toUpperCase();
}
// EMAIL_DOMAIN: Domain der Firmen-Adressen, Grundlage von emailVorschlag().
const EMAIL_DOMAIN = 'rauschenberg-ing.de';
// emailVorschlag: schlägt aus einem Namen die Firmen-Adresse nach dem Schema
// „Anfangsbuchstabe Vorname . Nachname @rauschenberg-ing.de" vor, klein
// geschrieben, Umlaute umschrieben (ä→ae, ß→ss), übrige Sonderzeichen
// entfernt: "Miriam Körner" → "m.koerner@rauschenberg-ing.de". Vorname ist
// das erste, Nachname das letzte Wort. Nur ein Vorschlag — bleibt
// überschreibbar. Parameter: name (String) — oder, wenn `nachname` gegeben
// ist, Vorname und Nachname getrennt (dann zählt der ganze Nachname, z. B.
// "von Plum" → "vonplum"). Rückgabe: Adresse oder ''.
function emailVorschlag(name, nachname){
if(nachname !== undefined){
const v = String(name || '').trim(), n = String(nachname || '').trim();
if(!v || !n) return emailVorschlag((v + ' ' + n).trim());
const l = emailVorschlag(v.split(/\s+/)[0] + ' ' + n.replace(/\s+/g, ''));
return l;
}
const teil = s => String(s).toLowerCase()
.replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss')
.normalize('NFD').replace(/[\u0300-\u036f]/g,'')
.replace(/[^a-z0-9-]/g,'');
const woerter = String(name || '').trim().split(/\s+/).map(teil).filter(Boolean);
if(woerter.length === 0) return '';
const lokal = woerter.length === 1 ? woerter[0] : woerter[0][0] + '.' + woerter[woerter.length - 1];
return lokal + '@' + EMAIL_DOMAIN;
}
// benutzernameVorschlag: Benutzername eines neuen Kontos = Anfang der
// E-Mail-Adresse, also "Anfangsbuchstabe Vorname . Nachname" in der
// Schreibweise von emailVorschlag() ("Miriam Körner" → "m.koerner").
// Angemeldet wird mit genau diesem Namen. Rückgabe: String oder ''.
function benutzernameVorschlag(vorname, nachname){
const adresse = emailVorschlag(vorname, nachname);
return adresse ? adresse.slice(0, adresse.indexOf('@')) : '';
}
// grossAnfang: erster Buchstabe groß, Leerraum zusammengefasst — für Vor-
// und Nachnamen beim Speichern ("miriam" → "Miriam").
function grossAnfang(s){
s = String(s || '').trim().replace(/\s+/g, ' ');
return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
}
// formatWeekDate: wandelt ein ISO-Datum (YYYY-MM-DD) in deutsches Format DD.MM.YYYY um
// Parameter: dateString (String). Rückgabe: formatierter String oder leer.
function formatWeekDate(dateString){
if(!dateString) return '';
const parts = dateString.split('-');
if(parts.length!==3) return dateString;
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
// ---------- Datumshilfsfunktionen (GANTT) ----------
// parseDateISO: parst einen ISO-Datumsstring (YYYY-MM-DD) in ein Date-Objekt (lokal)
// Parameter: d (String). Rückgabe: Date-Objekt oder null bei ungültigem Format.
function parseDateISO(d){
if(!d) return null;
const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if(!m) return null;
const date = new Date(parseInt(m[1]), parseInt(m[2])-1, parseInt(m[3]));
if(isNaN(date.getTime())) return null;
return date;
}
// formatDateISO: formatiert ein Date-Objekt als ISO-Datum (YYYY-MM-DD)
// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum.
function formatDateISO(d){
if(!d || isNaN(d.getTime())) return '';
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const day = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
}
// addDays: addiert n Tage zu einem Datum (ohne das Original zu verändern)
// Parameter: d (Date), n (Anzahl Tage). Rückgabe: neues Date-Objekt.
function addDays(d, n){
const res = new Date(d.getTime());
res.setDate(res.getDate() + n);
return res;
}
// heuteIso: das heutige Datum als ISO-Zeichenkette (YYYY-MM-TT) in ORTSZEIT.
//
// Nicht new Date().toISOString().slice(0,10): Das rechnet nach UTC um. In
// deutscher Sommerzeit (UTC+2) ist damit zwischen 00:00 und 02:00 Ortszeit
// noch der Vortag gemeint — ein Termin von heute galt in diesem Zeitfenster
// als überfällig. Rückgabe: String.
function heuteIso(){
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, '0');
const t = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '-' + m + '-' + t;
}
// isoPlusTage: Datum in n Tagen als ISO-Zeichenkette in Ortszeit (siehe
// heuteIso zur Begründung). Parameter: n (ganze Zahl, auch negativ).
// Rückgabe: String.
function isoPlusTage(n){
const d = new Date();
d.setDate(d.getDate() + (Number(n) || 0));
const m = String(d.getMonth() + 1).padStart(2, '0');
const t = String(d.getDate()).padStart(2, '0');
return d.getFullYear() + '-' + m + '-' + t;
}
// daysBetween: Anzahl der Kalendertage zwischen zwei Daten
// Parameter: a, b (Date). Rückgabe: ganzzahlige Tagesdifferenz (b - a).
function daysBetween(a, b){
const ms = b.getTime() - a.getTime();
return Math.round(ms / (1000 * 60 * 60 * 24));
}
// isWorkday: prüft, ob ein Datum ein Arbeitstag ist — kein Wochenende und
// kein gepflegter Feiertag (HOLIDAYS in js/globals.js, gefüllt aus
// GET /holidays). Vor dem Ausbau der Einstellungen kannte die Anwendung nur
// Wochenenden; ein Terminplan über Weihnachten fiel damit um mehrere Tage zu
// kurz aus. Parameter: d (Date). Rückgabe: Boolean.
function isWorkday(d){
if(!d) return false;
const day = d.getDay();
if(day === 0 || day === 6) return false;
return !(typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(formatDateISO(d)));
}
// workdaysBetween: Anzahl der Arbeitstage im Zeitraum inkl. Start- und Endtag.
// Wochenenden und Feiertage werden nicht mitgezählt – z.B. für die Auslastung.
// Parameter: a, b (Date). Rückgabe: Anzahl der Arbeitstage oder 0 bei ungültigem Zeitraum.
function workdaysBetween(a, b){
if(!a || !b || b < a) return 0;
let count = 0;
let current = new Date(a.getTime());
while(current <= b){
if(isWorkday(current)) count++;
current = addDays(current, 1);
}
return count;
}
// addWorkdays: liefert das Datum, das `n` Arbeitstage nach `d` liegt.
// n = 0 ergibt `d` selbst, sofern das ein Arbeitstag ist — fällt `d` auf ein
// Wochenende oder einen Feiertag, wird auf den nächsten Arbeitstag vorgerückt.
// Gegenstück zu workdaysBetween(); gebraucht von der Honorar-Terminplanung
// (js/plan/plan-honorar.js), die Phasen nahtlos in Arbeitstagen aneinanderreiht.
// Die Obergrenze der Schleifen ist eine Reißleine: Wären versehentlich alle
// Tage als Feiertag gepflegt, liefe sie sonst endlos.
// Parameter: d (Date), n (Anzahl Arbeitstage, >= 0). Rückgabe: Date.
function addWorkdays(d, n){
if(!d) return null;
let current = new Date(d.getTime());
let guard = 0;
// Startdatum selbst auf einen Arbeitstag schieben
while(!isWorkday(current) && guard++ < 400) current = addDays(current, 1);
let remaining = Math.max(0, Math.floor(n));
guard = 0;
while(remaining > 0 && guard++ < 100000){
current = addDays(current, 1);
if(isWorkday(current)) remaining--;
}
return current;
}
// formatDateDE: formatiert ein Date-Objekt als deutsches Datum (DD.MM.YYYY)
// Parameter: d (Date). Rückgabe: String oder leer bei ungültigem Datum.
function formatDateDE(d){
if(!d || isNaN(d.getTime())) return '';
return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
}
// ISO-Kalenderwoche nach https://en.wikipedia.org/wiki/ISO_week_date
// Parameter: d (Date). Rückgabe: Kalenderwochennummer (1-53).
function getWeekNumber(d){
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
}
// ---------- Status-Editor (Board & Projektplan) ----------
// Der contenteditable Status-/Kommentar-Editor (kleine Formatierungs-Toolbar:
// Fett, Farben, Absätze) wird sowohl im Projektboard (js/board/board.js,
// Detailpanel) als auch auf der Projektplan-Seite (js/plan/plan-info.js)
// verwendet — deshalb liegen die reinen Hilfsfunktionen hier im gemeinsamen
// Modul. Nur der
// Plan-Gegenpart (planStatusBlur() in plan-info.js) bleiben modul-eigen, da
// sie unterschiedliche Speicherpfade ansteuern.
// 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 (nur vom
// Board genutzt, siehe statusBlur() dort); auf der Projektplan-Seite bleibt
// sie ungenutzt (dort gibt es nur einen Status-Editor pro Seite).
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;
}
// ---------- 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.
function statusValueFromHtml(html){
const tmp = document.createElement('div');
tmp.innerHTML = html;
const text = (tmp.textContent || tmp.innerText || '').replace(/ /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);
}
// 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).
//
// DOMParser statt `div.innerHTML = html` — das ist der GANZE Sicherheitsgewinn
// dieser Funktion, nicht bloß Geschmackssache: innerHTML lädt eingebettete
// Ressourcen und führt deren Fehler-Handler aus, AUCH wenn das Element gar
// nicht im Dokument hängt. Ein `<img src=x onerror=…>` lief damit schon
// WÄHREND des Bereinigens — also auf jedem Anzeigepfad, der hier
// durchkommt, obwohl das Bild danach korrekt entfernt wurde. Der Server
// speichert Kommentare und Protokolltexte unverändert, ein angemeldetes
// Konto konnte solches HTML also über die API hinterlegen und damit Code in
// 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
// Knoten vorher in ein Element des Hauptdokuments übernommen, zöge
// appendChild() ein adoptNode() nach sich — und ein dabei übernommenes
// <img src=…> begänne wieder zu laden, also genau das, was hier
// verhindert werden soll.
const inert = new DOMParser().parseFromString(String(html), 'text/html');
const div = inert.body;
const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
const allowedAttrs = new Set(['style']);
// Elemente, deren INHALT mit verschwindet, statt ausgepackt zu werden:
// Bei <script> wäre der ausgepackte Rest sonst der sichtbare Quelltext.
const dropWithContent = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','IFRAME','OBJECT','EMBED']);
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){
const tag = child.tagName.toUpperCase();
if(dropWithContent.has(tag)){
node.removeChild(child);
continue;
}
if(!allowedTags.has(tag)){
// Referenzknoten ist `child` (ein Kind von `node`) — mit `node`
// selbst wirft insertBefore laut DOM-Spezifikation NotFoundError,
// und schon ein eingefügter Link hätte das ganze Rendern abgebrochen.
let ausgepackt = 0;
while(child.firstChild){ node.insertBefore(child.firstChild, child); ausgepackt++; }
node.removeChild(child);
// Die ausgepackten Knoten stehen jetzt an den Positionen i … i+n-1.
// Ohne diese Korrektur liefe die absteigende Schleife an ihnen
// vorbei — aus einer eingefügten Liste blieben dann die <li>-Tags
// und aus einer Tabelle <tbody>/<tr>/<td> stehen, obwohl die
// Positivliste beides ausschließt.
i += ausgepackt;
continue;
}
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')){
// 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;
}
// statusTextMitZeilen: wandelt Kommentar-HTML in reinen Text und erhält dabei
// die Zeilenumbrüche (<br> und Blockgrenzen werden zu \n). Gegenstück zu
// statusPlainText() weiter unten, das Umbrüche bewusst zu Leerzeichen
// einebnet — hier werden sie gebraucht, weil der einzige Aufrufer die ERSTE
// ZEILE will: openTodoAusKommentar() (js/wochenbericht/wochenbericht.js)
// schlägt daraus den Titel einer neuen Aufgabe vor.
//
// Hieß bis zur Umstellung auf contenteditable statusHtmlToEditText() und gab
// Fett als **Sternchen**, Kursiv als *Sternchen* und Farbe als
// [[c:…]]…[[/c]] zurück — die Rückrichtung zum damaligen Markup-Editor.
// Seit der Editor echtes HTML führt (richtextWickeln() unten), hat dieses
// Markup keinen Leser mehr; übrig blieben Sternchen, die unvermittelt im
// vorgeschlagenen Aufgabentitel standen, obwohl sie nie jemand getippt hat.
// Deshalb liefert die Funktion jetzt schlichten Text.
// Parameter: html (String). Rückgabe: String.
function statusTextMitZeilen(html){
if(!html) return '';
const div = document.createElement('div');
div.innerHTML = sanitizeStatusHtml(html);
// Block-Grenzen zu Zeilenumbrüchen; alle Auszeichnungen fallen durch
// textContent ohnehin weg.
div.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode('\n')));
div.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode('\n')));
return (div.textContent || '')
.replace(/ /g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
// ---------- Formatierbare Textfelder: contenteditable statt Markup-Zeichen ----------
//
// Erster Anlauf (Markup-Zeichen **fett**/*kursiv*/[[c:…]] in einem normalen
// <textarea>, inzwischen vollständig entfernt) zeigte während des
// Tippens sichtbare Stern-/Klammer-Zeichen (**fett**, [[c:…]]) statt echter
// Formatierung — Eriks Rückmeldung: "statt den zwei Sternchen... den Text
// richtig fett machen". Diese Felder sind jetzt contenteditable statt
// <textarea>, und richtextWickeln()/richtextFett()/richtextKursiv()/
// richtextFarbe() wenden die Auszeichnung direkt auf die Markierung an — über
// die Selection/Range-API (range.extractContents()/insertNode()), bewusst
// KEIN document.execCommand (abgekündigt, derselbe Grund, der die vorige
// contenteditable-Fassung dieser Felder schon einmal verworfen hatte — nur
// diesmal ohne execCommand). Der Nutzer sieht dadurch sofort echten fetten/
// kursiven/farbigen Text, keine Markup-Zeichen mehr.
// richtextToolbarHtml() liefert die dazugehörige, überall wiederverwendbare
// Werkzeugleiste; sie erwartet, dass das Zielfeld im selben .richtext-wrap
// steht (per closest()+querySelector() gefunden), damit mehrere Instanzen
// auf einer Seite (z. B. je Protokoll-Punkt) einander nicht in die Quere
// kommen. onmousedown mit preventDefault() verhindert, dass der Knopf-Klick
// dem Feld vorher den Fokus (und damit die Markierung) stiehlt.
function richtextWickeln(feld, tagName, styleAttr){
if(!feld) return;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
if(range.collapsed || !feld.contains(range.commonAncestorContainer)) return;
const wrapper = document.createElement(tagName);
if(styleAttr) wrapper.setAttribute('style', styleAttr);
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
// Markierung auf den neu eingefügten Inhalt setzen — ein zweiter Klick auf
// eine andere Auszeichnung wirkt dadurch sofort auf denselben Text weiter.
sel.removeAllRanges();
const neueRange = document.createRange();
neueRange.selectNodeContents(wrapper);
sel.addRange(neueRange);
feld.dispatchEvent(new Event('input', { bubbles: true }));
}
// ---------- 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
// <textarea>. html MUSS bereits sanitizeStatusHtml()-bereinigt sein (niemals
// escapter Klartext — der Inhalt soll als Markup interpretiert werden, nicht
// als Zeichenkette). extraAttrs hängt aufruferspezifische Event-Handler an.
// editable=false (gesperrte Sitzung) setzt contenteditable="false" statt das
// Feld ganz durch reinen Text zu ersetzen — Auswahl/Kopieren bleibt möglich.
// Parameter: cssClass, html, placeholder, extraAttrs (Strings), editable
// (Boolean, optional, Default true). Rückgabe: HTML-String.
function richtextFeldHtml(cssClass, html, placeholder, extraAttrs, editable){
const bearbeitbar = editable === false ? 'false' : 'true';
return `<div class="${cssClass}" contenteditable="${bearbeitbar}" data-placeholder="${esc(placeholder || '')}" ${extraAttrs || ''}>${html || ''}</div>`;
}
// richtextCursorAnsEnde: setzt den Fokus in ein contenteditable-Feld und den
// Cursor ans Textende — das Gegenstück zu textarea.setSelectionRange(), das
// es für contenteditable nicht gibt. Parameter: feld (Element). Keine
// Rückgabe.
function richtextCursorAnsEnde(feld){
if(!feld) return;
feld.focus();
const range = document.createRange();
range.selectNodeContents(feld);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
// Einfügen bereinigen: Ohne diesen Lauscher übernimmt ein contenteditable-Feld
// beim Einfügen aus Word, einer Webseite oder einer Mail deren vollständige
// Formatierung — Schriftarten, Größen, Tabellen, Bilder. Sichtbar blieb das
// bis zum Speichern; erst dort lief sanitizeStatusHtml() und der Text sprang
// unvermittelt in eine andere Gestalt. Jetzt wird schon beim Einfügen auf
// dieselbe Positivliste reduziert, die auch gespeichert wird — was man sieht,
// ist, was bleibt.
//
// EIN Lauscher am Dokument für alle Felder (gleiche Begründung wie beim
// selectionchange-Lauscher in js/plan/plan-protokoll.js): Die Felder werden
// beim Rendern ständig neu erzeugt, ein Lauscher je Feld müsste bei jedem
// Neuaufbau erneut gesetzt werden.
document.addEventListener('paste', function(e){
const feld = e.target && e.target.closest ? e.target.closest('[contenteditable="true"]') : null;
if(!feld || !e.clipboardData) return;
e.preventDefault();
// text/html nur, wenn vorhanden — reiner Text wird escaped eingesetzt,
// damit darin enthaltene spitze Klammern Zeichen bleiben und nicht als
// Markup ankommen.
const html = e.clipboardData.getData('text/html');
const sauber = html
? sanitizeStatusHtml(html)
: esc(e.clipboardData.getData('text/plain')).replace(/\r\n|\r|\n/g, '<br>');
if(!sauber) return;
const sel = window.getSelection();
if(!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
range.deleteContents();
// Über ein <template> in Knoten verwandeln: dessen Inhalt liegt in einem
// eigenen, inerten Dokumentfragment. `sauber` ist zwar bereits geprüft,
// aber so bleibt auch dieser Schritt frei von Ressourcen-Ladevorgängen.
const vorlage = document.createElement('template');
vorlage.innerHTML = sauber;
const fragment = vorlage.content;
const letzter = fragment.lastChild;
range.insertNode(fragment);
// Schreibmarke hinter das Eingefügte setzen, sonst stünde sie davor.
if(letzter){
const nach = document.createRange();
nach.setStartAfter(letzter);
nach.collapse(true);
sel.removeAllRanges();
sel.addRange(nach);
}
feld.dispatchEvent(new Event('input', { bubbles: true }));
});
// Rückmeldung der Formatierungsleiste: Fett/Kursiv/Farbe wirken nur auf
// MARKIERTEN Text — ohne Markierung passiert beim Klick nichts, und zwar ohne
// jeden Hinweis warum. Die Leiste hebt sich deshalb sichtbar hervor, sobald im
// zugehörigen Feld etwas markiert ist, und trägt daneben einen Hinweis, der
// von "Erst Text markieren" auf die Bestätigung umspringt.
//
// EIN dokumentweiter Lauscher für JEDES Vorkommen von .richtext-wrap, nicht
// nur für den Protokoll-Schreibbereich (wo diese Funktion ursprünglich nur
// dort lebte): der Board-Kommentar nutzt dieselbe Leiste
// (richtextToolbarHtml(), renderCommentEditor() in js/board/board.js), zeigte
// die Markierung aber nie an — die Leiste blieb dort dauerhaft blass, egal ob
// Text markiert war. Anders als im Protokoll (immer höchstens EIN
// Schreibbereich) können im Projektboard mehrere Zeilen gleichzeitig
// aufgeklappt sein, jede mit eigenem Kommentarfeld — der Lauscher geht
// deshalb jedes .richtext-wrap einzeln durch statt nur eines anzunehmen.
document.addEventListener('selectionchange', function(){
const sel = window.getSelection();
const bereich = (sel && sel.rangeCount > 0 && !sel.isCollapsed) ? sel.getRangeAt(0) : null;
document.querySelectorAll('.richtext-wrap').forEach(wrap => {
const leiste = wrap.querySelector('.richtext-toolbar');
const feld = wrap.querySelector('[contenteditable="true"]');
if(!leiste || !feld) return;
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 · erneut klicken hebt auf';
});
});
// RICHTEXT_FARBEN: die Palette der Farbknöpfe — bewusst dieselben Werte wie
// --red/--green/--accent/--amber (styles.css), damit eine im Text gewählte
// Farbe dieselbe Bedeutung trägt wie anderswo in der Anwendung (Rot = Achtung,
// Grün = erledigt/positiv, Blau = Hinweis, Orange = offen/Warnung).
const RICHTEXT_FARBEN = [
{ farbe: '#d70015', titel: 'Rot' },
{ farbe: '#248a3d', titel: 'Grün' },
{ farbe: '#0071e3', titel: 'Blau' },
{ farbe: '#b25000', titel: 'Orange' }
];
// richtextToolbarHtml: liefert die Werkzeugleiste (Fett/Kursiv/Farben) für ein
// contenteditable-Feld (richtextFeldHtml()), das im selben .richtext-wrap-
// Container steht. targetSelector wählt das Zielfeld innerhalb dieses
// Containers aus — jeder Aufrufer übergibt seine eigene Feldklasse (z. B.
// '.comment-field'), der Default greift nur, falls das je vergessen wird.
// Parameter: targetSelector (String, optional). Rückgabe: HTML-String.
function richtextToolbarHtml(targetSelector){
const sel = targetSelector || '[contenteditable]';
const ziel = `this.closest('.richtext-wrap').querySelector('${sel}')`;
const farbButtons = RICHTEXT_FARBEN.map(f =>
`<button type="button" class="richtext-farbe-btn" style="background:${f.farbe}" title="${f.titel}"
onmousedown="event.preventDefault()" onclick="richtextFarbe(${ziel}, '${f.farbe}')"></button>`).join('');
// .richtext-hinweis: Fett/Kursiv/Farbe wirken nur auf MARKIERTEN Text —
// ohne Markierung tut ein Klick nichts, und das war bisher nirgends
// ablesbar. Der Hinweis steht deshalb fest in der Leiste; wer ihn
// aktualisieren will, wenn tatsächlich etwas markiert ist, hängt sich an
// selectionchange (so macht es der Protokoll-Schreibbereich,
// js/plan/plan-protokoll.js).
return `<div class="richtext-toolbar">
<span class="richtext-btn-group">
<button type="button" class="richtext-btn" title="Fett" onmousedown="event.preventDefault()" onclick="richtextFett(${ziel})"><b>F</b></button>
<button type="button" class="richtext-btn" title="Kursiv" onmousedown="event.preventDefault()" onclick="richtextKursiv(${ziel})"><i>K</i></button>
</span>
<span class="richtext-divider"></span>
<span class="richtext-farben">${farbButtons}</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>`;
}
// -------- Projekt-Verlauf über mehrere Sitzungen --------
// Diese drei Helfer lagen ursprünglich in js/plan/plan-history.js und damit
// nur auf der Projektplan-Seite. Seit der Kommentar-Editor im Board den
// Verlauf ebenfalls anzeigt (renderBoardDrawer(), js/board/board.js), stehen
// sie hier im gemeinsamen Modul — sonst gäbe es zwei Implementierungen
// derselben Logik, die auseinanderlaufen können. plan-history.js greift
// weiterhin darauf zu.
//
// Kein Server-Endpunkt nötig: GET /load-all liefert ohnehin ALLE Sitzungen
// mit allen Projekten nach db.weeks. Zusammengeführt wird über projectUid
// (die dauerhafte, wochenunabhängige Projekt-Identität aus
// migrateAddProjectUid() in server.js) — project.id taugt dafür nicht, weil
// cloneWeek() sie bei jeder Kopie neu vergibt.
// projectVersionsForUid: alle Fassungen eines Projekts über alle geladenen
// Sitzungen, chronologisch aufsteigend (weekSortKey, also nach Kalenderwoche
// und nicht alphabetisch). Projekte aus Altdaten ohne projectUid liefern eine
// leere Liste — ein Fallback über nr/name wäre bei wiederverwendeten
// Projektnummern stillschweigend falsch. Parameter: uid (String).
// Rückgabe: Array von { week, project }.
function projectVersionsForUid(uid){
if(!uid) return [];
return (db.weeks || [])
.map(w => ({ week: w, project: (w.projects || []).find(p => p.projectUid === uid) }))
.filter(entry => !!entry.project)
.sort((a, b) => weekSortKey(a.week.label).localeCompare(weekSortKey(b.week.label)));
}
// statusPlainText: löst einen (HTML-formatierten) Kommentartext in reinen
// Text auf — für Vergleich und Kurzvorschau. Parameter: html (String).
// Rückgabe: String (leer, wenn kein Text vorhanden).
function statusPlainText(html){
const tmp = document.createElement('div');
tmp.innerHTML = sanitizeStatusHtml(html || '');
// wie in statusPreviewText(): Zeilenumbrüche zu Leerzeichen, sonst würden
// zwei Absätze beim Vergleich zu einem zusammengezogenen Wort
tmp.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode(' ')));
tmp.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode(' ')));
return (tmp.textContent || '').replace(/\s+/g, ' ').trim();
}
// projectStatusHistory: die Verlaufsliste eines Projekts — je Sitzung ein
// Eintrag, aber NUR für Sitzungen, in denen sich der Kommentartext gegenüber
// der vorherigen Fassung tatsächlich geändert hat. Ohne diese Filterung
// stünde derselbe Text mehrfach in der Liste: Sitzungen, die vor der
// Umstellung auf leere Kommentare (siehe cloneWeek(), js/weeks.js) dupliziert
// 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, 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 k = kommentarVon(v.project, gewerk);
const text = statusPlainText(k.status);
const entry = {
week: v.week,
text,
html: sanitizeStatusHtml(k.status || ''),
author: k.statusAuthor || '',
at: k.statusAt || '',
isFirst: idx === 0
};
if(idx === 0){
if(text) entries.push(entry);
} else if(text && text !== lastText){
entries.push(entry);
}
lastText = text;
});
return entries.reverse();
}
// getProgressColor: erzeugt eine HSL-Farbe basierend auf Prozentwert
// (grün → gelb → rot). Parameter `pct`: Zahl 0–100 (wird geklemmt). Genutzt
// für die LPH-Fortschrittsbalken im Board und auf der Projektplan-Seite.
// 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%)`;
}
// ---------- Verlauf eines Projekts über die Sitzungen ----------
//
// Jede Sitzung hält eine eigene Fassung jedes Projekts fest — eine "Kachel"
// je Woche. projectVersionsForUid() (weiter oben) liefert sie chronologisch.
// Der Verlauf macht damit nur sichtbar, was ohnehin gespeichert ist.
//
// Diese Darstellung steht hier in js/utils.js und nicht bei einem einzelnen
// Bereich, weil sie an DREI Stellen gebraucht wird: im Wochenbericht unter
// der Projektkarte, im Projektboard und im Verlaufs-Dialog des
// Projektbereichs. Vorher hatte jede dieser Stellen ihre eigene Fassung —
// der Aufklapper "Frühere Sitzungen" zeigte nur den Kommentartext und auch
// den nur bei tatsächlicher Änderung, der Dialog im Projektbereich zeigte
// eine dritte Auswahl. Die drei konnten sich damit widersprechen, und
// niemand wusste, welche die vollständige war.
//
// Gezeigt wird die ganze Kachel: Kommentar, Leistungsphasen, die Termine aus
// dem Projektplan und die Beteiligten JENER Woche.
// timelineLphText: die Leistungsphasen-Stände einer Projektfassung als
// kompakte Liste. Gezeigt werden nur Phasen mit einem Wert — eine Kachel mit
// zehn leeren Zeilen trägt nichts bei. Wie im übrigen Bericht zählen die
// beauftragten Phasen, sofern welche hinterlegt sind.
// Parameter: p (Projektfassung). Rückgabe: Array von { lph, wert }.
function verlaufLphWerte(p){
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const phasen = beauftragt.length > 0 ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
const lph = p.lph || {};
return phasen
.filter(l => lph[l] !== undefined && lph[l] !== '')
.map(l => ({ lph: l, wert: String(lph[l]) }));
}
// verlaufTermine: die Termine aus dem Projektplan einer Fassung — das
// späteste Datum im Plan (Projektende) und der nächste Meilenstein. Daran
// lässt sich über die Kacheln hinweg ablesen, ob sich Termine verschoben
// haben. Parameter: p (Projektfassung). Rückgabe: { ende, meilenstein } mit
// Strings (leer, wenn der Plan nichts hergibt).
function verlaufTermine(p){
const plan = p.plan || {};
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const daten = [];
todos.forEach(t => { const d = parseDateISO(t.end); if(d) daten.push(d); });
milestones.forEach(m => { const d = parseDateISO(m.date); if(d) daten.push(d); });
const ende = daten.length ? new Date(Math.max.apply(null, daten)) : null;
// Nächster Meilenstein: der früheste, der zum Zeitpunkt JENER Sitzung noch
// bevorstand. Ohne Sitzungsdatum (Altbestand) der früheste überhaupt.
const mitDatum = milestones
.map(m => ({ name: m.name || 'Meilenstein', datum: parseDateISO(m.date) }))
.filter(m => !!m.datum)
.sort((a, b) => a.datum - b.datum);
const naechster = mitDatum[0] || null;
return {
ende: ende ? formatDateDE(ende) : '',
meilenstein: naechster ? (naechster.name + ' · ' + formatDateDE(naechster.datum)) : '',
};
}
// verlaufBeteiligte: die Kürzel der Personen, die dieser Fassung zugeordnet
// waren. Gelesen wird der Teilnehmerkreis JENER Sitzung, nicht der heutige —
// sonst stünden in einer alten Kachel Personen, die damals gar nicht dabei
// waren. Parameter: version ({week, project}). Rückgabe: Array von Kürzeln.
function verlaufBeteiligte(version){
const p = version.project;
const personen = Array.isArray(version.week.people) ? version.week.people : [];
const namen = [];
personen.forEach(person => {
const rollen = Array.isArray(person.role) ? person.role : [person.role];
const zugeordnet = rollen.some(r => (p.assign || {})[assignKey(person.id, r)] > 0);
if(zugeordnet && namen.indexOf(person.name) === -1) namen.push(person.name);
});
return namen;
}
// renderProjektVerlauf: der aufklappbare Verlauf unter einer Projektkarte.
// Zeigt je früherer Sitzung eine Kachel, neueste zuerst. Geänderte Werte sind
// gegenüber der jeweils DAVOR liegenden Kachel dezent hervorgehoben — die
// Kacheln stehen zwar nebeneinander, aber bei zehn Leistungsphasen findet man
// die eine geänderte Zahl sonst nicht.
// Parameter: p (Projekt), weekLabel (Sitzung, deren Fassung als "aktuell"
// gilt und deshalb NICHT als Kachel erscheint), locked. Rückgabe: HTML.
// verlaufOffen: project_uids, deren Verlauf aufgeklappt ist — übersteht das
// Neuzeichnen nach „Weitere laden“ (sonst klappte er dabei wieder zu).
const verlaufOffen = new Set();
function verlaufUmschalten(uid, offen){ if(offen) verlaufOffen.add(uid); else verlaufOffen.delete(uid); }
function renderProjektVerlauf(p, weekLabel, locked){
const versionen = projectVersionsForUid(p.projectUid);
// Gezeigt wird, was geladen ist (anfangs die drei Sitzungen vor der
// aktuellen); „Weitere laden“ holt je fünf ältere (js/store.js).
const weitere = typeof weitereLadenKnopf === 'function' ? weitereLadenKnopf() : '';
const uidAttr = esc(String(p.projectUid || '')).replace(/'/g, '&#39;');
const offen = verlaufOffen.has(p.projectUid) ? ' open' : '';
const umschalten = ` ontoggle="verlaufUmschalten('${uidAttr}', this.open)"`;
// Neueste zuerst, damit links die jüngste Vergangenheit steht.
//
// Ohne weekLabel werden ALLE Sitzungen gezeigt: Der Verlaufs-Dialog im
// Projektbereich hat keine "aktuelle" Fassung, die auszulassen wäre. Mit
// weekLabel bleibt die genannte Sitzung außen vor — sie steht dort als
// Karte bzw. Eingabemaske ohnehin darüber.
const idx = weekLabel ? versionen.findIndex(v => v.week.label === weekLabel) : -1;
const frueher = (weekLabel ? (idx > 0 ? versionen.slice(0, idx) : []) : versionen.slice()).reverse();
if(frueher.length === 0){
return `<details class="timeline-block"${offen}${umschalten}>
<summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span>
<span>Verlauf</span>
<span class="comment-history-count zero">0</span>
</summary>
<div class="timeline-body"><span class="hint" style="margin:0">${weitere
? 'In den geladenen Sitzungen steht dieses Projekt noch nicht.'
: 'Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.'}</span>${weitere}</div>
</details>`;
}
const kacheln = frueher.map((v, i) => {
// Vergleichsfassung ist die zeitlich DAVOR liegende, also der nächste
// Eintrag in dieser (absteigend sortierten) Liste.
const davor = frueher[i + 1] || null;
const werte = verlaufLphWerte(v.project);
const werteDavor = davor ? verlaufLphWerte(davor.project) : [];
const wertVon = l => { const t = werteDavor.find(x => x.lph === l); return t ? t.wert : null; };
const lphHtml = werte.length === 0
? '<div class="timeline-leer">keine Angabe</div>'
: werte.map(w => {
const alt = wertVon(w.lph);
const geaendert = davor && alt !== null && alt !== w.wert;
return `<div class="timeline-lph${geaendert ? ' is-changed' : ''}">
<span>${esc(w.lph)}</span><b>${esc(w.wert)} %</b>
</div>`;
}).join('');
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);
const zeilen = [];
if(termine.meilenstein){
const geaendert = davor && termineDavor.meilenstein && termineDavor.meilenstein !== termine.meilenstein;
zeilen.push(`<div class="timeline-termin${geaendert ? ' is-changed' : ''}">↦ ${esc(termine.meilenstein)}</div>`);
}
if(termine.ende){
const geaendert = davor && termineDavor.ende && termineDavor.ende !== termine.ende;
zeilen.push(`<div class="timeline-termin${geaendert ? ' is-changed' : ''}">Ende ${esc(termine.ende)}</div>`);
}
// "übernehmen" schreibt den Kommentar jener Woche ins Textfeld der
// aktuellen Sitzung. Diese Funktion lag bis zum Verdichten der Karte im
// Aufklapper "Frühere Sitzungen"; der ist entfallen, weil er dieselben
// Kommentare noch einmal zeigte. onmousedown + preventDefault wie dort:
// Sonst verliert das Textfeld zuerst den Fokus, das Blur-Netz speichert
// den Zwischenstand, und die Übernahme liefe gegen einen bereits
// geschriebenen Text.
// Ohne weekLabel gibt es keine Sitzung, in die übernommen werden könnte
// (Verlaufs-Dialog) — dann entfällt der Knopf, statt ins Leere zu greifen.
const uebernehmen = (locked || !text || !weekLabel) ? '' :
`<button type="button" class="timeline-take"
onmousedown="event.preventDefault();takeCommentFromHistory(this,'${esc(v.week.label)}')"
title="Diesen Kommentar in die aktuelle Sitzung übernehmen">übernehmen</button>`;
return `<article class="timeline-card">
<header class="timeline-card-head">
<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 ? html : 'Kein Kommentar'}</div>
${uebernehmen}
<div class="timeline-lph-list">${lphHtml}</div>
${zeilen.length ? `<div class="timeline-termine">${zeilen.join('')}</div>` : ''}
<footer class="timeline-people">${
beteiligte.length
? beteiligte.map(n => `<span class="timeline-chip">${esc(n)}</span>`).join('')
: '<span class="timeline-leer">keine Zuordnung</span>'
}</footer>
</article>`;
}).join('');
return `<details class="timeline-block"${offen}${umschalten}>
<summary onmousedown="event.preventDefault()">
<span class="comment-history-caret">▶</span>
<span>Verlauf</span>
<span class="comment-history-count">${frueher.length}</span>
</summary>
<div class="timeline-body">
<div class="timeline-rail">${kacheln}</div>
${weitere ? `<div class="verlauf-weitere-zeile">${weitere}</div>` : ''}
</div>
</details>`;
}