Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung
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>
This commit is contained in:
1 parent
bc30f2900b
commit
db63a29365
47 files changed
+16486
-3768
No files matched your search
+606
-255
@@ -1,56 +1,70 @@
|
||||
// -----------------------------
|
||||
// Projektübersicht — Board (Haupttabelle)
|
||||
// Projektübersicht — Board (Listenansicht)
|
||||
// -----------------------------
|
||||
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `Test.html`),
|
||||
// die zentrale Tabelle mit Projekten, Status-Kommentaren, Personen-Spalten
|
||||
// (Anwesenheit/Zuordnung) und Leistungsphasen in Prozent. Sie wird von
|
||||
// `Test.html` nach `js/weeks.js` (vor `js/people/people.js`) geladen und stellt
|
||||
// globale Funktionen wie `renderBoard()`, `statusFocus()`, `cycleAssign()`,
|
||||
// `editLph()` und `delProj()` bereit. Sie nutzt `currentWeek()`/
|
||||
// Diese Datei rendert das Projektboard (Tab "Projektboard" in `App.html`) als
|
||||
// schlanke, aufklappbare Liste statt der früheren breiten Matrix (eine Spalte
|
||||
// je Person/Leistungsphase): jede Zeile zeigt auf einen Blick Kürzel-Avatare
|
||||
// der zugeordneten Personen, die aktuelle Leistungsphase samt Prozentwert und
|
||||
// eine einzeilige Status-Vorschau; ein Klick auf die Zeile klappt darunter das
|
||||
// Detailpanel auf (voller Status-Editor, vollständige Rollen-Zuordnung, alle
|
||||
// LPH-Balken). Sie wird von `App.html` nach `js/weeks.js` (vor
|
||||
// `js/people/people.js`) geladen und stellt globale Funktionen wie
|
||||
// `renderBoard()`, `toggleBoardRow()`, `cycleAssign()`,
|
||||
// und `editLph()` bereit. Sie nutzt `currentWeek()`/
|
||||
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.js`,
|
||||
// `assignKey()` aus `js/utils.js` und `openProjectPlan()` aus `js/gantt.js`.
|
||||
// `assignKey()`, `statusFocus()`, `sanitizeStatusHtml()` und
|
||||
// `getProgressColor()` aus `js/utils.js` sowie `openProjectPlan()` aus
|
||||
// `js/gantt.js` — navigiert bewusst im selben Tab (kein window.open() mehr),
|
||||
// ein Klick auf den Rest der Zeile klappt stattdessen nur das Detailpanel
|
||||
// auf/zu (toggleBoardRow()). editLph()/cycleAssign() rufen zusätzlich
|
||||
// renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen
|
||||
// Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem
|
||||
// eigenen DOM-Container (#wochenberichtContent) liegen.
|
||||
|
||||
// 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; wird beim
|
||||
// Speichern des Status (`statusBlur`) verwendet.
|
||||
let currentStatusPid = null;
|
||||
// boardRowOpen: UI-Zustand, welche Projekt-Zeile ihr Detailpanel aufgeklappt
|
||||
// darstellt (Key = Projekt-ID, Wert = boolean). Reine Anzeige-Steuerung, lebt
|
||||
// außerhalb von renderBoard() und übersteht daher dessen Neuaufbauten (z. B.
|
||||
// nach einer Zuordnungs- oder Prozent-Änderung im offenen Panel). Nicht
|
||||
// persistiert — jede Sitzung startet mit eingeklappten Zeilen.
|
||||
let boardRowOpen = {};
|
||||
|
||||
// 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;
|
||||
// toggleBoardRow: klappt das Detailpanel einer Projekt-Zeile auf bzw. zu.
|
||||
// Parameter: pid (Projekt-ID). Keine Rückgabe.
|
||||
function toggleBoardRow(pid){
|
||||
boardRowOpen[pid] = !boardRowOpen[pid];
|
||||
renderBoard();
|
||||
// Der Wochenbericht und die Wandtafel rendern dieselben Projektzeilen in
|
||||
// einem eigenen Container. Ohne diesen Aufruf merkten sie sich den Klick
|
||||
// zwar, zeigten die Zeile aber erst nach einem Tab-Wechsel auf- bzw.
|
||||
// zugeklappt.
|
||||
if(typeof renderWochenbericht === 'function') renderWochenbericht();
|
||||
if(typeof renderWandboard === 'function') renderWandboard();
|
||||
}
|
||||
|
||||
// statusBlur: übernimmt den Inhalt des Status-Editors (`el`) als neuen Status
|
||||
// des Projekts `pid` und räumt die Editor-Merker wieder ab, wenn `el` das
|
||||
// zuletzt fokussierte Element war.
|
||||
function statusBlur(el, pid){
|
||||
const val = statusValueFromHtml(el.innerHTML);
|
||||
editProj(pid, 'status', val);
|
||||
if(currentStatusEditor === el){ currentStatusEditor = null; currentStatusPid = null; }
|
||||
}
|
||||
|
||||
// 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){
|
||||
// statusPreviewText: liefert eine einzeilige Klartext-Vorschau eines
|
||||
// Status-HTML-Werts (Tags entfernt, überflüssiger Leerraum/Zeilenumbrüche zu
|
||||
// einem Leerzeichen zusammengefasst) für die eingeklappte Zeile — die volle
|
||||
// Formatierung bleibt dem aufgeklappten Editor vorbehalten. Parameter: html
|
||||
// (String). Rückgabe: String.
|
||||
function statusPreviewText(html){
|
||||
if(!html) return '';
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = html;
|
||||
const text = (tmp.textContent || tmp.innerText || '').replace(/\u00A0/g, ' ');
|
||||
return text.trim() === '' ? '' : html;
|
||||
// Bereinigen, BEVOR der Wert ins DOM geht: Auch ein nicht eingehängtes
|
||||
// Element lädt Ressourcen und führt deren Fehler-Handler aus, ungefiltertes
|
||||
// innerHTML wäre hier also ein Einfallstor.
|
||||
tmp.innerHTML = sanitizeStatusHtml(html);
|
||||
// Zeilen- und Absatzgrenzen zuerst in Leerzeichen überführen: textContent
|
||||
// ignoriert sie, sonst klebte das letzte Wort einer Zeile am ersten der
|
||||
// nächsten ("… KW 40.Freigabe steht aus").
|
||||
tmp.querySelectorAll('br').forEach(el => el.replaceWith(document.createTextNode(' ')));
|
||||
tmp.querySelectorAll('p, div').forEach(el => el.append(document.createTextNode(' ')));
|
||||
return (tmp.textContent || tmp.innerText || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// HINWEIS: statusBlur() ist entfallen. Seit der Kommentar-Editor ein
|
||||
// <textarea> ist (renderCommentEditor/saveBoardComment), gab es keinen Aufrufer
|
||||
// mehr; die Funktion schrieb ungefiltertes innerHTML in p.status und wäre beim
|
||||
// erneuten Verdrahten ein Einfallstor gewesen.
|
||||
|
||||
// peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch
|
||||
// die Projektplan-Seite, die board.js nicht lädt).
|
||||
@@ -65,33 +79,537 @@ function roleClass(role){
|
||||
return 'role-' + role.toLowerCase().replace(/\s+/g,'-');
|
||||
}
|
||||
|
||||
// renderBoard: baut die HTML-Tabelle für das Board auf (Kopf, Zeilen, Zellen).
|
||||
// Liest die aktuelle Woche, gruppiert Projekte nach Gruppe, sortiert sie nach
|
||||
// `order` und erzeugt die Status-, Personen- und LPH-Zellen. Bei gesperrten
|
||||
// Sitzungen werden alle Bearbeitungs-Handler deaktiviert und ein Hinweis samt
|
||||
// Entsperren-Button angezeigt. Aktualisiert außerdem die Legende mit den
|
||||
// vollständigen Namen und Anwesenheiten. Keine Parameter, keine Rückgabe.
|
||||
// renderBoardRow: baut die eingeklappte Zeile eines Projekts (Nr., Name +
|
||||
// Tags, Status-Vorschau, Avatar-Stapel der Zugeordneten, Phasen-Badge) sowie
|
||||
// bei Bedarf das aufgeklappte Detailpanel darunter. Parameter: p (Projekt),
|
||||
// allPeople (Array `{person, role}`, alle Rollen), locked (Boolean).
|
||||
// Rückgabe: HTML-String.
|
||||
// BOARD_OPTS: die Einstellungen der Projektliste. Sie zeigt wie der
|
||||
// Wochenbericht nur die beauftragten Leistungsphasen (Rückfall auf alle, wenn
|
||||
// keine hinterlegt sind — p.beauftragteLP leer, siehe renderBoardDrawer()),
|
||||
// nur in der ausführlichen statt der dichten Fassung, und nichts davon ist
|
||||
// hier bearbeitbar (nurAnsicht).
|
||||
const BOARD_OPTS = { nurAnsicht: true, nurBeauftragteLph: true };
|
||||
|
||||
function renderBoardRow(p, allPeople, locked, weekLabel, opts){
|
||||
const isOpen = !!boardRowOpen[p.id];
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
const tagsHtml = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
|
||||
const previewText = statusPreviewText(p.status);
|
||||
const isNew = p.statusState === 'new';
|
||||
|
||||
// Avatar-Stapel der eingeklappten Zeile: nur tatsächlich zugeordnete
|
||||
// Personen (Zustand 1 = Haupt oder 2 = optional), volle Rollen-Matrix nur
|
||||
// im aufgeklappten Detailpanel (siehe renderBoardDrawer).
|
||||
const stackHtml = allPeople.map(entry=>{
|
||||
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
|
||||
if(state === 0) return '';
|
||||
return `<span class="board-avatar ${roleClass(entry.role)} state-${state}" title="${esc(entry.person.fullName || entry.person.name)} · ${esc(entry.role)}">${esc(entry.person.name)}</span>`;
|
||||
}).join('');
|
||||
|
||||
const aktLp = lpActiveList(p)[0];
|
||||
const aktPct = aktLp && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? p.lph[aktLp] : null;
|
||||
const phaseHtml = aktLp
|
||||
? `<span class="phase-pill">${esc(aktLp)}${aktPct !== null ? ' · ' + esc(aktPct) + '%' : ''}</span>`
|
||||
: `<span class="phase-pill muted">Keine aktive LP</span>`;
|
||||
|
||||
// Ziehbar und mit Griff: Die Reihenfolge innerhalb einer Gruppe und der
|
||||
// Wechsel zwischen Gruppen wurden zuvor im eigenen Tab "Projekte" gepflegt,
|
||||
// der dieselben Projekte ein zweites Mal auflistete. Beides geschieht jetzt
|
||||
// hier, in der einen Projektliste.
|
||||
let html = `<div class="board-row${isOpen ? ' open' : ''}" data-project-id="${p.id}" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'}>`;
|
||||
html += `<div class="board-row-main" onclick="toggleBoardRow('${p.id}')">`;
|
||||
html += locked ? '' : `<span class="drag-handle" title="Verschieben" onclick="event.stopPropagation()">${icon('grip')}</span>`;
|
||||
html += `<span class="board-chev">${icon('chevronRight')}</span>`;
|
||||
html += `<span class="board-nr">${esc(p.nr) || '<span class="hint" style="margin:0">ohne Nr.</span>'}</span>`;
|
||||
html += `<div class="board-name-wrap">
|
||||
<span class="board-name" onclick="event.stopPropagation();openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</span>
|
||||
${typeof auftragsstatusChip === 'function' ? auftragsstatusChip(p) : ''}
|
||||
${tagsHtml}
|
||||
<div class="board-status-line">${isNew ? '<span class="board-newdot" title="Neuer Kommentar"></span>' : ''}${previewText ? esc(previewText) : '<span class="hint" style="margin:0">Kein Kommentar</span>'}</div>
|
||||
</div>`;
|
||||
html += `<div class="board-avatar-stack">${stackHtml}</div>`;
|
||||
html += phaseHtml;
|
||||
// Listenoperationen am Zeilenende (aus dem früheren Projekte-Tab). Das
|
||||
// Bearbeiten der Projektangaben führt in den Projektbereich, wo sie ihren
|
||||
// einen Ort haben — hier steht nur, was die LISTE betrifft.
|
||||
html += `<div class="board-row-actions" onclick="event.stopPropagation()">
|
||||
<button type="button" class="secondary small" onclick="openProjectPlan('${p.id}')" title="Projektbereich öffnen">Öffnen</button>
|
||||
${locked ? '' : `<button type="button" class="danger small" onclick="delProjectFromList('${p.id}')" title="Projekt aus dieser Sitzung löschen">Löschen</button>`}
|
||||
</div>`;
|
||||
html += `</div>`; // board-row-main
|
||||
|
||||
if(isOpen) html += renderBoardDrawer(p, allPeople, locked, weekLabel, opts);
|
||||
html += `</div>`; // board-row
|
||||
return html;
|
||||
}
|
||||
|
||||
// ---------- Kommentar-Editor (Board-Schublade und Wochenbericht) ----------
|
||||
// Der Kommentar eines Projekts zu einer Sitzung (p.status) wird nicht mehr in
|
||||
// einem contenteditable-Feld mit document.execCommand-Leiste bearbeitet,
|
||||
// sondern in einem normalen Textfeld mit ausdrücklichem Speichern. Dazu
|
||||
// kommen Verfasser und Zeitstempel (p.statusAuthor/p.statusAt, Spalten
|
||||
// status_author/status_at in der Tabelle projects) sowie der scrollbare
|
||||
// Verlauf der vorangegangenen Sitzungen aus projectStatusHistory()
|
||||
// (js/utils.js), der bisher nur auf der Projektplan-Seite sichtbar war.
|
||||
//
|
||||
// Zusammenspiel mit cloneWeek() (js/weeks.js): Beim Duplizieren einer Sitzung
|
||||
// wird der Kommentartext NICHT mehr mitkopiert — man schreibt den Kommentar
|
||||
// dieser Woche, statt den der Vorwoche zu überschreiben. Wenn sich nichts
|
||||
// geändert hat, übernimmt der Knopf "übernehmen" am jeweiligen Verlaufs-
|
||||
// Eintrag dessen Text in das Textfeld.
|
||||
|
||||
// boardCommentBaseline: der zuletzt gespeicherte Kommentartext je Projekt-ID,
|
||||
// Vergleichsbasis für die "ungespeichert"-Anzeige und für "Verwerfen".
|
||||
// Wird bei jedem Aufbau der Schublade neu gesetzt.
|
||||
let boardCommentBaseline = {};
|
||||
|
||||
// commentAuthorLine: Kopfzeile des Editors — wer den gespeicherten Kommentar
|
||||
// verfasst hat und wann. Bestandskommentare ohne Verfasser (vor der
|
||||
// Einführung von status_author) werden als "unbekannt" ausgewiesen, statt
|
||||
// stillschweigend die aktuell angemeldete Person zu behaupten.
|
||||
// Parameter: p (Projekt). Rückgabe: HTML-String.
|
||||
function commentAuthorLine(p){
|
||||
const hasText = statusPreviewText(p.status) !== '';
|
||||
if(!hasText){
|
||||
const me = (currentUser && currentUser.displayName) || '';
|
||||
return me
|
||||
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(me))}</span>${esc(me)}</span><span class="comment-sep">·</span><span>neuer Eintrag</span>`
|
||||
: `<span>neuer Eintrag</span>`;
|
||||
}
|
||||
const author = p.statusAuthor || '';
|
||||
const at = p.statusAt ? formatCommentStamp(p.statusAt) : '';
|
||||
const who = author
|
||||
? `<span class="comment-who"><span class="comment-av">${esc(initialsForName(author))}</span>${esc(author)}</span>`
|
||||
: `<span class="comment-who comment-unknown">Verfasser unbekannt</span>`;
|
||||
return who + (at ? `<span class="comment-sep">·</span><span>${esc(at)}</span>` : '');
|
||||
}
|
||||
|
||||
// formatCommentStamp: ISO-Zeitstempel als "TT.MM.JJJJ, HH:MM". Parameter:
|
||||
// iso (String). Rückgabe: String (leer bei ungültigem Wert).
|
||||
function formatCommentStamp(iso){
|
||||
const d = new Date(iso);
|
||||
if(isNaN(d.getTime())) return '';
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${pad(d.getDate())}.${pad(d.getMonth()+1)}.${d.getFullYear()}, ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// renderCommentEditor: der vollständige Kommentarbereich der Schublade —
|
||||
// Editor mit Kopf-/Fußzeile und darunter der eingeklappte, scrollbare
|
||||
// Verlauf.
|
||||
//
|
||||
// In der dichten Fassung (dicht = true, nur Wochenbericht) entfallen zwei
|
||||
// Elemente, die dort je Karte rund 60 px kosteten, ohne etwas beizutragen:
|
||||
// * Die eigene KOPFZEILE mit Verfasser und Tastenkürzel. Der Verfasser
|
||||
// rückt in die Fußzeile neben die Knöpfe — dieselbe Angabe, eine Zeile
|
||||
// weniger. Die Tastenkürzel stehen als Titel am Textfeld.
|
||||
// * Der Aufklapper "FRÜHERE SITZUNGEN". Er ist seit dem Verlauf eine
|
||||
// Doppelung: Der zeigt dieselben Kommentare und mehr. Seine einzige
|
||||
// eigene Funktion — einen früheren Text übernehmen — hat der Verlauf
|
||||
// übernommen (renderProjektVerlauf(), js/utils.js).
|
||||
// Parameter: p (Projekt), weekLabel (String), locked (Boolean),
|
||||
// dicht (Boolean). Rückgabe: HTML-String.
|
||||
function renderCommentEditor(p, weekLabel, locked, dicht){
|
||||
const html = sanitizeStatusHtml(p.status || '');
|
||||
boardCommentBaseline[p.id] = html;
|
||||
|
||||
if(locked){
|
||||
return `<div class="comment-block" data-pid="${esc(p.id)}">
|
||||
<div class="comment-head-line">${commentAuthorLine(p)}</div>
|
||||
<div class="comment-readonly">${html || '<span class="hint">Kein Kommentar erfasst.</span>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const kopf = dicht ? '' : `<div class="comment-editor-head">
|
||||
${commentAuthorLine(p)}
|
||||
<span class="comment-kbd">Strg+↵ speichern · Esc verwerfen</span>
|
||||
</div>`;
|
||||
const verfasserImFuss = dicht ? `<span class="comment-foot-author">${commentAuthorLine(p)}</span>` : '';
|
||||
// Der frühere Aufklapper "Frühere Sitzungen" ist entfallen. Er zeigte nur
|
||||
// den Kommentartext und auch den nur für Sitzungen mit tatsächlicher
|
||||
// Änderung — eine dritte, engere Auswahl neben dem Verlauf im
|
||||
// Wochenbericht und dem Dialog im Projektbereich. Alle drei Stellen nutzen
|
||||
// jetzt renderProjektVerlauf() (js/utils.js) und zeigen damit dasselbe.
|
||||
const verlaufAufklapper = '';
|
||||
|
||||
// richtextToolbarHtml() (js/utils.js): Fett/Kursiv/Farbe für den Kommentar
|
||||
// (Eriks Wunsch) — anders als kopf (der Autor-/Tastenkürzel-Kopf) IMMER
|
||||
// gezeigt, auch im dichten Modus: "dicht" ist hier die reguläre, zweispaltig
|
||||
// kompakte Wochenbericht-Kartenansicht (nicht eine lange Liste vieler
|
||||
// Karten), genau die Stelle, an der der Kommentar normalerweise bearbeitet
|
||||
// wird — die Werkzeugleiste ist klein genug, um dort nicht zu stören.
|
||||
const formatLeiste = richtextToolbarHtml('.comment-field');
|
||||
const feldAttrs = `${dicht ? 'title="Strg+↵ speichern · Esc verwerfen" ' : ''}`
|
||||
+ `oninput="markCommentDirty(this)" onkeydown="boardCommentKey(event, this)" onblur="boardCommentBlur(this)"`;
|
||||
return `<div class="comment-block" data-pid="${esc(p.id)}">
|
||||
<div class="comment-editor">
|
||||
${kopf}
|
||||
<div class="richtext-wrap">
|
||||
${formatLeiste}
|
||||
${richtextFeldHtml('comment-field', html, 'Was gibt es diese Woche zu diesem Projekt?', feldAttrs)}
|
||||
</div>
|
||||
<div class="comment-editor-foot">
|
||||
<button type="button" class="small" onmousedown="event.preventDefault()" onclick="saveBoardComment(this)">Speichern</button>
|
||||
<button type="button" class="secondary small"
|
||||
onmousedown="event.preventDefault();discardBoardComment(this)">Verwerfen</button>
|
||||
${verfasserImFuss}
|
||||
<span class="comment-foot-spacer"></span>
|
||||
<span class="comment-dirty" hidden>● ungespeichert</span>
|
||||
</div>
|
||||
</div>
|
||||
${verlaufAufklapper}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// pendingCommentFocus: Projekt-ID des Kommentarfelds, in das der Nutzer gerade
|
||||
// klickt. Hintergrund: Ein Klick in ein anderes Kommentarfeld löst zuerst den
|
||||
// Fokusverlust des bisherigen aus; dessen Blur-Netz speichert und rendert
|
||||
// Board UND Wochenbericht neu — das angeklickte Feld ist dann längst durch ein
|
||||
// neues Element ersetzt, der Klick geht ins Leere und die nächsten
|
||||
// Tastenanschläge landen nirgends. Die Absicht wird deshalb in der
|
||||
// Capture-Phase festgehalten und nach dem Neu-Rendern wiederhergestellt.
|
||||
let pendingCommentFocus = null;
|
||||
|
||||
document.addEventListener('mousedown', function(e){
|
||||
const field = e.target && e.target.closest ? e.target.closest('.comment-field') : null;
|
||||
const block = field && field.closest('.comment-block');
|
||||
pendingCommentFocus = block ? block.dataset.pid : null;
|
||||
}, true);
|
||||
|
||||
// restorePendingCommentFocus: setzt den Fokus nach einem Neu-Rendern in das
|
||||
// Kommentarfeld, das der Nutzer anklicken wollte, und stellt den Cursor ans
|
||||
// Textende. Ohne gemerkte Absicht oder ohne Treffer passiert nichts.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
function restorePendingCommentFocus(){
|
||||
const pid = pendingCommentFocus;
|
||||
pendingCommentFocus = null;
|
||||
if(!pid) return;
|
||||
const sel = '.comment-block[data-pid="' + ((window.CSS && CSS.escape) ? CSS.escape(pid) : pid) + '"] .comment-field';
|
||||
// Board und Wochenbericht rendern dieselben Projekte; das sichtbare Feld
|
||||
// gewinnt (offsetParent ist null, solange der Tab ausgeblendet ist).
|
||||
const kandidaten = Array.from(document.querySelectorAll(sel));
|
||||
const field = kandidaten.find(f => f.offsetParent !== null) || kandidaten[0];
|
||||
if(!field) return;
|
||||
richtextCursorAnsEnde(field);
|
||||
}
|
||||
|
||||
// commentParts: löst von einem beliebigen Bedienelement aus dessen
|
||||
// Kommentar-Block auf und liefert Projekt-ID, Textfeld und Hinweis-Element.
|
||||
//
|
||||
// Warum nicht über Element-IDs: Projektboard und Wochenbericht rendern
|
||||
// dieselben Projekte gleichzeitig (der Bericht nutzt renderBoardDrawer()
|
||||
// wieder). Feste IDs wie "bc_text_<pid>" wären dann doppelt vergeben, und
|
||||
// document.getElementById() liefert immer den ersten Treffer — im
|
||||
// Wochenbericht getippter Text wurde dadurch beim Speichern durch den Inhalt
|
||||
// des (unsichtbaren) Board-Felds ersetzt. Über closest() trifft jede Aktion
|
||||
// garantiert den Block, in dem sie ausgelöst wurde.
|
||||
// Parameter: el (Element innerhalb des Blocks). Rückgabe: Objekt oder null.
|
||||
function commentParts(el){
|
||||
if(!el || !el.closest) return null;
|
||||
// Erst der eigene Block; findet sich keiner, der Block derselben Karte.
|
||||
// Gebraucht seit der Verlauf (js/wochenbericht/wochenbericht.js) einen
|
||||
// "übernehmen"-Knopf trägt: Er steht UNTER dem Spaltenraster und damit
|
||||
// ausserhalb von .comment-block, gehört aber zum selben Projekt.
|
||||
const block = el.closest('.comment-block')
|
||||
|| (el.closest('.report-card') || document).querySelector('.comment-block');
|
||||
if(!block) return null;
|
||||
return {
|
||||
block,
|
||||
pid: block.dataset.pid,
|
||||
field: block.querySelector('.comment-field'),
|
||||
dirty: block.querySelector('.comment-dirty')
|
||||
};
|
||||
}
|
||||
|
||||
// markCommentDirty: blendet den "ungespeichert"-Hinweis ein bzw. aus, indem
|
||||
// der Feldinhalt mit dem zuletzt gespeicherten Stand verglichen wird.
|
||||
// Parameter: el (Element im Kommentar-Block). Keine Rückgabe.
|
||||
function markCommentDirty(el){
|
||||
const c = commentParts(el);
|
||||
if(!c || !c.field || !c.dirty) return;
|
||||
c.dirty.hidden = c.field.innerHTML === (boardCommentBaseline[c.pid] || '');
|
||||
}
|
||||
|
||||
// saveBoardComment: schreibt den Feldinhalt als neuen Kommentar des Projekts,
|
||||
// samt Verfasser (angemeldete Person) und Zeitstempel, und rendert neu.
|
||||
// Wird nichts geändert, passiert nichts — ein unverändertes Feld soll weder
|
||||
// den Zeitstempel noch den Verfasser überschreiben. Parameter: el (Element im
|
||||
// Kommentar-Block). Rückgabe: keine.
|
||||
function saveBoardComment(el){
|
||||
if(guardWeekLocked()) return;
|
||||
const c = commentParts(el);
|
||||
if(!c || !c.field) return;
|
||||
const html = sanitizeStatusHtml(c.field.innerHTML);
|
||||
if(html === (boardCommentBaseline[c.pid] || '')) return;
|
||||
|
||||
const p = currentWeek().projects.find(x => x.id === c.pid);
|
||||
if(!p) return;
|
||||
|
||||
const before = (p.status || '').trim();
|
||||
if(before === '' && html !== '') p.statusState = 'new';
|
||||
else if(html === '') delete p.statusState;
|
||||
|
||||
p.status = html;
|
||||
if(html === ''){
|
||||
p.statusAuthor = '';
|
||||
p.statusAt = '';
|
||||
} else {
|
||||
p.statusAuthor = (currentUser && currentUser.displayName) || '';
|
||||
p.statusAt = new Date().toISOString();
|
||||
}
|
||||
boardCommentBaseline[c.pid] = html;
|
||||
save();
|
||||
renderBoard();
|
||||
if(typeof renderWochenbericht === 'function') renderWochenbericht();
|
||||
// Beide Ansichten wurden komplett neu aufgebaut — den Fokus in das Feld
|
||||
// zurückholen, das der Nutzer gerade anklicken wollte.
|
||||
restorePendingCommentFocus();
|
||||
}
|
||||
|
||||
// discardBoardComment: setzt das Textfeld auf den zuletzt gespeicherten Stand
|
||||
// zurück. Parameter: el (Element im Kommentar-Block). Keine Rückgabe.
|
||||
function discardBoardComment(el){
|
||||
const c = commentParts(el);
|
||||
if(!c || !c.field) return;
|
||||
c.field.innerHTML = boardCommentBaseline[c.pid] || '';
|
||||
markCommentDirty(c.field);
|
||||
richtextCursorAnsEnde(c.field);
|
||||
}
|
||||
|
||||
// boardCommentKey: Tastaturbedienung des Kommentarfelds — Strg/Cmd+Enter
|
||||
// speichert, Escape verwirft. Ein einfaches Enter bleibt der Zeilenumbruch.
|
||||
// Parameter: e (KeyboardEvent), el (das Textfeld). Keine Rückgabe.
|
||||
function boardCommentKey(e, el){
|
||||
if(e.key === 'Enter' && (e.ctrlKey || e.metaKey)){
|
||||
saveBoardComment(el);
|
||||
e.preventDefault();
|
||||
} else if(e.key === 'Escape'){
|
||||
discardBoardComment(el);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// boardCommentBlur: Sicherheitsnetz — wer tippt und dann woanders hin klickt,
|
||||
// verliert seinen Text nicht. Entspricht dem früheren Verhalten von
|
||||
// statusBlur(). Bedienelemente, bei denen der Fokusverlust stören würde
|
||||
// ("Verwerfen", "übernehmen", die Anwesenheits-Kacheln), unterdrücken ihn per
|
||||
// onmousedown/preventDefault. Parameter: el (das Textfeld). Keine Rückgabe.
|
||||
function boardCommentBlur(el){
|
||||
saveBoardComment(el);
|
||||
}
|
||||
|
||||
// takeCommentFromHistory: übernimmt den Kommentartext einer früheren Sitzung
|
||||
// in das Textfeld der aktuellen — für den häufigen Fall "gegenüber der
|
||||
// Vorwoche unverändert". Gespeichert wird erst durch Speichern (oder das
|
||||
// Blur-Netz), sodass der Text vorher noch angepasst werden kann. Parameter:
|
||||
// el (der geklickte Knopf), weekLabel (Bezeichnung der Quell-Sitzung).
|
||||
// Keine Rückgabe.
|
||||
function takeCommentFromHistory(el, weekLabel){
|
||||
if(guardWeekLocked()) return;
|
||||
const c = commentParts(el);
|
||||
if(!c || !c.field) return;
|
||||
const p = currentWeek().projects.find(x => x.id === c.pid);
|
||||
if(!p || !p.projectUid) return;
|
||||
|
||||
// Zwei Quellen, weil zwei Aufrufer: Der Aufklapper "Frühere Sitzungen"
|
||||
// listet projectStatusHistory() — also nur Sitzungen, in denen sich der
|
||||
// Text tatsächlich GEÄNDERT hat. Der Verlauf im Wochenbericht zeigt dagegen
|
||||
// JEDE frühere Sitzung. Verwies der Knopf dort auf eine Woche ohne
|
||||
// Textänderung, fand find() nichts und das Übernehmen blieb wirkungslos.
|
||||
const entry = projectStatusHistory(p.projectUid).find(e => e.week.label === weekLabel);
|
||||
const html = entry
|
||||
? entry.html
|
||||
: (v => v ? sanitizeStatusHtml(v.project.status || '') : null)(
|
||||
projectVersionsForUid(p.projectUid).find(v => v.week.label === weekLabel));
|
||||
if(html === null || html === undefined) return;
|
||||
|
||||
c.field.innerHTML = sanitizeStatusHtml(html);
|
||||
markCommentDirty(c.field);
|
||||
richtextCursorAnsEnde(c.field);
|
||||
}
|
||||
|
||||
// renderBoardDrawer: baut das aufgeklappte Detailpanel einer Projekt-Zeile —
|
||||
// links der volle Status-Editor (unverändert gegenüber der früheren Matrix-
|
||||
// Zelle) plus alle LPH-Balken, rechts die vollständige Rollen-/Personen-
|
||||
// Zuordnung als klickbare Avatar-Chips. Parameter: p (Projekt), allPeople
|
||||
// (Array `{person, role}`), locked (Boolean). Rückgabe: HTML-String.
|
||||
function renderBoardDrawer(p, allPeople, locked, weekLabel, opts){
|
||||
// opts.dicht: die dichte Fassung der Karte (Wochenbericht). Sie zeigt
|
||||
// dieselben Angaben und dieselben Bedienelemente wie die normale, nur
|
||||
// enger gesetzt: die Leistungsphasen stehen als EINE Leiste nebeneinander
|
||||
// statt als bis zu zehn Zeilen untereinander, und die Beteiligten rücken
|
||||
// darunter statt in eine eigene Spalte. Die Zeilenform bestimmte bisher die
|
||||
// Höhe der ganzen Karte — bei 29 Projekten ergab das rund 17
|
||||
// Bildschirmseiten, durch die man sich im Teammeeting hindurchscrollen
|
||||
// musste. Im Projektboard, wo die Karte nur beim Aufklappen einer Zeile
|
||||
// erscheint, bleibt es bei der ausführlichen Fassung.
|
||||
//
|
||||
// nurBeauftragteLph: sowohl im Wochenbericht als auch im Projektboard
|
||||
// werden nur die Leistungsphasen gezeigt, für die das Projekt beauftragt
|
||||
// ist (p.beauftragteLP, gesetzt beim Anlegen oder im Honorar-Dialog). Ist
|
||||
// nichts hinterlegt — was auf ältere Bestandsprojekte zutrifft —, bleibt es
|
||||
// bei der vollständigen Liste, sonst stünde dort gar nichts.
|
||||
// opts.nurAnsicht: die Karte zeigt, sie erfasst nicht.
|
||||
//
|
||||
// Kommentar, Fortschritt je Leistungsphase und die Zuordnung der
|
||||
// Beteiligten entstehen in der wöchentlichen Besprechung und werden deshalb
|
||||
// allein im Wochenbericht gepflegt. Zuvor waren dieselben drei Angaben an
|
||||
// drei Stellen bearbeitbar (Projektboard, Wochenbericht, Projektbereich) —
|
||||
// niemand wusste, welche die richtige ist, und zwei Personen konnten
|
||||
// dasselbe Feld an verschiedenen Stellen ändern. Dieselbe Karte, dieselben
|
||||
// Werte, nur ohne Eingabefelder und mit einem Verweis dorthin, wo sie
|
||||
// gepflegt werden. Eine gesperrte Sitzung wirkt weiterhin zusätzlich.
|
||||
const ansicht = !!(opts && opts.nurAnsicht);
|
||||
const starr = ansicht || locked;
|
||||
|
||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
||||
const nurBeauftragte = !!(opts && opts.nurBeauftragteLph) && beauftragt.length > 0;
|
||||
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
|
||||
const aktLp = lpActiveList(p)[0];
|
||||
|
||||
// lphLeiste: die Leistungsphasen nebeneinander statt gestapelt — seit
|
||||
// Variante A (Eriks Wahl aus dem Layout-Vergleich) die EINE Darstellung für
|
||||
// Leistungsphasen in dieser Schublade, nicht mehr nur die dichte
|
||||
// Wochenbericht-Karte. Jede Phase ist ein schmales Feld mit der
|
||||
// Phasennummer und ihrem Eingabefeld; der Füllstand steckt als senkrechter
|
||||
// Verlauf im Feld-Hintergrund. Die amtierende Phase bleibt hervorgehoben.
|
||||
const lphLeiste = sichtbareLphs.map(l=>{
|
||||
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
||||
const aktiv = l === aktLp;
|
||||
const inputAttr = starr ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
|
||||
const pct = v !== "" ? Math.min(100, parseFloat(v)||0) : 0;
|
||||
// Deckkraft 4d (30 %): Bei 33 (20 %) war der Füllstand auf dem dunklen
|
||||
// Feld-Hintergrund praktisch nicht zu sehen, und damit hätte die Leiste
|
||||
// den Balken der Zeilenform ersatzlos verloren.
|
||||
const fuellung = pct > 0
|
||||
? ` style="background:linear-gradient(to top, ${getProgressColor(pct)}4d ${pct}%, transparent ${pct}%)"`
|
||||
: '';
|
||||
return `<div class="board-lph-cell${aktiv ? ' lp-aktiv' : ''}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}"${fuellung} title="${esc(l)}${aktiv ? ' · amtierende Leistungsphase' : ''}">
|
||||
<span class="board-lph-cell-label">${esc(l.replace('LPH ', ''))}</span>
|
||||
<input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const roleRows = ROLES.map(r=>{
|
||||
const entries = allPeople.filter(e=>e.role===r);
|
||||
const chips = entries.length ? entries.map(entry=>{
|
||||
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0;
|
||||
const click = starr ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`;
|
||||
return `<span class="board-avatar ${roleClass(entry.role)} state-${state}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}" ${click} title="${esc(entry.person.fullName || entry.person.name)}">${esc(entry.person.name)}</span>`;
|
||||
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
|
||||
return `<div class="board-role-row"><span class="board-role-label ${roleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
|
||||
}).join('');
|
||||
|
||||
// Dichte Fassung: zwei Spalten — links der Kommentar, rechts die
|
||||
// Leistungsphasen-Leiste und darunter die Beteiligten.
|
||||
//
|
||||
// Zwischenzeitlich waren es drei Spalten, weil die gestapelten Beteiligten
|
||||
// die Karte höher machten und damals 29 Karten untereinander standen. Seit
|
||||
// der Wochenbericht eine Tagesordnung ist, ist immer nur EIN Projekt
|
||||
// geöffnet — die Höhe der Karte kostet nichts mehr, während die Breite
|
||||
// knapp ist: Neben der Projektliste blieben für drei Spalten nur noch
|
||||
// 137 px für die Beteiligten, zu wenig für Rollenname und Kürzel.
|
||||
//
|
||||
// Die Beteiligten behalten ihre Zeile je Rolle — anders als im Entwurf, wo
|
||||
// sie zu einer flachen Reihe zusammengefasst waren: Ein Kürzel kann in
|
||||
// mehreren Rollen vorkommen, und cycleAssign() braucht die Rolle. Ohne sie
|
||||
// wäre beim Klicken nicht erkennbar, welche Zuordnung man durchschaltet.
|
||||
if(opts && opts.dicht){
|
||||
return `<div class="board-drawer board-drawer-dicht">
|
||||
<div class="board-drawer-grid">
|
||||
<div class="drawer-col drawer-col-comment">
|
||||
<h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
|
||||
${renderCommentEditor(p, weekLabel, locked, true)}
|
||||
</div>
|
||||
<div class="drawer-col drawer-col-kompakt">
|
||||
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
|
||||
<div class="board-lph-leiste">${lphLeiste}</div>
|
||||
<h4 class="drawer-h4-zweit">Beteiligte</h4>
|
||||
<div class="board-role-mini">${roleRows}</div>
|
||||
</div>
|
||||
</div>
|
||||
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Die drei Blöcke stehen als gleichrangige Geschwister im Raster, damit der
|
||||
// aufrufende Kontext ihre Anordnung allein über CSS bestimmen kann: das
|
||||
// Projektboard stellt Kommentar und Leistungsphasen übereinander in die
|
||||
// linke Spalte (grid-template-areas in styles.css), der Wochenbericht
|
||||
// nebeneinander in drei Spalten. Inhalt und Reihenfolge sind in beiden
|
||||
// Fällen identisch.
|
||||
return `<div class="board-drawer">
|
||||
<div class="board-drawer-grid">
|
||||
<div class="drawer-col drawer-col-comment">
|
||||
<h4>Kommentar${weekLabel ? ' zur Sitzung ' + esc(weekLabel) : ''}</h4>
|
||||
${ansicht ? renderKommentarAnsicht(p, locked) : renderCommentEditor(p, weekLabel, locked)}
|
||||
</div>
|
||||
<div class="drawer-col drawer-col-lph">
|
||||
<h4>${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'}</h4>
|
||||
<div class="board-lph-leiste">${lphLeiste}</div>
|
||||
</div>
|
||||
<div class="drawer-col drawer-col-people">
|
||||
<h4>Beteiligte</h4>
|
||||
<div class="board-role-mini">${roleRows}</div>
|
||||
</div>
|
||||
</div>
|
||||
${ansicht ? renderBerichtVerweis(p, locked) : ''}
|
||||
${typeof renderProjektVerlauf === 'function' ? renderProjektVerlauf(p, weekLabel, locked) : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderKommentarAnsicht: der Kommentar der laufenden Sitzung, nur zu lesen.
|
||||
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
|
||||
function renderKommentarAnsicht(p, locked){
|
||||
const text = sanitizeStatusHtml(p.status || '');
|
||||
return `<div class="status-edit is-ansicht locked-cell">${text ||
|
||||
'<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>'}</div>`;
|
||||
}
|
||||
|
||||
// renderBerichtVerweis: der Weg zur Bearbeitung. Der Verweis springt in den
|
||||
// Wochenbericht UND stellt dort dieses Projekt ein — ohne das müsste man es
|
||||
// in einer Liste von dreißig wiederfinden.
|
||||
// Parameter: p (Projekt), locked. Rückgabe: HTML-String.
|
||||
function renderBerichtVerweis(p, locked){
|
||||
if(locked) return '<div class="drawer-verweis"><span class="hint" style="margin:0">Die Sitzung ist gesperrt.</span></div>';
|
||||
const uid = esc(p.projectUid || '');
|
||||
return `<div class="drawer-verweis">
|
||||
<span class="hint" style="margin:0">Kommentar, Fortschritt und Zuordnung werden in der Besprechung erfasst.</span>
|
||||
<button type="button" class="plan-verweis" onclick="springZumWochenbericht('${uid}')">Im Wochenbericht bearbeiten →</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderBoard: baut die Listenansicht des Boards auf (eine Bubble-Card je
|
||||
// Projektgruppe mit den Projekten als Zeilen, siehe renderBoardRow()). Liest
|
||||
// die aktuelle Woche, gruppiert Projekte nach Gruppe und sortiert sie nach
|
||||
// `order`. Bei gesperrten Sitzungen werden alle Bearbeitungs-Handler
|
||||
// deaktiviert und ein Hinweis samt Entsperren-Button angezeigt. Aktualisiert
|
||||
// außerdem die Legende mit den vollständigen Namen und Anwesenheiten. Keine
|
||||
// Parameter, keine Rückgabe.
|
||||
function renderBoard(){
|
||||
const w = currentWeek();
|
||||
const container = document.getElementById('boardTable');
|
||||
// Ohne geladene Sitzung (Ladefehler, oder die letzte Sitzung wurde von
|
||||
// jemand anderem gelöscht) griff der Code ungeprüft auf w.projects zu — der
|
||||
// TypeError riss renderAll() mit und ließ die Oberfläche halb gerendert
|
||||
// stehen. Alle Schwester-Renderer prüfen das bereits selbst.
|
||||
if(!w){
|
||||
if(container) container.innerHTML = '<div class="hint">Keine Sitzung geladen.</div>';
|
||||
return;
|
||||
}
|
||||
const locked = isWeekLocked(w);
|
||||
const table = document.getElementById('boardTable');
|
||||
const grouped = peopleByRole(w);
|
||||
const allPeople = [];
|
||||
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
|
||||
|
||||
// Kopfzeilen: Rollen und Leistungsphasen
|
||||
let h1 = `<tr><th rowspan="2">Pr.-Nr.</th><th rowspan="2">Bezeichnung</th><th rowspan="2">Status / Kommentar</th>`;
|
||||
ROLES.forEach(r=>{ h1 += `<th class="${roleClass(r)}" colspan="${Math.max(1,grouped[r].length)}">${r}</th>`; });
|
||||
h1 += `<th colspan="${LPHS.length}">Leistungsphasen (%)</th><th rowspan="2"></th></tr>`;
|
||||
|
||||
let h2 = `<tr>`;
|
||||
ROLES.forEach(r=>{
|
||||
if(grouped[r].length===0) h2 += `<th class="rot ${roleClass(r)}">–</th>`;
|
||||
grouped[r].forEach(entry=> h2 += `<th class="rot ${roleClass(entry.role)}">${esc(entry.person.name)}</th>`);
|
||||
});
|
||||
LPHS.forEach(l=> h2 += `<th class="rot">${l}</th>`);
|
||||
h2 += `</tr>`;
|
||||
|
||||
// Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen
|
||||
// Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen.
|
||||
//
|
||||
// Alle vier Standardgruppen werden vorbelegt, auch wenn sie leer sind: Sonst
|
||||
// entstünde für eine leer geräumte Gruppe keine Ablagefläche, und ein
|
||||
// Projekt ließe sich nicht mehr dorthin ziehen. (Aus dem früheren
|
||||
// Projekte-Tab übernommen, der hierin aufgegangen ist.)
|
||||
const groups = {};
|
||||
GROUP_ORDER.forEach(g => { groups[g] = []; });
|
||||
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
|
||||
|
||||
// Projekte nach ihrer Reihenfolge sortieren
|
||||
@@ -99,169 +617,29 @@ function renderBoard(){
|
||||
groups[g].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
});
|
||||
|
||||
let body = "";
|
||||
const allPeople = [];
|
||||
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
|
||||
|
||||
// Jede Gruppe als Abschnitt, dann Zeilen für Projekte
|
||||
let html = "";
|
||||
sortGroups(Object.keys(groups)).forEach(g=>{
|
||||
const colspan = 3 + allPeople.length + LPHS.length + 1; // Spaltenanzahl für Gruppentitel
|
||||
body += `<tr class="group-header"><td colspan="${colspan}">${esc(g)}</td></tr>`;
|
||||
groups[g].forEach(p=>{
|
||||
body += `<tr>`;
|
||||
body += `<td class="left">${esc(p.nr)}</td>`;
|
||||
body += `<td class="left proj-name" style="cursor:pointer" onclick="openProjectPlan('${p.id}')" title="Projektplan öffnen">${esc(p.name)}</td>`;
|
||||
// Status-Editor: contenteditable mit kleiner Toolbar (Fett, Farben, Absätze mit Enter)
|
||||
const statusClass = p.statusState === 'new' ? 'status-new' : p.statusState === 'duplicated' ? 'status-duplicated' : '';
|
||||
const statusEditable = locked ? 'false' : 'true';
|
||||
const statusToolbar = locked ? '' : `
|
||||
<div class="status-toolbar" tabindex="-1">
|
||||
<button type="button" title="Fett" onmousedown="event.preventDefault();formatStatus('bold')"><b>B</b></button>
|
||||
<button type="button" title="Rot" class="color-red" onmousedown="event.preventDefault();formatStatus('foreColor','#c53030')">A</button>
|
||||
<button type="button" title="Grün" class="color-green" onmousedown="event.preventDefault();formatStatus('foreColor','#2f855a')">A</button>
|
||||
<button type="button" title="Blau" class="color-blue" onmousedown="event.preventDefault();formatStatus('foreColor','#2b6cb0')">A</button>
|
||||
<button type="button" title="Schwarz" class="color-black" onmousedown="event.preventDefault();formatStatus('foreColor','#1a1a1a')">A</button>
|
||||
<button type="button" title="Absatz" onmousedown="event.preventDefault();formatStatus('formatBlock','P')">¶</button>
|
||||
</div>`;
|
||||
const statusClick = locked ? '' : `onfocus="statusFocus(this,'${p.id}')" onblur="statusBlur(this,'${p.id}')"`;
|
||||
const safeStatus = sanitizeStatusHtml(p.status || '');
|
||||
body += `<td class="status ${locked ? 'locked-cell' : ''}">
|
||||
${statusToolbar}
|
||||
<div class="status-edit ${statusClass}" contenteditable="${statusEditable}" ${statusClick}>${safeStatus}</div>
|
||||
</td>`;
|
||||
|
||||
// Personen-Spalten: Klick wechselt Zustand (nicht, optional, Hauptverantwortung)
|
||||
allPeople.forEach(entry=>{
|
||||
const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; // 0=keine,1=Haupt,2=optional
|
||||
const cls = state===1?'on':state===2?'optional':'';
|
||||
const txt = state===1?'X':state===2?'(X)':'';
|
||||
const click = locked ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`;
|
||||
body += `<td class="person-cell ${roleClass(entry.role)} ${cls} ${locked ? 'locked-cell' : ''}" ${click}>${txt}</td>`;
|
||||
});
|
||||
|
||||
// LPH-Spalten mit Prozentwerten und Fortschrittsbalken;
|
||||
// die amtierende Leistungsphase (Tab "Projekte") wird farblich markiert.
|
||||
const aktLp = lpActiveList(p)[0];
|
||||
LPHS.forEach(l=>{
|
||||
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
|
||||
const aktiv = l === aktLp;
|
||||
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
|
||||
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">`;
|
||||
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
|
||||
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
|
||||
body += `</td>`;
|
||||
});
|
||||
body += `</tr>`;
|
||||
});
|
||||
|
||||
html += `<div class="section-title">${esc(g)}</div>`;
|
||||
// Klasse "projects-sortable": Die Ablage-Handler aus
|
||||
// js/projects/projects.js (handleDrop/reorderProjects) finden ihre
|
||||
// Container über diesen Selektor. Sie bleiben dort, weil sie auch die
|
||||
// Reihenfolge-Logik für die Daten enthalten — hier wird nur dieselbe
|
||||
// Ablagefläche aufgespannt.
|
||||
html += `<div class="board-list projects-sortable" data-group="${esc(g)}" ${locked ? '' : 'ondragover="handleDragOver(event)" ondrop="handleDrop(event)"'}>`;
|
||||
groups[g].forEach(p=>{ html += renderBoardRow(p, allPeople, locked, w.label, BOARD_OPTS); });
|
||||
if(groups[g].length === 0) html += `<div class="hint" style="padding:6px 2px 12px">Keine Projekte — Projekt hierher ziehen.</div>`;
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
let lockNotice = '';
|
||||
// Sperr-Hinweis über der Projektliste. Er stand früher im Container der
|
||||
// Kürzel-Legende unter dem Board; seit die Legende entfallen ist, gehört er
|
||||
// dorthin, wo er gilt — über die Liste, die man nicht bearbeiten kann.
|
||||
if(locked){
|
||||
lockNotice = `<div class="lock-notice" style="margin-bottom:10px;padding:8px 12px;background:#fff5f5;border:1px solid #fc8181;border-radius:6px;color:#c53030;font-weight:bold;font-size:12px;">
|
||||
Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich.
|
||||
<button type="button" class="secondary small" style="margin-left:12px;" onclick="unlockCurrentWeek()">Entsperren</button>
|
||||
</div>`;
|
||||
html = `<div class="lock-notice">
|
||||
Diese Sitzung ist gesperrt. Bearbeiten nur nach Entsperren möglich.
|
||||
<button type="button" class="secondary small" onclick="unlockCurrentWeek()">Entsperren</button>
|
||||
</div>` + html;
|
||||
}
|
||||
|
||||
table.innerHTML = `<thead>${h1}${h2}</thead><tbody>${body}</tbody>`;
|
||||
|
||||
// Legende mit vollständigen Namen und Anwesenheitstracking
|
||||
let legendeHtml = lockNotice + `<div style="margin-bottom:8px; font-weight:bold; color:var(--accent);">Legende Projektkürzel${locked ? '' : ' (klick für Anwesenheit)'}:</div>`;
|
||||
const weekAttendance = w?.attendance || {};
|
||||
const legendeItems = [];
|
||||
allPeople.forEach(entry=>{
|
||||
if(!legendeItems.some(item=>item.id===entry.person.id)){
|
||||
legendeItems.push({id:entry.person.id, name:entry.person.name, fullName:entry.person.fullName||entry.person.name});
|
||||
}
|
||||
});
|
||||
legendeHtml += `<div class="legend-list">` + legendeItems.map(item=>{
|
||||
const isPresent = weekAttendance[item.id];
|
||||
const cursor = locked ? 'not-allowed' : 'pointer';
|
||||
const click = locked ? '' : `onclick="toggleAttendance('${item.id}')"`;
|
||||
return `<div class="legend-item${isPresent?' present':''}${locked ? ' locked-legend-item' : ''}" ${click}><span>${esc(item.name)}</span> = ${esc(item.fullName)}</div>`;
|
||||
}).join('') + `</div>`;
|
||||
document.getElementById('boardLegend').innerHTML = legendeHtml;
|
||||
adjustStickyOffsets();
|
||||
}
|
||||
|
||||
// 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']);
|
||||
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){
|
||||
if(!allowedTags.has(child.tagName.toUpperCase())){
|
||||
while(child.firstChild) node.insertBefore(child.firstChild, node);
|
||||
node.removeChild(child);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// getProgressColor: erzeugt eine HSL-Farbe basierend auf Prozentwert
|
||||
// (grün → gelb → rot). Parameter `pct`: Zahl 0–100 (wird geklemmt).
|
||||
// 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%)`;
|
||||
}
|
||||
|
||||
// adjustStickyOffsets: berechnet die reale Höhe der ersten Kopfzeile
|
||||
// der Board-Tabelle und setzt den top-Wert der zweiten sticky-Kopfzeile
|
||||
// entsprechend, damit beide Zeilen exakt aneinander anschließen
|
||||
// (keine Lücke/Überlappung beim Scrollen). Keine Parameter, keine Rückgabe.
|
||||
function adjustStickyOffsets(){
|
||||
const firstRow = document.querySelector('#boardTable thead tr:nth-child(1)');
|
||||
if(!firstRow) return;
|
||||
const h = firstRow.getBoundingClientRect().height;
|
||||
document.querySelectorAll('#boardTable thead tr:nth-child(2) th').forEach(th=>{
|
||||
th.style.top = h + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// toggleAttendance: togglet den Anwesenheitsstatus (boolean in `w.attendance`)
|
||||
// eines Projektbeteiligten für die aktuelle Sitzung und rendert das Board neu.
|
||||
// Parameter `personId`: stabile Personen-ID. Keine Rückgabe; bei gesperrter
|
||||
// Woche über `guardWeekLocked()` abgebrochen.
|
||||
function toggleAttendance(personId){
|
||||
if(guardWeekLocked()) return;
|
||||
const w = currentWeek();
|
||||
if(!w) return;
|
||||
if(!w.attendance) w.attendance = {};
|
||||
w.attendance[personId] = !w.attendance[personId];
|
||||
save();
|
||||
renderBoard();
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt zyklisch
|
||||
@@ -275,7 +653,7 @@ function cycleAssign(pid, personId, role){
|
||||
const cur = p.assign[key]||0;
|
||||
const next = (cur+1)%3;
|
||||
if(next===0) delete p.assign[key]; else p.assign[key]=next;
|
||||
save(); renderBoard();
|
||||
save(); renderBoard(); renderWochenbericht();
|
||||
}
|
||||
|
||||
// editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein
|
||||
@@ -287,36 +665,9 @@ function editLph(pid,lph,val){
|
||||
val = val.trim();
|
||||
if(val==="") delete p.lph[lph];
|
||||
else p.lph[lph]=val.replace('%','');
|
||||
save(); renderBoard();
|
||||
save(); renderBoard(); renderWochenbericht();
|
||||
}
|
||||
|
||||
// editProj: aktualisiert ein Feld eines Projekts; für Status wird zusätzlich
|
||||
// ein flag `statusState` gesetzt (='new' beim Anlegen eines neuen Kommentars),
|
||||
// wenn zuvor noch kein Status existierte. Parameter: `pid` (Projekt-ID),
|
||||
// `field` (Feldname), `val` (neuer Wert). Keine Rückgabe.
|
||||
function editProj(pid,field,val){
|
||||
if(guardWeekLocked()) return;
|
||||
const p = currentWeek().projects.find(x=>x.id===pid);
|
||||
if(!p) return;
|
||||
if(field==='status'){
|
||||
const before = (p.status||"").trim();
|
||||
const after = val.trim();
|
||||
if(before === "" && after !== ""){
|
||||
p.statusState = 'new';
|
||||
} else if(after === ""){
|
||||
delete p.statusState;
|
||||
}
|
||||
}
|
||||
p[field]=val; save();
|
||||
}
|
||||
|
||||
// delProj: löscht ein Projekt (per Bestätigungsdialog) aus der aktuellen
|
||||
// Sitzung. Parameter `pid`: Projekt-ID. Keine Rückgabe; bei gesperrter Woche
|
||||
// abgebrochen.
|
||||
function delProj(pid){
|
||||
if(guardWeekLocked()) return;
|
||||
if(!confirm("Projekt aus dieser Sitzung löschen?")) return;
|
||||
const w = currentWeek();
|
||||
w.projects = w.projects.filter(x=>x.id!==pid);
|
||||
save(); renderBoard();
|
||||
}
|
||||
// HINWEIS: delProj() ist entfallen — seit der neuen Zeilendarstellung des
|
||||
// Boards gibt es dort keinen Löschen-Knopf mehr. Projekte werden im
|
||||
// Projekte-Tab entfernt (delProjectFromList(), js/projects/projects.js).
|
||||
Reference in new issue
Block a user