Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte. Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt. Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
845 lines
41 KiB
JavaScript
845 lines
41 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 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).
|
||
//
|
||
// 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.
|
||
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;
|
||
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 }));
|
||
}
|
||
function richtextFett(feld){ richtextWickeln(feld, 'b'); }
|
||
function richtextKursiv(feld){ richtextWickeln(feld, 'i'); }
|
||
function richtextFarbe(feld, farbe){ richtextWickeln(feld, 'span', 'color:' + farbe); }
|
||
|
||
// 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';
|
||
});
|
||
});
|
||
|
||
// 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-hinweis">Erst Text markieren</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){
|
||
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>`;
|
||
}
|