All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
+504
-14
@@ -8,15 +8,23 @@
|
||||
// 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.
|
||||
|
||||
// departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck.
|
||||
// Parameter: keine. Rückgabe: String wie '?department=ELT'.
|
||||
function departmentParam(){
|
||||
return '?department=' + encodeURIComponent(currentDepartment);
|
||||
}
|
||||
|
||||
// esc: einfache Escape-Funktion für Ausgabe in HTML
|
||||
// Parameter: s (String). Rückgabe: HTML-sicherer String (&, <, >, " ersetzt).
|
||||
function esc(s){ return (s||"").replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
// 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.
|
||||
@@ -72,6 +80,19 @@ 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){
|
||||
@@ -112,6 +133,30 @@ function addDays(d, 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){
|
||||
@@ -119,21 +164,55 @@ function daysBetween(a, b){
|
||||
return Math.round(ms / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
// workdaysBetween: Anzahl der Werktage (Mo–Fr) im Zeitraum inkl. Start- und Endtag.
|
||||
// Wochenendtage (Sa/So) werden nicht mitgezählt – z.B. für die Auslastung.
|
||||
// Parameter: a, b (Date). Rückgabe: Anzahl der Werktage oder 0 bei ungültigem Zeitraum.
|
||||
// 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){
|
||||
const day = current.getDay();
|
||||
if(day !== 0 && day !== 6) count++;
|
||||
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){
|
||||
@@ -149,4 +228,415 @@ function getWeekNumber(d){
|
||||
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>`;
|
||||
}
|
||||
Reference in new issue
Block a user