Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und nur noch in den Projekteinstellungen editierbar (planCycleAssign()). Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen. Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst unabhaengig von den gespeicherten Protokollen - nichts davon wird abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter Import leicht 20 Vorschlaege liefert. Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt (Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog. Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe. plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit Zeilenzahl und Umsortierung nicht auseinanderlaufen. KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt (Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem alten Limit und brach bei jeder Schwankung ab. Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root, angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die Akzentleiste ist ein inset-Schatten statt eines positionierten Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens. Plan Checker: optionaler Projektabgleich des Plankopfs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
707 lines
38 KiB
JavaScript
707 lines
38 KiB
JavaScript
// -----------------------------
|
|
// Projektübersicht — Board (Listenansicht)
|
|
// -----------------------------
|
|
// 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()`,
|
|
// und `editLph()` bereit. Sie nutzt `currentWeek()`/
|
|
// `isWeekLocked()` aus `js/weeks.js`, `lpActiveList()` aus `js/load/load.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() ruft zusätzlich
|
|
// renderWochenbericht() (js/wochenbericht/wochenbericht.js) auf, da dessen
|
|
// Karten dieselben renderBoardDrawer()-Bedienelemente nutzen, aber in einem
|
|
// eigenen DOM-Container (#wochenberichtContent) liegen. Die Zuordnung der
|
|
// Beteiligten (p.assign) ist hier und im Wochenbericht nur noch Anzeige;
|
|
// editierbar ist sie allein über planCycleAssign() in
|
|
// js/plan/plan-settings.js — siehe renderBoardDrawer()s Kopfkommentar.
|
|
|
|
// 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 = {};
|
|
|
|
// berichtDisclosureState: merkt sich, ob die Anwesenheit im Wochenbericht
|
|
// (renderAttendance(), js/wochenbericht/wochenbericht.js) gerade auf- oder
|
|
// zugeklappt ist. Ursprünglich auch für Leistungsphasen/Beteiligte gedacht
|
|
// (damals beide ebenfalls Aufklapper, Fokus-Modus "C"); seit Eriks
|
|
// Rückmeldung zum Entwurfsvergleich ("Aktenregal") sind beide wieder
|
|
// dauerhaft sichtbar (renderBoardDrawer()s dichte Fassung weiter unten) und
|
|
// brauchen dieses Gedächtnis nicht mehr — nur die Anwesenheit bleibt ein
|
|
// Aufklapper. Ohne dieses Gedächtnis klappte sie bei jedem Neu-Rendern wieder
|
|
// auf ihren Vorgabewert zurück — und JEDE Änderung im Bericht (eine
|
|
// Zuordnung, ein LPH-Wert, ein Schritt im Vorwoche-Scrollrad) baut den ganzen
|
|
// Bericht neu auf. 'toggle' bubbelt nicht, ein Lauscher in der Capture-Phase
|
|
// sieht das Ereignis trotzdem auf dem Weg nach unten (gleiches Muster wie der
|
|
// scroll-Lauscher der Wandtafel).
|
|
const berichtDisclosureState = { anwesenheit: false };
|
|
document.addEventListener('toggle', function(e){
|
|
const key = e.target && e.target.dataset && e.target.dataset.berichtAbschnitt;
|
|
if(key) berichtDisclosureState[key] = e.target.open;
|
|
}, true);
|
|
|
|
// 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();
|
|
}
|
|
|
|
// 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');
|
|
// 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();
|
|
}
|
|
|
|
// 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).
|
|
|
|
// roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array
|
|
// (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role`
|
|
// (String oder Array). Rückgabe: Klassen-String, leer bei undefined.
|
|
function roleClass(role){
|
|
if(Array.isArray(role)){
|
|
return role.map(r=>'role-'+r.toLowerCase().replace(/\s+/g,'-')).join(' ');
|
|
}
|
|
return 'role-' + role.toLowerCase().replace(/\s+/g,'-');
|
|
}
|
|
|
|
// 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 und Fortschritt je Leistungsphase entstehen in der
|
|
// wöchentlichen Besprechung und werden deshalb allein im Wochenbericht
|
|
// gepflegt. Zuvor waren dieselben 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.
|
|
//
|
|
// Die Zuordnung der Beteiligten (p.assign, roleRows unten) gehörte früher
|
|
// zu dieser Gruppe, ist aber auf Eriks Wunsch umgezogen: ein Stammdatum,
|
|
// nur noch in den Projekteinstellungen editierbar
|
|
// (planCycleAssign(), js/plan/plan-settings.js). Hier — sowohl im
|
|
// Projektboard als auch im Wochenbericht — ist sie deshalb IMMER nur
|
|
// Anzeige, unabhängig von opts.nurAnsicht; cycleAssign() ist damit ohne
|
|
// Aufrufer und entfallen.
|
|
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 aktLp = lpActiveList(p)[0];
|
|
// Die amtierende Phase (aktLp) IMMER mit anzeigen, auch wenn sie nicht in
|
|
// beauftragteLP steht: aktLp kommt unabhängig von beauftragteLP aus
|
|
// lpActiveList() (js/load/load.js) - beide Felder können auseinanderlaufen
|
|
// (z. B. ein Projekt auf LP 8, aber nur LP 1/2/3/6 als beauftragt
|
|
// gepflegt). Ohne diese Ergänzung behauptete die Kopfzeile dieses
|
|
// Abschnitts ("LP 8 aktiv"), während LP 8 in der aufgeklappten Liste
|
|
// darunter gar nicht auftauchte und ihr Fortschritt dort nicht pflegbar
|
|
// war - gefunden beim Live-Test mit echten Projektdaten.
|
|
const sichtbareLphs = nurBeauftragte ? LPHS.filter(l => beauftragt.includes(l) || l === aktLp) : LPHS;
|
|
|
|
// 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;
|
|
return `<span class="board-avatar ${roleClass(entry.role)} state-${state} is-ansicht${locked ? ' locked-cell' : ''}" 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 (Wochenbericht), zweite Fassung (Eriks Rückmeldung zum
|
|
// Entwurfsvergleich "Aktenregal"): Leistungsphasen und Beteiligte stehen
|
|
// jetzt dauerhaft sichtbar nebeneinander statt hinter Aufklappern verborgen
|
|
// — "die Beteiligten sollen mit angezeigt werden". Die erste Fokus-Modus-
|
|
// Fassung hatte beide hinter <details> versteckt (Reaktion auf "zu
|
|
// überladen" bei allem gleichzeitig sichtbar); diesmal bleiben beide
|
|
// bewusst KOMPAKT, damit sie nebeneinander Platz finden, ohne den Kommentar
|
|
// ("im Vergleich zu groß") zu überragen: Leistungsphasen nutzen dieselbe
|
|
// Kästchen-Leiste wie das Projektboard (lphLeiste, oben in dieser Funktion
|
|
// berechnet — keine zweite, eigene LPH-Darstellung mehr für den
|
|
// Wochenbericht), Beteiligte ihre ohnehin schon kompakten Rollenzeilen
|
|
// (roleRows, s. u.).
|
|
//
|
|
// 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 ohne die Zeile wäre nicht erkennbar, in
|
|
// welcher Rolle genau jemand zugeordnet ist (dieselbe Zeilenform nutzt die
|
|
// jetzt editierbare Matrix der Projekteinstellungen, planCycleAssign(),
|
|
// js/plan/plan-settings.js).
|
|
if(opts && opts.dicht){
|
|
const lphInfo = aktLp
|
|
? esc(aktLp.replace('LPH ', 'LP ')) + ' aktiv' + (p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? ' · ' + esc(p.lph[aktLp]) + '%' : '')
|
|
: 'keine aktiv';
|
|
|
|
return `<div class="board-drawer board-drawer-dicht">
|
|
${renderCommentEditor(p, weekLabel, locked, true)}
|
|
<div class="bericht-meta-grid">
|
|
<div>
|
|
<div class="bericht-meta-label">${nurBeauftragte ? 'Beauftragte Leistungsphasen' : 'Leistungsphasen'} · ${lphInfo}</div>
|
|
${lphLeiste ? `<div class="board-lph-leiste">${lphLeiste}</div>` : '<span class="hint" style="margin:0">Keine gültige Leistungsphase hinterlegt — bitte in den Projekteinstellungen prüfen.</span>'}
|
|
</div>
|
|
<div>
|
|
<div class="bericht-meta-label">Beteiligte</div>
|
|
<div class="board-role-mini">${roleRows}</div>
|
|
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
|
|
</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>
|
|
${!locked ? `<button type="button" class="plan-verweis" style="margin-top:4px" onclick="springZuEinstellungen('${p.id}')">In den Projekteinstellungen bearbeiten →</button>` : ''}
|
|
</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 und Fortschritt 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 grouped = peopleByRole(w);
|
|
const allPeople = [];
|
|
ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry)));
|
|
|
|
// 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
|
|
Object.keys(groups).forEach(g=>{
|
|
groups[g].sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
});
|
|
|
|
let html = "";
|
|
sortGroups(Object.keys(groups)).forEach(g=>{
|
|
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>`;
|
|
});
|
|
// 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){
|
|
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;
|
|
}
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein
|
|
// Projekt. Parameter: `pid` (Projekt-ID), `lph` (Leistungsphasen-Schlüssel),
|
|
// `val` (Eingabewert). Leere/`%`-Werte werden bereinigt; keine Rückgabe.
|
|
function editLph(pid,lph,val){
|
|
if(guardWeekLocked()) return;
|
|
const p = currentWeek().projects.find(x=>x.id===pid);
|
|
val = val.trim();
|
|
if(val==="") delete p.lph[lph];
|
|
else p.lph[lph]=val.replace('%','');
|
|
save(); renderBoard();
|
|
if(typeof renderWochenbericht === 'function') renderWochenbericht();
|
|
}
|
|
|
|
// 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).
|