This is placeholder commit and will be replaced by a merge of your virtual branches. Due to GitButler managing multiple virtual branches, you cannot switch back and forth between git branches and virtual branches easily. If you switch to another branch, GitButler will need to be reinitialized. If you commit on this branch, GitButler will throw it away. For more information about what we're doing here, check out our docs: https://docs.gitbutler.com/features/branch-management/integration-branch
654 lines
30 KiB
JavaScript
654 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/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'.
|
||
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>`;
|
||
}
|