643 lines
30 KiB
JavaScript
643 lines
30 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/csv.js, 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 auch csv.js (weekToCsv für savePlan auf der
|
||
// Projektplan-Seite) und pdf.js darauf 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;
|
||
}
|
||
|
||
// 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'.
|
||
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();
|
||
}
|
||
|
||
// 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 statusBlur() (board.js, ruft editProj() auf) bzw. 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;
|
||
}
|
||
|
||
// 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).
|
||
function sanitizeStatusHtml(html){
|
||
if(!html) return '';
|
||
const div = document.createElement('div');
|
||
div.innerHTML = html;
|
||
const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']);
|
||
const allowedAttrs = new Set(['style']);
|
||
// 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;
|
||
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;
|
||
}
|
||
|
||
// statusHtmlToEditText: wandelt einen gespeicherten Kommentar (HTML) in den
|
||
// Klartext, der im Textfeld des Kommentar-Editors steht. Fettschrift wird zu
|
||
// **Sternchen**, Absätze und <br> werden zu Zeilenumbrüchen, alles andere
|
||
// fällt weg. Gegenstück zu statusEditTextToHtml(). Nötig, weil der Editor im
|
||
// Board seit der Kommentar-Überarbeitung ein normales <textarea> ist und kein
|
||
// contenteditable mehr (document.execCommand ist abgekündigt) — gespeichert
|
||
// wird aber weiterhin HTML, damit alle vorhandenen Anzeigepfade (Board-
|
||
// Vorschau, Wochenbericht, PDF-Export, Show-Mode, Verlauf) unverändert
|
||
// funktionieren und Altbestände lesbar bleiben.
|
||
// Parameter: html (String). Rückgabe: String.
|
||
function statusHtmlToEditText(html){
|
||
if(!html) return '';
|
||
const div = document.createElement('div');
|
||
div.innerHTML = sanitizeStatusHtml(html);
|
||
// Fett-Auszeichnungen vor dem Auflösen in Sternchen überführen
|
||
div.querySelectorAll('b, strong').forEach(el => {
|
||
const inner = (el.textContent || '').trim();
|
||
el.replaceWith(document.createTextNode(inner ? '**' + inner + '**' : ''));
|
||
});
|
||
// Block-Grenzen zu Zeilenumbrüchen
|
||
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();
|
||
}
|
||
|
||
// statusEditTextToHtml: wandelt den Klartext aus dem Kommentar-Editor in das
|
||
// gespeicherte HTML. Escapt zuerst vollständig (esc()), sodass eingetippte
|
||
// spitze Klammern niemals als Markup ankommen, und setzt danach die einzige
|
||
// erlaubte Auszeichnung **fett** sowie Zeilenumbrüche. Gegenstück zu
|
||
// statusHtmlToEditText(). Leerer oder reiner Leerraum-Text ergibt '' — die
|
||
// bisherige Bedeutung von "kein Kommentar" bleibt damit erhalten.
|
||
// Parameter: text (String). Rückgabe: String (HTML).
|
||
function statusEditTextToHtml(text){
|
||
const clean = String(text || '').trim();
|
||
if(clean === '') return '';
|
||
return esc(clean)
|
||
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
|
||
.replace(/\n/g, '<br>');
|
||
}
|
||
|
||
// -------- 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){
|
||
const versions = projectVersionsForUid(uid);
|
||
const entries = [];
|
||
let lastText = null;
|
||
versions.forEach((v, idx) => {
|
||
const text = statusPlainText(v.project.status);
|
||
const entry = {
|
||
week: v.week,
|
||
text,
|
||
html: sanitizeStatusHtml(v.project.status || ''),
|
||
author: v.project.statusAuthor || '',
|
||
at: v.project.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.
|
||
function renderProjektVerlauf(p, weekLabel, locked){
|
||
const versionen = projectVersionsForUid(p.projectUid);
|
||
// 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">
|
||
<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">Dieses Projekt erscheint zum ersten Mal — es gibt noch keine frühere Sitzung.</span></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(v.project.status);
|
||
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 ? esc(text) : '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">
|
||
<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>
|
||
</div>
|
||
</details>`;
|
||
}
|