- Kommentare mehrerer Gewerke mit Überschrift und Linie: Gewerk in seiner Farbe, rechts Verfasser und Zeit, darunter der Text (Projekt-Dashboard, Projektliste, Wandtafel, Verlauf-Kacheln) - Behoben: Im Projekt-Dashboard erschienen Kommentare von Gewerken, die das Projekt nicht mehr hat (z. B. HLS bei 21-4030); Anzeige und Verlauf zeigen nur noch Gewerke des Projekts - Sitzungswechsel übernimmt nur noch Kommentare der Gewerke des Projekts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1335 lines
65 KiB
JavaScript
1335 lines
65 KiB
JavaScript
// -----------------------------
|
||
// 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"').replace(/'/g,''');
|
||
}
|
||
|
||
// 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 }.
|
||
// kommentareDesProjekts: die Kommentare nur der Gewerke, die das Projekt hat.
|
||
// Ein Kommentar eines Gewerks, das dem Projekt nicht (mehr) gehört — etwa aus
|
||
// einer Zeit, als es noch HLS hatte —, gehört nicht in die Gesamtanzeige
|
||
// (Eriks Meldung zu 21-4030). Ohne gepflegte Gewerke: alle. Rückgabe: Objekt.
|
||
function kommentareDesProjekts(p){
|
||
const alle = (p && p.kommentare) || {};
|
||
const gewerke = Array.isArray(p && p.gewerke) ? p.gewerke : [];
|
||
if(!gewerke.length) return alle;
|
||
const aus = {};
|
||
Object.keys(alle).forEach(k => { if(gewerke.includes(k)) aus[k] = alle[k]; });
|
||
return aus;
|
||
}
|
||
|
||
function kommentarVon(p, g){
|
||
const alle = g ? ((p && p.kommentare) || {}) : kommentareDesProjekts(p);
|
||
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 des Projekts. Bei
|
||
// mehreren Gewerken Darstellung „B – Überschrift mit Linie“ (Eriks Wahl):
|
||
// je Gewerk eine Kopfzeile mit dem Gewerk in seiner Farbe, einer feinen Linie
|
||
// und (mitMeta) Verfasser und Zeit, darunter der Text — Systemtext, nicht
|
||
// Teil des Kommentars (anders als die fette "ELT:"-Zeile, die kommentarVon()
|
||
// für Klartext-Vorschauen einfügt). Parameter: p, g, mitMeta.
|
||
// Rückgabe: HTML ('' ohne Text).
|
||
function kommentarAnzeigeHtml(p, g, mitMeta){
|
||
if(g) return sanitizeStatusHtml(kommentarVon(p, g).status || '');
|
||
const alle = kommentareDesProjekts(p);
|
||
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 '<div class="kommentar-gewerke">' + mitText.map(k => {
|
||
const e = alle[k];
|
||
const zeit = e.statusAt && typeof formatCommentStamp === 'function' ? formatCommentStamp(e.statusAt) : '';
|
||
const meta = mitMeta ? [e.statusAuthor, zeit].filter(Boolean).join(' · ') : '';
|
||
return '<div class="kommentar-block">'
|
||
+ '<div class="kommentar-kopf"><span class="kommentar-gewerk gewerk-' + esc(k.toLowerCase()) + '">' + esc(k) + '</span>'
|
||
+ '<span class="kommentar-linie"></span>' + (meta ? '<span class="kommentar-meta">' + esc(meta) + '</span>' : '') + '</div>'
|
||
+ '<div class="kommentar-text">' + sanitizeStatusHtml(e.status) + '</div></div>';
|
||
}).join('') + '</div>';
|
||
}
|
||
|
||
// kommentarGewerkeMitText: wie viele Gewerke des Projekts einen Kommentar
|
||
// haben (für die Frage, ob eine gemeinsame Verfasserzeile Sinn ergibt).
|
||
function kommentarGewerkeMitText(p){
|
||
const alle = kommentareDesProjekts(p);
|
||
return Object.keys(alle).filter(k => alle[k] && statusPlainText(alle[k].status) !== '').length;
|
||
}
|
||
|
||
// 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, ''');
|
||
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>`;
|
||
}
|