Protokoll-Import: KI-Erkennung (Gruppe, Teilnehmer, Status, Mehrfachpunkte), Ladeanzeige, Erstellungsdatum je Punkt

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-01 10:54:43 +02:00
1 parent 6f18848f86
commit 11eabe42ae
275 files changed
+71952 -3029

No files matched your search

+1661 -254
View File
File diff suppressed because it is too large. Load diff
+612 -235
View File
@@ -1,56 +1,68 @@
// -----------------------------
// 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 `Test.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 `Test.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 rendert dieselben Projektzeilen in einem eigenen
// Container. Ohne diesen Aufruf merkte er sich den Klick zwar, zeigte die
// Zeile aber erst nach einem Tab-Wechsel auf- bzw. zugeklappt.
if(typeof renderWochenbericht === 'function') renderWochenbericht();
}
// 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 +77,545 @@ 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 alle
// Leistungsphasen (nicht nur die beauftragten) in der ausführlichen Fassung,
// aber nichts davon ist hier bearbeitbar — siehe nurAnsicht in
// renderBoardDrawer().
const BOARD_OPTS = { nurAnsicht: 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 text = statusHtmlToEditText(p.status || '');
boardCommentBaseline[p.id] = text;
if(locked){
return `<div class="comment-block" data-pid="${esc(p.id)}">
<div class="comment-head-line">${commentAuthorLine(p)}</div>
<div class="comment-readonly">${text ? sanitizeStatusHtml(p.status) : '<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 = '';
return `<div class="comment-block" data-pid="${esc(p.id)}">
<div class="comment-editor">
${kopf}
<textarea class="comment-field" rows="3" placeholder="Was gibt es diese Woche zu diesem Projekt?"
${dicht ? 'title="Strg+↵ speichern · Esc verwerfen"' : ''}
oninput="markCommentDirty(this)"
onkeydown="boardCommentKey(event, this)"
onblur="boardCommentBlur(this)">${esc(text)}</textarea>
<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;
field.focus();
const ende = field.value.length;
try { field.setSelectionRange(ende, ende); } catch(e){ /* unterstützt nicht jedes Feld */ }
}
// 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.value === (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 text = c.field.value;
if(text === (boardCommentBaseline[c.pid] || '')) return;
const p = currentWeek().projects.find(x => x.id === c.pid);
if(!p) return;
const html = statusEditTextToHtml(text);
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] = text;
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.value = boardCommentBaseline[c.pid] || '';
markCommentDirty(c.field);
c.field.focus();
}
// 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.value = statusHtmlToEditText(html);
markCommentDirty(c.field);
c.field.focus();
}
// 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: im Wochenbericht 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
// alle Bestandsprojekte zutrifft —, bleibt es bei der vollständigen Liste,
// sonst stünde dort gar nichts. Im Projektboard sind immer alle Phasen zu
// sehen.
// 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];
const lphRows = 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;
const feld = ansicht
? `<span class="board-lph-wert">${v === "" ? '–' : esc(v) + ' %'}</span>`
: `<input type="text" value="${esc(v)}" ${inputAttr} placeholder="-">`;
return `<div class="board-lph-row${aktiv ? ' lp-aktiv' : ''}${ansicht ? ' is-ansicht' : ''}${locked ? ' locked-cell' : ''}"${aktiv ? ' title="Amtierende Leistungsphase"' : ''}>
<span class="board-lph-label">${esc(l)}</span>
<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>
${feld}
</div>`;
}).join('');
// lphLeiste: dieselben Werte wie lphRows, nur nebeneinander. Jede Phase ist
// ein schmales Feld mit der Phasennummer und ihrem Eingabefeld; der
// Füllstand steckt als senkrechter Verlauf im Feld-Hintergrund, damit der
// Fortschrittsbalken nicht verlorengeht. 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' : ''}${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-list">${lphRows}</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 +623,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 +659,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,7 +671,7 @@ 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
@@ -310,13 +694,6 @@ function editProj(pid,field,val){
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).
-42
View File
@@ -1,42 +0,0 @@
// -----------------------------
// Projektübersicht — CSV/ZIP-Export
// -----------------------------
// Stellt exportCSV() bereit: lädt den ZIP mit den übersichtlichen CSV-Dateien
// der aktuellen Abteilung über GET /export-csv und stößt den Download an.
// Hängt im Seitenmenü von Test.html am Button "CSV Export" und nutzt baseUrl()
// aus js/server.js sowie currentDepartment aus js/globals.js. Der ZIP enthält
// pro logischer Entität eine CSV (Wochen, Personen, Projekte, …), erzeugt vom
// Server (siehe server.js im Projekt-Root).
// exportCSV: fordert den CSV-Export der aktuellen Abteilung an und lädt ihn
// als ZIP-Datei herunter. Kein Parameter, keine Rückgabe; Fehler werden über
// ein Alert gemeldet.
async function exportCSV(){
try {
const dept = currentDepartment || 'ELT';
const res = await fetch(baseUrl() + '/export-csv?department=' + encodeURIComponent(dept));
if(!res.ok){
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
if(json && json.error) msg = json.error;
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
const blob = await res.blob();
const filename = 'Projektuebersicht_' + dept + '_'
+ new Date().toISOString().slice(0, 10) + '.zip';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch(err){
console.error('CSV-Export fehlgeschlagen:', err);
alert('CSV-Export fehlgeschlagen: ' + (err.message || err));
}
}
+56 -73
View File
@@ -1,19 +1,16 @@
// -----------------------------
// Projektübersicht — GANTT-Diagramm (gemeinsam genutzt)
// -----------------------------
// Gemeinsames Modul für das GANTT-Diagramm: Es wird von Test.html (nach
// js/load/load.js) UND von project-plan.html geladen – dort zusammen mit globals,
// utils, ui, date-picker, csv, server und den js/plan/*-Modulen, aber OHNE
// js/store.js, js/weeks.js, js/board/board.js usw. Es darf daher auf der Plan-Seite
// keine Hauptseiten-spezifischen Funktionen voraussetzen; Funktionen, die
// currentWeek()/isWeekLocked() aus js/weeks.js brauchen (renderGantt,
// fitGanttRange, openProjectPlan), werden nur vom Hauptfenster aufgerufen.
// Das Modul stellt u. a. buildGanttHtml(), renderGantt(), fitGanttRange(),
// projectAssignees(), ganttRangeFromProjects() sowie openProjectPlan()/
// findProjectForPlan() bereit. Die Plan-Seite erweitert buildGanttHtml() über
// js/plan/plan-build-gantt.js und bezieht Beteiligte/Range über
// projectAssignees()/ganttRangeFromProjects() aus diesem Modul.
// Gemeinsames Modul für das GANTT-Diagramm. Es wird von Test.html nach
// js/load/load.js geladen und ist die Grundlage sowohl der Übersichts-
// Diagramme als auch des Terminplans im Projektbereich: die Module unter
// js/plan/ setzen darauf auf und müssen deshalb NACH dieser Datei stehen.
// Das Modul stellt u. a. buildGanttHtml(), projectAssignees(),
// ganttRangeFromProjects() sowie openProjectPlan()/closePlanPage()/
// findProjectForPlan() bereit. js/plan/plan-build-gantt.js umhüllt
// buildGanttHtml() und ergänzt sie um TODO- und Meilensteinzeilen; diese
// Erweiterung greift nur bei gesetzter Option showTodos, die Aufrufe der
// Übersicht bleiben also unverändert.
// ganttModalProjectId: reservierte Projekt-ID für GANTT-Modal-Dialoge (zurzeit ungenutzt).
let ganttModalProjectId = null;
@@ -35,7 +32,7 @@ function ensurePlan(p){
function projectAssignees(p, week = null){
const w = week || currentWeek();
const people = w ? (w.people || []) : [];
const globalList = db.globalPeople[currentDepartment] || [];
const globalList = db.globalPeople || [];
const result = [];
people.forEach(person => {
const globalPerson = globalList.find(gp => gp.id === person.id);
@@ -63,7 +60,12 @@ function projectColor(p){
// Projekt-/TODO-/Meilenstein-Terminen sowie den Abwesenheiten der globalen
// Personen, jeweils erweitert um padDays Tage (Standard 7). Parameter:
// projects = Projekt-Array, padDays = Puffer; Rückgabe: { start, end } als Date.
function ganttRangeFromProjects(projects, padDays = 7){
// Parameter `personIds` (optional): Beschränkt die berücksichtigten
// Abwesenheiten auf diese Personen. Ohne die Einschränkung zog die Elternzeit
// eines völlig unbeteiligten Kollegen den Zeitraum jedes Projektbereichs bis
// weit in die Zukunft — obwohl die zugehörige Urlaubszeile gar nicht gezeichnet
// wird (renderPlanGantt() filtert sie auf die Projektbeteiligten).
function ganttRangeFromProjects(projects, padDays = 7, personIds = null){
let min = null, max = null;
projects.forEach(p => {
const plan = p.plan;
@@ -89,8 +91,10 @@ function ganttRangeFromProjects(projects, padDays = 7){
});
});
});
// Urlaub berücksichtigen
(db.globalPeople[currentDepartment] || []).forEach(person => {
// Urlaub berücksichtigen (nur von Personen, deren Zeilen auch gezeichnet werden)
const relevant = personIds ? new Set(personIds) : null;
(db.globalPeople || []).forEach(person => {
if(relevant && !relevant.has(person.id)) return;
(person.absences || []).forEach(a => {
const s = parseDateISO(a.start), e = parseDateISO(a.end);
if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; }
@@ -105,37 +109,11 @@ function ganttRangeFromProjects(projects, padDays = 7){
return { start: addDays(min, -padDays), end: addDays(max, padDays) };
}
// fitGanttRange: setzt die Datumsfelder (ganttStart/ganttEnd) auf den
// automatisch berechneten Projekt-Zeitrahmen und rendert das Diagramm neu.
function fitGanttRange(){
const w = currentWeek(); if(!w) return;
const range = ganttRangeFromProjects(w.projects, 7);
document.getElementById('ganttStart').value = formatDateISO(range.start);
document.getElementById('ganttEnd').value = formatDateISO(range.end);
renderGantt();
}
// renderGantt: rendert das GANTT-Diagramm der aktuellen Woche in
// ganttContainer. Nutzt die Datumsfelder bzw. fällt auf den Projekt-Zeitrahmen
// zurück; bei gesperrter Woche ist die Ansicht nicht editierbar.
function renderGantt(){
const w = currentWeek();
const container = document.getElementById('ganttContainer');
if(!w || !container){
if(container) container.innerHTML = '<div class="hint">Keine Sitzung ausgewählt.</div>';
return;
}
let start = parseDateISO(document.getElementById('ganttStart').value);
let end = parseDateISO(document.getElementById('ganttEnd').value);
if(!start || !end){
const range = ganttRangeFromProjects(w.projects, 7);
start = range.start; end = range.end;
document.getElementById('ganttStart').value = formatDateISO(start);
document.getElementById('ganttEnd').value = formatDateISO(end);
}
const showVacation = document.getElementById('ganttShowVacation').checked;
container.innerHTML = buildGanttHtml(w.projects, start, end, { showVacation, editable: !isWeekLocked(w) });
}
// HINWEIS: Die frühere GANTT-Ansicht der Hauptseite (renderGantt/fitGanttRange)
// ist entfallen. Sie griff auf die Elemente #ganttContainer/#ganttStart/
// #ganttEnd/#ganttShowVacation zu, die es in keiner der drei HTML-Seiten mehr
// gibt — ein Aufruf hätte sofort geworfen. Das GANTT lebt heute ausschließlich
// im Projektbereich (renderPlanGantt/fitPlanGanttRange in js/plan/plan-gantt.js).
// buildGanttHtml: erzeugt das HTML des GANTT-Diagramms (Kopf mit Monaten/
// KW-Zellen, Wochenend-Hintergrund, Heute-Linie, Urlaubs- und Projektzeilen
@@ -250,7 +228,7 @@ function buildGanttHtml(projects, start, end, options = {}){
// Urlaubszeilen
if(showVacation){
let allPeople = db.globalPeople[currentDepartment] || [];
let allPeople = db.globalPeople || [];
// Optional: nur Urlaub bestimmter Personen anzeigen
if(options.vacationPeople && Array.isArray(options.vacationPeople)){
const allowed = new Set(options.vacationPeople);
@@ -327,35 +305,40 @@ function buildGanttHtml(projects, start, end, options = {}){
// ---------- Projektplan-Navigation ----------
// openProjectPlan: öffnet project-plan.html in einem neuen Tab. Weil
// Projekt-IDs nicht persistent sind, werden Wochen-Label und Proj.-Nr. als
// URL-Parameter übergeben; Abteilung und Projektname stehen im Pfad nach dem
// Schema "/<Abteilung>/Projektplan/<Projektname>". Parameter: pid =
// Projekt-ID der aktuellen Woche. Kein Rückgabewert.
// openProjectPlan: öffnet den Projektbereich EINES Projekts. Das war einmal
// eine Navigation auf ein eigenes Dokument (project-plan.html); inzwischen
// ist der Projektbereich der Bereich #plan derselben Seite, also genügt ein
// gewöhnlicher Bereichswechsel — Menüband und Seitenleiste bleiben stehen,
// die Sitzungsdaten sind schon geladen, nichts blitzt weiß auf.
// Weil Projekt-IDs beim Duplizieren einer Sitzung neu vergeben werden,
// merkt sich der Bereich stabile Werte (Sitzungs-Label, Projekt-Nr./-Name);
// aus ihnen baut updateRouteUrl() auch die verschickbare Adresse
// "/Projektplan/<Projektname>?weekLabel=…&projNr=…".
// Parameter: pid = Projekt-ID der aktuellen Sitzung. Kein Rückgabewert.
function openProjectPlan(pid){
const w = currentWeek(); if(!w) return;
const p = w.projects.find(x => x.id === pid); if(!p) return;
// Projekt-IDs sind in der DB stabil, aber die Plan-Seite ist eine separate
// Route. Daher werden stabile Werte (Label, Projekt-Nr.) in der URL übergeben.
// Absoluter Pfad mit Abteilung und Projektname als Route-Segment, damit der
// Aufruf auch von Deep-Link-URLs wie "/HLS/Auslastung" funktioniert.
const url = new URL('/' + currentDepartment + '/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin);
url.searchParams.set('weekLabel', w.label);
url.searchParams.set('projNr', p.nr || '');
window.open(url.toString(), '_blank');
setPlanTarget(w.label, p.nr || '', p.name || '');
showTab('plan');
window.scrollTo(0, 0);
}
// Diese Funktion wird von project-plan.html aufgerufen, um das aktuelle
// Wochen-/Projekt-Objekt im Hauptfenster zu finden.
// Parameter: department, weekId, projectId; Rückgabe: { week, project } oder
// null, wenn die Woche/Abteilung nicht (synchron) auflösbar ist.
function findProjectForPlan(department, weekId, projectId){
if(currentDepartment !== department){
const targetWeek = db.weeks.find(w => w.id === weekId);
if(!targetWeek) return null;
// temporär umschalten ist unpraktisch, daher versuchen wir über load-all
return null;
}
// closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf
// in der Kopfzeile des Projektbereichs. Früher ging es hier in der
// Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war.
// Jetzt ist es ein schlichter Bereichswechsel zurück auf das Projektboard
// (bzw. auf den Bereich, aus dem geöffnet wurde). Kein Parameter, keine
// Rückgabe.
function closePlanPage(){
const back = (typeof previousTab === 'string' && previousTab && previousTab !== 'plan') ? previousTab : 'board';
showTab(back);
}
// findProjectForPlan: findet Sitzung und Projekt anhand ihrer IDs im
// geladenen Bestand.
// Parameter: weekId, projectId; Rückgabe: { week, project } oder null, wenn
// die Woche nicht auflösbar ist.
function findProjectForPlan(weekId, projectId){
const w = db.weeks.find(x => x.id === weekId);
if(!w) return null;
return { week: w, project: w.projects.find(x => x.id === projectId) };
+22 -25
View File
@@ -4,25 +4,23 @@
// Stellt importDb() bereit: wählt über einen Datei-Dialog eine mit "DB Backup"
// erzeugte .db-Datei aus, lädt sie als rohen Binär-Body an POST /import und
// lädt nach erfolgreicher Wiederherstellung die Seite neu. Da der Import den
// kompletten Datenbestand ersetzt, ist er passwortgeschützt (gleiche Logik wie
// Wochen-Löschung über requestedAction/isAuthenticated, siehe js/tabs-auth.js
// und js/weeks.js) und wird zusätzlich im Browser per Bestätigungsdialog
// abgesichert.
// kompletten Datenbestand ersetzt, ist die Route serverseitig admin-only
// abgesichert (requireRole('admin'), server.js); der Button ist im UI
// zusätzlich nur für Admins sichtbar (js/auth.js: renderUserBar()). Zusätzlich
// wird der Import im Browser per Bestätigungsdialog abgesichert.
// importDb: öffnet bei Bedarf das Passwort-Modal (requestedAction 'importDb')
// und startet danach den Datei-Dialog für das Backup. Parameter `skipAuth=true`
// überspringt die Passwortabfrage (wird von confirmPassword() genutzt).
// Keine Rückgabe.
function importDb(skipAuth = false){
if(!skipAuth && !isAuthenticated){
requestedAction = { type: 'importDb' };
document.getElementById('passwordModal').style.display = 'flex';
document.getElementById('passwordInput').value = '';
document.getElementById('passwordInput').focus();
return;
}
if(!confirm('Datenbank-Backup wirklich wiederherstellen?\n\nAlle aktuellen Daten werden ersetzt.')){
requestedAction = null;
// importDb: bestätigt die destruktive Aktion und startet danach den
// Datei-Dialog für das Backup. Keine Rückgabe.
function importDb(){
// Genau benennen, was ersetzt wird: Der Server tauscht nur die
// Sitzungstabellen (IMPORT_TABLES in server.js). Benutzerkonten, Rollen,
// Abwesenheiten, hochgeladene Dateien und deren Freigaben bleiben stehen —
// die frühere Formulierung "Alle aktuellen Daten werden ersetzt" führte in
// die Irre, etwa beim Versuch, einen gelöschten Benutzer zurückzuholen.
if(!confirm('Datenbank-Backup wirklich wiederherstellen?\n\n'
+ 'ERSETZT WERDEN: alle Sitzungen mit Projekten, Zuordnungen, Kommentaren, Terminplänen und Aufgaben.\n\n'
+ 'NICHT BETROFFEN: Benutzerkonten, Rollen, Abwesenheiten, hochgeladene Dateien und deren Freigaben.\n\n'
+ 'Der aktuelle Stand wird vorher automatisch als Backup gesichert.')){
return;
}
const input = document.createElement('input');
@@ -30,14 +28,10 @@ function importDb(skipAuth = false){
input.accept = '.db,application/octet-stream,application/x-sqlite3';
input.onchange = () => {
const file = input.files && input.files[0];
if(!file){
requestedAction = null;
return;
}
if(!file) return;
uploadBackup(file);
};
input.click();
requestedAction = null;
}
// uploadBackup: lädt die gewählte .db-Datei als rohen Binär-Body an
@@ -51,6 +45,7 @@ async function uploadBackup(file){
body: file
});
if(!res.ok){
if(res.status === 403){ alert('Nur Administratoren dürfen die Datenbank importieren.'); return; }
let msg = 'Server HTTP ' + res.status;
try {
const json = await res.json();
@@ -58,10 +53,12 @@ async function uploadBackup(file){
} catch(e){ /* Antwort war kein JSON */ }
throw new Error(msg);
}
alert('Datenbank-Backup erfolgreich wiederhergestellt.\nDie Seite wird neu geladen.');
alert('Sitzungsdaten aus dem Backup wiederhergestellt.\n\n'
+ 'Benutzerkonten, Abwesenheiten und hochgeladene Dateien wurden dabei nicht verändert.\n'
+ 'Die Seite wird neu geladen.');
window.location.reload();
} catch(err){
console.error('DB-Import fehlgeschlagen:', err);
alert('DB-Import fehlgeschlagen: ' + (err.message || err));
}
}
}
+11 -5
View File
@@ -959,10 +959,16 @@ async function exportProtokollPDF(protokollId){
return cell;
};
const nummer = nummerById.get(p.id) || '';
const nummerText = p.neu
? { text: [nummer, { text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' }] }
: { text: nummer };
const statusCell = zelle({ text: p.erledigt ? 'erledigt' : 'offen', alignment: 'center' });
// Erstellungsdatum steht ausgegraut unter der Nummer statt in
// einer eigenen Spalte — auf Eriks Wunsch, damit die Tabelle
// keine zusätzliche Spalte bekommt.
const erstelltText = formatDateDE(parseDateISO(p.erstelltAm));
const nummerParts = [nummer];
if(p.neu) nummerParts.push({ text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' });
if(erstelltText) nummerParts.push({ text: '\n' + erstelltText, fontSize: 5.5, bold: false, color: '#86868B' });
const nummerText = { text: nummerParts };
const statusLabel = { offen: 'offen', erledigt: 'erledigt', info: 'Info' }[p.status] || 'offen';
const statusCell = zelle({ text: statusLabel, alignment: 'center' });
body.push([
zelle(nummerText, { bold: true }),
zelle({ text: [p.thema ? { text: p.thema, bold: true } : '', p.text ? { text: (p.thema ? '\n' : '') + p.text } : ''] }),
@@ -971,7 +977,7 @@ async function exportProtokollPDF(protokollId){
statusCell
]);
});
punkteBlock.push({ table: { widths: [28, '*', 72, 42, 30], body }, layout: pdfWbGridLayout(true), margin: [0, 0, 0, 8] });
punkteBlock.push({ table: { widths: [34, '*', 72, 42, 30], body }, layout: pdfWbGridLayout(true), margin: [0, 0, 0, 8] });
});
}
+113 -326
View File
@@ -1,242 +1,98 @@
// -----------------------------
// Projektübersicht — Personen-Verwaltung (global pro Abteilung)
// Projektübersicht — Personen-Ansicht (global, NUR LESEND)
// -----------------------------
// Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `Test.html`)
// und verwaltet die globale Personenliste pro Abteilung: Anlegen, Bearbeiten,
// Löschen, Rollen-Auswahl, Drag-and-Drop-Sortierung sowie Urlaub/Abwesenheiten.
// Sie wird von `Test.html` nach `js/board/board.js` (vor `js/projects/projects.js`) geladen
// und stellt globale Funktionen wie `addPerson()`, `delPerson()`,
// `editPerson()`, `renderPeople()` und `addAbsence()` bereit. Genutzt wird sie
// u.a. von `js/board/board.js` (`renderBoard`), `js/gantt.js` und
// `js/plan/plan-modals.js`; die Sortier-Hilfe `getDragAfterElement()` liegt in
// `js/projects/projects.js`.
// als reine Anzeige. Seit der Ablösung von people/person_roles/person_absences
// (server.js) werden Projektbeteiligte ausschließlich in der zentralen
// Benutzerverwaltung (Bereich „Admin“) gepflegt; Anlegen/Bearbeiten/Löschen/
// Drag-and-Drop-Sortierung/Abwesenheits-Formulare gibt es hier bewusst nicht
// mehr. EINE Ausnahme ist geblieben bzw. wieder dazugekommen: der
// Teilnehmerkreis der aktuellen Sitzung (`currentWeek().people`). Er entsteht
// nur beim Anlegen einer neuen Sitzung und wird beim Duplizieren
// unverändert übernommen — ohne die Schaltflächen hier ("Zur Sitzung
// hinzufügen"/"Aus Sitzung entfernen") wäre ein neu angelegter Benutzer in
// keiner laufenden Sitzung zuordenbar, weder im Board noch in der
// Auslastung noch in der Anwesenheit. Die Personendaten selbst kommen unverändert über `db.globalPeople`
// (befüllt via `GET /load-all`, serverseitig jetzt aus `users`/`user_roles`/
// `user_absences` zusammengesetzt, firmenweit ohne Abteilungstrennung). Sie
// wird von `Test.html` nach `js/board/board.js` (vor `js/projects/projects.js`)
// geladen und stellt die globale Funktion `renderPeople()` bereit, die u.a.
// von `js/tabs-auth.js` (`showTab()`) und `js/init.js` aufgerufen wird.
// addPerson: fügt eine neue Person der globalen Liste hinzu und
// synchronisiert sie in die aktuelle Sitzung.
function addPerson(){
if(guardWeekLocked()) return;
const w = currentWeek();
if(!w) return;
const name = document.getElementById('newPersonName').value.trim();
const fullName = document.getElementById('newPersonFullName').value.trim();
const email = document.getElementById('newPersonEmail').value.trim();
if(!name){ alert("Kürzel eingeben."); return; }
const roles = [...document.querySelectorAll('input[name="newPersonRoles"]:checked')].map(el=>el.value);
if(roles.length===0){ alert("Bitte mindestens eine Rolle auswählen."); return; }
const person = {id:personIdForName(name), name, fullName:fullName||name, email:email, role:roles};
// Globale Liste aktualisieren
if(!Array.isArray(db.globalPeople[currentDepartment])) db.globalPeople[currentDepartment] = [];
const existing = db.globalPeople[currentDepartment].find(p => p.id === person.id);
if(existing){
alert('Eine Person mit diesem Kürzel existiert bereits.');
return;
}
db.globalPeople[currentDepartment].push(person);
// Aktuelle Sitzung aktualisieren
if(!Array.isArray(w.people)) w.people = [];
if(!w.people.find(p => p.id === person.id)){
w.people.push(JSON.parse(JSON.stringify(person)));
}
document.getElementById('newPersonName').value='';
document.getElementById('newPersonFullName').value='';
document.getElementById('newPersonEmail').value='';
document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false);
save({people:true}); renderPeople(); renderBoard();
}
// delPerson: entfernt eine Person aus der globalen Liste. Bereits in
// historischen Sitzungen verwendete Personen bleiben dort sichtbar, da
// jede Sitzung ihre eigene Kopie der Personendaten speichert.
function delPerson(id){
if(!confirm("Person global löschen? Sie bleibt in bereits existierenden Sitzungen sichtbar, wird aber in neuen Sitzungen nicht mehr angeboten.")) return;
// Erst prüfen, ob die Person in der aktuellen Sitzung noch zugeordnet ist –
// erst danach die globalen Listen verändern, sonst wäre die globale Löschung
// trotz Abbruch bereits durchgeführt (und würde über save() persistiert).
const w = currentWeek();
if(w){
const inUse = w.projects.some(p => Object.keys(p.assign).some(k => k.startsWith(id + '|')));
if(inUse){
alert('Die Person ist in der aktuellen Sitzung noch Projekten zugeordnet. Bitte zuerst die Zuordnungen entfernen.');
return;
}
}
// Aus globaler Liste entfernen
if(Array.isArray(db.globalPeople[currentDepartment])){
db.globalPeople[currentDepartment] = db.globalPeople[currentDepartment].filter(p=>p.id!==id);
}
// Aus aktueller Sitzung entfernen
if(w){
w.people = (w.people || []).filter(p=>p.id!==id);
}
save({people:true}); renderPeople(); renderBoard();
}
// editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der
// aktuellen Sitzung. Historische Sitzungen bleiben unverändert.
// Bei Feld `name` wird die ID migriert (`personIdForName`) und die
// Zuordnungen/Anwesenheiten werden umgeschrieben. Parameter: `id`
// (Personen-ID), `field` (Feldname), `val` (neuer Wert). Keine Rückgabe.
function editPerson(id,field,val){
if(guardWeekLocked()) return;
const w = currentWeek();
if(!w) return;
// Global aktualisieren
const globalPeople = db.globalPeople[currentDepartment] || [];
const globalPerson = globalPeople.find(p => p.id === id);
if(field === 'name'){
const newName = val.trim();
if(!newName) return;
const newId = personIdForName(newName);
if(newId !== id && globalPeople.some(p => p.id === newId)){
alert('Dieses Kürzel wird bereits verwendet.');
return;
}
if(globalPerson){
globalPerson.name = newName;
globalPerson.id = newId;
}
// Aktuelle Sitzung aktualisieren und Zuordnungen migrieren
const localPerson = (w.people || []).find(p => p.id === id);
if(localPerson){
localPerson.name = newName;
localPerson.id = newId;
}
w.projects.forEach(p => {
const newAssign = {};
Object.keys(p.assign).forEach(k => {
const [oldId, role] = k.split('|');
if(oldId === id){
newAssign[assignKey(newId, role)] = p.assign[k];
} else {
newAssign[k] = p.assign[k];
}
});
p.assign = newAssign;
if(p.plan && Array.isArray(p.plan.people)){
p.plan.people = p.plan.people.map(pid => pid === id ? newId : pid);
}
});
if(w.attendance){
if(w.attendance[id]){
w.attendance[newId] = true;
delete w.attendance[id];
}
}
save({people:true}); renderPeople(); renderBoard();
return;
}
if(field === 'role'){
const roles = Array.isArray(val) ? [...new Set(val)] : val ? [val] : [];
if(globalPerson) globalPerson.role = roles;
const localPerson = (w.people || []).find(p => p.id === id);
if(localPerson) localPerson.role = roles;
} else {
if(globalPerson) globalPerson[field] = val;
const localPerson = (w.people || []).find(p => p.id === id);
if(localPerson) localPerson[field] = val;
}
save({people:true}); renderPeople(); renderBoard();
}
// togglePersonRole: fügt eine Rolle (`role`) zur Person `id` hinzu bzw. entfernt
// sie (je nach Checkbox-Zustand `checked`), sowohl global als auch in der
// aktuellen Sitzung. Keine Rückgabe.
function togglePersonRole(id, role, checked){
if(guardWeekLocked()) return;
const w = currentWeek();
if(!w) return;
const globalPeople = db.globalPeople[currentDepartment] || [];
const globalPerson = globalPeople.find(p => p.id === id);
const localPerson = (w.people || []).find(p => p.id === id);
function updateRoles(person){
person.role = Array.isArray(person.role) ? [...person.role] : person.role ? [person.role] : [];
if(checked){
if(!person.role.includes(role)) person.role.push(role);
} else {
person.role = person.role.filter(r=>r!==role);
}
}
if(globalPerson) updateRoles(globalPerson);
if(localPerson) updateRoles(localPerson);
save({people:true}); renderPeople(); renderBoard();
}
// renderPeople: baut die HTML-Liste der Personen für den Personen-Tab auf
// (Karten mit Bearbeitungsfeldern, Rollen-Checkboxen, aufklappbaren
// Abwesenheiten und Drag-Handle). Bei gesperrter Sitzung wird das
// Abwesenheits-Formular ausgeblendet. Keine Parameter, keine Rückgabe.
// renderPeople: baut die schreibgeschützte Liste (`.people-readonly`, eine
// Bubble-Card mit den Personen als Listenzeilen) der Personen für den
// Personen-Tab auf (Zeilen mit Kürzel, Vollname, E-Mail, Rollen,
// aufklappbaren Abwesenheiten). Kein Formular, keine Lösch-/
// Bearbeiten-Aktionen, kein Drag-Handle. Zeigt zusätzlich einen Hinweis auf
// die zentrale Benutzerverwaltung (Link nur für Admins sichtbar). Keine
// Parameter, keine Rückgabe.
function renderPeople(){
const people = db.globalPeople[currentDepartment] || [];
const locked = isWeekLocked(currentWeek());
let html = `<div class="people-sortable"
ondragover="handlePersonDragOver(event)"
ondrop="handlePersonDrop(event)">`;
people.forEach(p=>{
const people = db.globalPeople || [];
const isAdmin = typeof currentUser !== 'undefined' && !!currentUser && currentUser.role === 'admin';
const w = currentWeek();
const inWeek = new Set((w && Array.isArray(w.people) ? w.people : []).map(p => p.id));
const locked = !w || isWeekLocked(w);
let html = `<div class="hint" style="margin-bottom:12px">Projektbeteiligte werden zentral in der Benutzerverwaltung gepflegt.`
+ (isAdmin ? ` <a href="/Admin#benutzer" onclick="showAdminSection('benutzer');return false;">Zur Benutzerverwaltung</a>` : '')
+ `</div>`;
html += `<div class="hint" style="margin-bottom:12px">Wer in der aktuellen Sitzung`
+ (w ? ` <b>${esc(w.label)}</b>` : '')
+ ` mitwirkt, erscheint als Spalte im Projektboard, in der Auslastung und in der Anwesenheit.`
+ (locked ? ` Die Sitzung ist gesperrt — der Kreis kann nicht geändert werden.` : '')
+ `</div>`;
if(people.length === 0){
html += '<div class="hint">Keine Personen vorhanden.</div>';
document.getElementById('peopleList').innerHTML = html;
return;
}
html += `<div class="people-readonly">`;
people.forEach(p => {
const isOpen = personAbsenceOpen[p.id];
const roles = Array.isArray(p.role) ? p.role : [];
const dabei = inWeek.has(p.id);
// Die Schaltfläche liegt IM Kopfbereich, der das Aufklappen auslöst —
// stopPropagation verhindert, dass ein Klick darauf zusätzlich die
// Abwesenheiten auf- oder zuklappt.
const weekAction = locked ? '' : (dabei
? `<button type="button" class="secondary small" onclick="event.stopPropagation(); removePersonFromCurrentWeek('${esc(p.id)}')">Aus Sitzung entfernen</button>`
: `<button type="button" class="small" onclick="event.stopPropagation(); addPersonToCurrentWeek('${esc(p.id)}')">Zur Sitzung hinzufügen</button>`);
html += `<div class="person-card${isOpen ? ' open' : ''}" data-person-id="${p.id}">`;
html += `<div class="person-header" onclick="togglePersonAbsences('${p.id}')">
${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px" onclick="event.stopPropagation()" ondragstart="handlePersonDragStart(event)" ondragend="handlePersonDragEnd(event)" draggable="true">⋮⋮</span>`}
<span class="person-toggle">${isOpen ? '▼' : '▶'}</span>
<input type="text" value="${esc(p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','name',this.value)" style="width:80px;" placeholder="Kürzel">
<input type="text" value="${esc(p.fullName||p.name)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','fullName',this.value)" style="width:180px;" placeholder="Voller Name">
<input type="text" value="${esc(p.email)}" onclick="event.stopPropagation()" onchange="editPerson('${p.id}','email',this.value)" style="width:180px;" placeholder="E-Mail">
<div class="role-checkboxes" onclick="event.stopPropagation()">${ROLES.map(rr=>`<label><input type="checkbox" onchange="togglePersonRole('${p.id}','${rr}',this.checked)" value="${rr}" ${p.role.includes(rr)?'checked':''}> ${rr}</label>`).join('')}</div>
<button class="danger small" onclick="event.stopPropagation(); delPerson('${p.id}')">Löschen</button>
html += `<div class="person-header" onclick="togglePersonAbsences('${esc(p.id)}')">
<span class="person-toggle">${icon('chevronRight')}</span>
<strong>${esc(p.name)}</strong>
<span>${esc(p.fullName || '')}</span>
<span class="hint" style="margin:0">${esc(p.email || '')}</span>
<span>${roles.length ? roles.map(r => esc(r)).join(', ') : '<span class="hint" style="margin:0">Keine Rolle</span>'}</span>
<span class="person-week-state">${dabei ? 'in dieser Sitzung' : '<span class="hint" style="margin:0">nicht in dieser Sitzung</span>'}</span>
${weekAction}
</div>`;
// Aufklappbare Abwesenheiten
html += `<div class="person-absences" style="display:${isOpen ? 'block' : 'none'}">`;
const absences = p.absences || [];
const absences = Array.isArray(p.absences) ? p.absences : [];
if(absences.length > 0){
html += `<div class="absence-list">`;
absences.forEach(a => {
html += `<div class="absence-chip">
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
${a.note ? `<small>${esc(a.note)}</small>` : ''}
${locked ? '' : `<button class="danger small" onclick="deleteAbsence('${p.id}','${a.id}')">×</button>`}
</div>`;
});
html += `</div>`;
}
if(!locked){
html += `<div class="absence-form">
<input type="text" id="absenceRange_${p.id}" class="range-picker-display" placeholder="Zeitraum wählen…" readonly
onclick="event.stopPropagation(); openRangePicker({ anchor: this, startInputId: 'absenceStart_${p.id}', endInputId: 'absenceEnd_${p.id}', displayId: 'absenceRange_${p.id}' })">
<input type="hidden" id="absenceStart_${p.id}">
<input type="hidden" id="absenceEnd_${p.id}">
<select id="absenceType_${p.id}">
<option>Urlaub</option>
<option>Krank</option>
<option>Fortbildung</option>
<option>Sonstiges</option>
</select>
<input type="text" id="absenceNote_${p.id}" placeholder="Notiz (optional)" style="width:140px">
<button class="secondary small" onclick="addAbsence('${p.id}')">+ Abwesenheit</button>
</div>`;
} else {
html += `<div class="hint">Keine Abwesenheiten hinterlegt.</div>`;
}
html += `</div></div>`;
});
html += `</div>`;
if(people.length===0) html = '<div class="hint">Keine Personen vorhanden.</div>';
document.getElementById('peopleList').innerHTML=html;
document.getElementById('peopleList').innerHTML = html;
}
// personAbsenceOpen: UI-Zustand, welche Personen-Karte ihre Abwesenheiten
// aufgeklappt darstellt (Key = Personen-ID, Wert = boolean).
// aufgeklappt darstellt (Key = Personen-ID, Wert = boolean). Reine Anzeige-
// Steuerung, wird nicht persistiert.
let personAbsenceOpen = {};
// togglePersonAbsences: klappt die Abwesenheits-Liste einer Person auf bzw. zu
@@ -246,122 +102,53 @@ function togglePersonAbsences(personId){
renderPeople();
}
// draggedPerson: die während eines Drag-and-Drop-Vorgangs verschobene
// Personen-Karte (DOM-Element) bzw. null nach dem Loslassen.
let draggedPerson = null;
// getPersonCard: liefert die nächstgelegene Personen-Karte (`.person-card`).
// Parameter `el`: Event-Ziel-Element. Rückgabe: Karten-Element oder null.
function getPersonCard(el){
return el && el.closest ? el.closest('.person-card') : null;
}
// handlePersonDragStart: startet das Ziehen einer Personen-Karte, merkt sich
// diese in `draggedPerson` und verringert ihre Deckkraft. Rückgabe: keine.
function handlePersonDragStart(event){
draggedPerson = getPersonCard(event.target);
if(!draggedPerson) return;
event.dataTransfer.effectAllowed = 'move';
draggedPerson.style.opacity = '0.5';
}
// handlePersonDragOver: erlaubt das Ablegen während des Überfahrens
// (verhindert den Standard-Drop-Typ und setzt `dropEffect='move'`).
function handlePersonDragOver(event){
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
// handlePersonDrop: legt die gezogene Personen-Karte an der Zielposition
// innerhalb `.people-sortable` ab (per `getDragAfterElement`) und speichert
// die neue Reihenfolge über `reorderPeople()`.
function handlePersonDrop(event){
event.preventDefault();
const targetCard = getPersonCard(event.target);
if(!draggedPerson || draggedPerson === targetCard) return;
const sortable = document.querySelector('.people-sortable');
if(!sortable) return;
const afterElement = getDragAfterElement(sortable, event.clientY, '.person-card:not([style*="opacity"])');
if(afterElement == null){
sortable.appendChild(draggedPerson);
} else {
sortable.insertBefore(draggedPerson, afterElement);
}
reorderPeople();
}
// handlePersonDragEnd: stellt die Deckkraft der Karte wieder her und setzt
// `draggedPerson` zurück (Ende des Drag-Vorgangs).
function handlePersonDragEnd(event){
const card = getPersonCard(event.target);
if(card) card.style.opacity = '1';
draggedPerson = null;
}
// reorderPeople: übernimmt die im DOM vorgefundene Reihenfolge der
// Personen-Karten in die globale Personenliste der Abteilung UND die
// Personen-Kopie der aktuellen Woche (letztere bestimmt die Spaltenreihenfolge
// im Projektboard), dann speichern. Keine Parameter, keine Rückgabe.
function reorderPeople(){
// addPersonToCurrentWeek: nimmt eine Person aus der globalen Liste in den
// Teilnehmerkreis der aktuellen Sitzung auf. Nötig, weil der Kreis sonst nur
// beim Anlegen einer neuen Sitzung entsteht: Ein später angelegter Benutzer
// tauchte in laufenden (und in allen daraus duplizierten) Sitzungen nirgends
// auf. Geschrieben wird über den normalen save()-Weg — `week.people` ist Teil
// des Wochen-Datensatzes, den /save-week ohnehin schreibt.
// Parameter: personId (String). Keine Rückgabe.
function addPersonToCurrentWeek(personId){
if(guardWeekLocked()) return;
const sortable = document.querySelector('.people-sortable');
if(!sortable) return;
const people = db.globalPeople[currentDepartment] || [];
if(people.length === 0) return;
const newPeople = [];
[...sortable.querySelectorAll('[data-person-id]')].forEach(item => {
const person = people.find(p => p.id === item.dataset.personId);
if(person) newPeople.push(person);
});
if(newPeople.length === people.length){
db.globalPeople[currentDepartment] = newPeople;
// Wochen-Kopie auf dieselbe Reihenfolge bringen, damit das Board sie nutzt
const w = currentWeek();
if(w && Array.isArray(w.people)){
const byId = {};
w.people.forEach(p => { byId[p.id] = p; });
const newWeekPeople = [];
newPeople.forEach(p => { if(byId[p.id]) newWeekPeople.push(byId[p.id]); });
if(newWeekPeople.length === w.people.length) w.people = newWeekPeople;
}
save({people:true});
}
const w = currentWeek();
if(!w) return;
if(!Array.isArray(w.people)) w.people = [];
if(w.people.some(p => p.id === personId)) return;
const person = (db.globalPeople || []).find(p => p.id === personId);
if(!person){ alert('Diese Person ist nicht mehr in der Benutzerverwaltung vorhanden.'); return; }
// Tiefe Kopie: Der Teilnehmerkreis friert Name und Rollen zum Zeitpunkt der
// Sitzung ein (genauso wie newWeek() es tut) und darf sich später nicht
// rückwirkend mit der globalen Liste ändern.
w.people.push(JSON.parse(JSON.stringify(person)));
save();
renderAll();
}
// ---------- Urlaub / Abwesenheiten ----------
// addAbsence: legt für eine Person eine neue Abwesenheit an, die aus dem
// Bereichspicker (Start-/Enddatum), Typ und Notiz des Formulars gelesen wird.
// Prüft Datumsfolge, sortiert die Liste nach Startdatum und speichert.
// Parameter `personId`: Personen-ID. Keine Rückgabe.
function addAbsence(personId){
// removePersonFromCurrentWeek: nimmt eine Person wieder aus dem
// Teilnehmerkreis der aktuellen Sitzung heraus. Verweigert wird das, solange
// sie noch einem Projekt dieser Sitzung zugeordnet ist — sonst bliebe eine
// Zuordnung ohne zugehörige Spalte zurück. Parameter: personId (String).
function removePersonFromCurrentWeek(personId){
if(guardWeekLocked()) return;
const people = db.globalPeople[currentDepartment] || [];
const person = people.find(p => p.id === personId);
const w = currentWeek();
if(!w || !Array.isArray(w.people)) return;
const person = w.people.find(p => p.id === personId);
if(!person) return;
const start = document.getElementById('absenceStart_' + personId).value;
const end = document.getElementById('absenceEnd_' + personId).value;
const type = document.getElementById('absenceType_' + personId).value;
const note = document.getElementById('absenceNote_' + personId).value.trim();
if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; }
if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
if(!Array.isArray(person.absences)) person.absences = [];
person.absences.push({ id: uid(), start, end, type, note });
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
save({week:false, people:true});
renderPeople();
if(currentTab === 'gantt') renderGantt();
}
// deleteAbsence: entfernt eine Abwesenheit (`absenceId`) aus der Liste der
// Person (`personId`), speichert und rendert neu. Keine Rückgabe.
function deleteAbsence(personId, absenceId){
if(guardWeekLocked()) return;
const people = db.globalPeople[currentDepartment] || [];
const person = people.find(p => p.id === personId);
if(!person || !person.absences) return;
person.absences = person.absences.filter(a => a.id !== absenceId);
save({week:false, people:true});
renderPeople();
if(currentTab === 'gantt') renderGantt();
}
const zugeordnet = (w.projects || []).filter(proj => {
const assign = proj.assign || {};
return Object.keys(assign).some(key => key === personId || key.indexOf(personId + '|') === 0);
});
if(zugeordnet.length > 0){
alert('"' + (person.fullName || person.name) + '" ist in dieser Sitzung noch ' + zugeordnet.length
+ ' Projekt(en) zugeordnet:\n\n' + zugeordnet.map(p => '· ' + (p.name || p.nr)).join('\n')
+ '\n\nBitte zuerst die Zuordnungen im Projektboard entfernen.');
return;
}
w.people = w.people.filter(p => p.id !== personId);
if(w.attendance) delete w.attendance[personId];
save();
renderAll();
}
+93 -27
View File
@@ -32,8 +32,8 @@
}
// onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler
// (⋮⋮) einer TODO-/Meilenstein-Zeile gedrückt wird, und merkt sich Zeile
// und Startposition in app.todoDrag.
// (Icon "grip", .gantt-todo-drag) einer TODO-/Meilenstein-Zeile gedrückt
// wird, und merkt sich Zeile und Startposition in app.todoDrag.
function onGanttTodoMouseDown(e){
if(app.isLocked()) return;
const handle = e.target.closest('.gantt-todo-drag');
@@ -113,7 +113,11 @@
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const todoIds = todos.map(t => t.id);
// Nur terminierte Aufgaben (Start UND Ende gesetzt) haben überhaupt eine
// eigene GANTT-Zeile (siehe plan-build-gantt.js) und damit einen
// Ziehgriff — Aufgaben ohne Termin bleiben an ihrer Stelle
// in plan.todos stehen, sie können hier gar nicht bewegt worden sein.
const scheduledIds = new Set(todos.filter(t => t.start && t.end).map(t => t.id));
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
@@ -125,17 +129,19 @@
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
// die positionierten zählen.
if(todoRowIds.length !== todoIds.length) return;
if(todoRowIds.length !== scheduledIds.size) return;
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
if(milestoneRowIds.length !== positionedIds.length) return;
// TODOs neu ordnen
const newTodos = [];
todoRowIds.forEach(id => {
const t = todos.find(x => x.id === id);
if(t) newTodos.push(t);
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
// Termin behalten exakt ihren bisherigen Platz in der Liste.
let nextRowIdIdx = 0;
plan.todos = todos.map(t => {
if(!scheduledIds.has(t.id)) return t;
const id = todoRowIds[nextRowIdIdx++];
return todos.find(x => x.id === id) || t;
});
plan.todos = newTodos;
// Meilenstein-Positionen neu berechnen
// Regeln:
@@ -382,9 +388,12 @@
const totalDays = daysBetween(rangeStart, rangeEnd) + 1;
const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays)));
const date = formatDateISO(addDays(rangeStart, dayOffset));
openTodoModal(null);
document.getElementById('todoEditStart').value = date;
document.getElementById('todoEditEnd').value = date;
// Über prefill statt nachträglich in die versteckten Felder schreiben:
// openTodoModal() setzt damit auch die sichtbare Zeitraum-Anzeige, das
// Dauer-Kennzeichen und den Vergleichsstand für "ungespeicherte
// Änderungen" (sonst stand im Feld "Start – Ende wählen…", und Esc fragte
// grundlos nach dem Verwerfen).
openTodoModal(null, { start: date, end: date });
}
// updateProjectDates: setzt Start und Ende des Projekt-Plans und speichert.
@@ -415,23 +424,80 @@
}
}
// updateVacationDates: schreibt die verschobenen Urlaubsdaten sowohl in die
// lokale Sitzungs-Kopie der Person als auch in die globale Personenliste
// und speichert beides.
// updateVacationDates: übernimmt die per Drag verschobenen Urlaubsdaten
// sofort optimistisch in lokale Sitzungs-Kopie und globale Personenliste
// (damit das GANTT unmittelbar danach die neue Position zeichnet), und
// persistiert sie asynchron über die /users/:id/absences-Endpunkte — es
// gibt seit der Ablösung von people/person_roles/person_absences keinen
// /save-people-Schreibpfad und kein PATCH auf Abwesenheiten mehr, daher:
// alte Abwesenheit löschen, neue mit den verschobenen Daten anlegen (dabei
// vergibt der Server eine neue ID, die hier übernommen wird). Kein
// Benutzerkonto für die Person gefunden -> klare Fehlermeldung statt
// stillem Fehlschlag.
function updateVacationDates(personId, absenceId, start, end){
const localPerson = (app.week.people || []).find(p => p.id === personId);
if(localPerson && localPerson.absences){
const a = localPerson.absences.find(x => x.id === absenceId);
if(a){ a.start = start; a.end = end; }
}
const globalPeople = db.globalPeople[app.department] || [];
const localAbsence = localPerson && Array.isArray(localPerson.absences)
? localPerson.absences.find(x => x.id === absenceId) : null;
const globalPeople = db.globalPeople || [];
const globalPerson = globalPeople.find(p => p.id === personId);
if(globalPerson && globalPerson.absences){
const a = globalPerson.absences.find(x => x.id === absenceId);
if(a){ a.start = start; a.end = end; }
}
app.savePlan();
app.saveGlobalPeople();
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
? globalPerson.absences.find(x => x.id === absenceId) : null;
if(!globalAbsence) return;
const type = globalAbsence.type || '';
const note = globalAbsence.note || '';
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
// Berechtigung), wird die optimistisch verschobene Anzeige wieder auf den
// Server-Stand zurückgesetzt, statt etwas zu zeigen, das dort nicht gilt.
const vorher = { start: globalAbsence.start, end: globalAbsence.end };
const zuruecksetzen = () => {
if(localAbsence){ localAbsence.start = vorher.start; localAbsence.end = vorher.end; }
globalAbsence.start = vorher.start; globalAbsence.end = vorher.end;
renderPlanGantt();
};
if(localAbsence){ localAbsence.start = start; localAbsence.end = end; }
globalAbsence.start = start; globalAbsence.end = end;
app.getUserIdForPersonId(personId).then(userId => {
if(!userId){
app.showToast('Für diese Person existiert kein Benutzerkonto – Abwesenheit konnte nicht gespeichert werden.');
zuruecksetzen();
return;
}
// Ergebnis des Löschens prüfen, BEVOR neu angelegt wird: Schlug das
// Anlegen danach fehl (Netz, 400), war die alte Abwesenheit bereits weg
// — die Anzeige zeigte sie aber weiter, und nach dem nächsten Neuladen
// fehlte sie ersatzlos.
return fetch(baseUrl() + '/users/' + userId + '/absences/' + encodeURIComponent(absenceId), { method: 'DELETE' })
.then(r => {
if(!r.ok){
// 403 heißt: Der Eintrag stammt von jemand anderem (siehe
// DELETE /users/:id/absences/:absenceId in server.js).
return r.json().catch(() => ({})).then(json => {
throw new Error((json && json.error) || ('Alte Abwesenheit konnte nicht entfernt werden (HTTP ' + r.status + ')'));
});
}
return fetch(baseUrl() + '/users/' + userId + '/absences', {
method: 'POST',
headers: {'Content-Type':'application/json; charset=utf-8'},
body: JSON.stringify({ start, end, type, note })
});
})
.then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => {
if(!ok || !json || !json.ok){
app.showToast('Abwesenheit konnte nicht gespeichert werden: ' + ((json && json.error) || 'unbekannter Fehler')
+ ' — bitte die Seite neu laden.');
return;
}
globalAbsence.id = json.absence.id;
if(localAbsence) localAbsence.id = json.absence.id;
renderPlanGantt();
});
}).catch(err => {
console.error('Abwesenheit verschieben fehlgeschlagen:', err);
app.showToast('Abwesenheit konnte nicht verschoben werden: ' + (err.message || err));
zuruecksetzen();
});
}
// Drag-Funktionalität erst nach geladenem DOM registrieren
+20 -9
View File
@@ -348,7 +348,7 @@
// setzeProtokollImportLadezustand: blendet den Ladehinweis ein/aus und
// sperrt alle Felder + Knöpfe des Dialogs währenddessen — ohne das gab es
// während eines länger laufenden KI-Aufrufs (bis zu KI_TIMEOUT_MS_LOKAL =
// 5 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung,
// 30 Minuten bei einem eigenen Server, server.js) keinerlei Rückmeldung,
// der Dialog wirkte hängengeblieben, und ein zweiter Klick auf
// "Importieren" hätte denselben Import doppelt losgeschickt.
function setzeProtokollImportLadezustand(aktiv){
@@ -584,19 +584,27 @@
<div class="legend">
<span class="legend-item"><span class="legend-swatch neu"></span>Neu in diesem Protokoll</span>
<span class="legend-item"><span class="legend-swatch fortgeschrieben"></span>Aus vorigem Protokoll fortgeschrieben</span>
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt (läuft nicht weiter mit)</span>
<span class="legend-item"><span class="legend-swatch erledigt"></span>Erledigt oder Info (läuft nicht weiter mit)</span>
</div>`;
}
// PROTOKOLL_PUNKT_STATUS_LABEL: Anzeigetext je Status im <select> unten —
// einzige Stelle, die die drei Werte kennt, damit ein vierter Status
// (sollte es den je geben) nur hier ergänzt werden müsste.
const PROTOKOLL_PUNKT_STATUS_LABEL = { offen: 'Offen', erledigt: 'Erledigt', info: 'Info' };
function renderPunktZeileHtml(punkt, idx, nummer, istErste, istLetzte){
const locked = app.isLocked();
const terminDisplay = formatDateDE(parseDateISO(punkt.termin)) || '';
const neuChip = punkt.neu ? '<span class="protokoll-chip-neu">Neu</span>' : '';
const status = punkt.status || 'offen';
const moveBtns = locked ? '' : `<span class="protokoll-punkt-move">
<button type="button" class="secondary small" ${istErste ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, -1)" title="Innerhalb der Gruppe nach oben">↑</button>
<button type="button" class="secondary small" ${istLetzte ? 'disabled' : ''} onclick="verschiebePunkt(${idx}, 1)" title="Innerhalb der Gruppe nach unten">↓</button>
</span>`;
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${punkt.erledigt ? ' is-erledigt' : ''}" id="protokollPunkt_${idx}">
const statusOptions = Object.keys(PROTOKOLL_PUNKT_STATUS_LABEL)
.map(s => `<option value="${s}" ${s === status ? 'selected' : ''}>${PROTOKOLL_PUNKT_STATUS_LABEL[s]}</option>`).join('');
return `<div class="protokoll-punkt${punkt.neu ? ' is-neu' : ''}${status === 'erledigt' ? ' is-erledigt' : ''}${status === 'info' ? ' is-info' : ''}" id="protokollPunkt_${idx}">
<div class="protokoll-punkt-top-row">
<span class="protokoll-punkt-nr" title="Errechnet sich automatisch aus Gruppe und Reihenfolge">${esc(nummer)}</span>
${moveBtns}
@@ -606,8 +614,8 @@
${neuChip}
<input type="text" class="protokoll-punkt-thema" value="${esc(punkt.thema)}" placeholder="Thema" ${locked ? 'disabled' : ''}
oninput="currentProtokollSetzePunktFeld(${idx}, 'thema', this.value)">
<label class="protokoll-punkt-erledigt-toggle"><input type="checkbox" ${punkt.erledigt ? 'checked' : ''} ${locked ? 'disabled' : ''}
onchange="currentProtokollSetzePunktErledigt(${idx}, this.checked)"> erledigt</label>
<select class="protokoll-punkt-status" ${locked ? 'disabled' : ''}
onchange="currentProtokollSetzePunktStatus(${idx}, this.value)">${statusOptions}</select>
${locked ? '' : `<span class="protokoll-row-remove" onclick="entfernePunkt(${idx})" title="Punkt entfernen">✕</span>`}
</div>
<textarea class="protokoll-punkt-text" rows="2" placeholder="Text / Vereinbarung" ${locked ? 'disabled' : ''}
@@ -648,11 +656,14 @@
const el = document.getElementById('protokollPunktTermin_' + idx);
if(el) currentProtokoll.punkte[idx].termin = el.value;
};
window.currentProtokollSetzePunktErledigt = function(idx, checked){
window.currentProtokollSetzePunktStatus = function(idx, status){
if(!currentProtokoll || !currentProtokoll.punkte[idx]) return;
currentProtokoll.punkte[idx].erledigt = checked;
currentProtokoll.punkte[idx].status = status;
const row = document.getElementById('protokollPunkt_' + idx);
if(row) row.classList.toggle('is-erledigt', checked);
if(row){
row.classList.toggle('is-erledigt', status === 'erledigt');
row.classList.toggle('is-info', status === 'info');
}
};
// currentProtokollSetzePunktGruppe: einzige Punkt-Feldänderung, die neu
// rendert (nur den Punkte-Block, siehe renderPunkteBlockHtml) — ändert
@@ -686,7 +697,7 @@
// immer, was der Server beim Speichern setzt (server.js, PUT .../:id).
currentProtokoll.punkte.push({
id: null, nummer: '', gruppe: gruppe || '', neu: true,
thema: '', text: '', zustaendig: '', termin: '', erledigt: false,
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
erstelltAm: currentProtokoll.datum || ''
});
const block = document.getElementById('protokollPunkteBlock');
+287 -141
View File
@@ -1,23 +1,38 @@
// -----------------------------
// Projektplan — Zustand, Initialisierung & Daten laden
// Projektbereich — Zustand, Einstieg & Projektauswahl
// -----------------------------
// Dieses Modul wird als erstes der plan-*-Module von public/project-plan.html
// geladen und definiert window.PlanApp als gemeinsamen Zustand aller
// Projektplan-Module. Es liest die URL-Route (Abteilung + Projektname, z. B.
// "/HLS/Projektplan/Gymnasium%20Hemer") und die URL-Parameter (weekLabel,
// projNr), über die das zu bearbeitende Projekt adressiert wird.
// Nach DOMContentLoaded werden Theme, Toolbar-Buttons und die Sitzungsdaten
// über die Server-API geladen. Die hier definierten Funktionen (savePlan,
// isLocked, showToast u. a.) werden über app.* den anderen plan-Modulen
// bereitgestellt, die alle in einem IIFE mit window.PlanApp laufen.
// Dieses Modul wird als erstes der plan-*-Module von public/Test.html geladen
// und definiert window.PlanApp als gemeinsamen Zustand aller Module des
// Projektbereichs.
//
// WICHTIG — geändert gegenüber der früheren Fassung: Der Projektbereich war
// einmal eine eigene Seite (public/project-plan.html) mit eigenem
// Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren
// Datenbestand über GET /load-all selbst. Beides ist entfallen: Der
// Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in Test.html),
// der über showTab('plan') eingeblendet wird wie jeder andere. Er arbeitet
// deshalb auf dem bereits geladenen In-Memory-Bestand db.weeks (js/store.js)
// und braucht weder einen eigenen Ladevorgang noch eine eigene
// Anmeldeprüfung.
//
// Einstieg sind zwei Schritte: setPlanTarget(weekLabel, projNr, projName)
// merkt sich das Ziel, openPlanView() sucht es in db.weeks und rendert den
// Bereich. Getrennt sind sie, weil showTab('plan') (js/tabs-auth.js) aus dem
// Ziel zuerst die Adresszeile schreibt und den Bereich einblendet — das GANTT
// kann sich erst vermessen, wenn sein Container sichtbar ist. Gesetzt wird
// das Ziel von openProjectPlan() (js/gantt.js) und beim Seitenstart aus einem
// Deep-Link "/Projektplan/<Projektname>?weekLabel=…&projNr=…"
// (applyUrlRoute() in js/ui.js, ausgewertet in js/init.js).
// Die hier definierten Funktionen (savePlan, isLocked, showToast u. a.)
// stellen sich über app.* den anderen plan-Modulen bereit, die alle in einem
// IIFE mit window.PlanApp laufen.
// PlanApp: gemeinsamer, modulübergreifender Zustand der Projektplan-Seite.
// PlanApp: gemeinsamer, modulübergreifender Zustand des Projektbereichs.
window.PlanApp = {
params: new URLSearchParams(location.search),
department: '',
weekLabel: '',
projNr: '',
projName: '',
sandboxPlanId: '', // gesetzt von openSandboxPlan() — Sandbox-Pläne werden über id adressiert, nicht über nr/name
week: null, // planWeek
project: null, // planProject
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
@@ -34,88 +49,117 @@ window.PlanApp = {
(function(){
const app = window.PlanApp;
// Abteilung und Projektname aus dem Deep-Link-Pfad
// "/<Abteilung>/Projektplan/<Projektname>" lesen. Fallback auf die früheren
// Query-Parameter ?department=/&projName= für alte project-plan.html-Links.
const routeMatch = window.location.pathname.match(/^\/([^/]+)\/(?:Projektplan|project-plan\.html)(?:\/(.+))?\/?$/);
app.department = routeMatch ? routeMatch[1] : (app.params.get('department') || 'ELT');
app.weekLabel = app.params.get('weekLabel');
app.projNr = app.params.get('projNr') || '';
let projNameFromRoute = '';
if(routeMatch && routeMatch[2]){
try { projNameFromRoute = decodeURIComponent(routeMatch[2]); }
catch(e) { projNameFromRoute = app.params.get('projName') || ''; }
}
app.projName = projNameFromRoute || (app.params.get('projName') || '');
// init: Einstiegspunkt nach DOMContentLoaded. Übernimmt das Theme aus
// localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung
// ab, falls kein Projekt ausgewählt wurde. Sonst bindet es die Toolbar und
// startet das Laden der Daten.
function init(){
loadTheme(); // Theme aus localStorage / System übernehmen
if(!app.weekLabel || !app.projName){
document.getElementById('planTitle').textContent = 'Fehler';
document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.';
return;
// planFail: zeigt einen Fehler in der Kopfzeile des Projektbereichs an und
// blendet die Bereichsleiste aus, weil ohne Projekt jeder Unterbereich nur
// in eine leere Maske führte. Parameter: title, subtitle (Strings).
function planFail(title, subtitle){
const t = document.getElementById('planTitle');
const sub = document.getElementById('planSubtitle');
if(t) t.textContent = title;
if(sub) sub.textContent = subtitle;
const badge = document.getElementById('planIdBadge');
if(badge) badge.textContent = '';
app.project = null;
app.hidePlanNav();
}
// setPlanTarget: merkt sich, welches Projekt der Bereich zeigen soll —
// adressiert über Sitzungs-Label und Projekt-Nr./-Bezeichnung, NICHT über
// project.id, die beim Duplizieren einer Sitzung (cloneWeek(), js/weeks.js)
// neu vergeben wird. Bewusst getrennt vom Rendern: showTab('plan')
// (js/tabs-auth.js) schreibt aus diesen Werten zuerst die Adresszeile und
// blendet den Bereich ein, und erst der sichtbare Bereich lässt sich
// vermessen — das GANTT braucht die Containerbreite. Parameter:
// weekLabel, projNr, projName (Strings). Keine Rückgabe.
window.setPlanTarget = function(weekLabel, projNr, projName){
app.weekLabel = weekLabel || '';
app.projNr = projNr || '';
app.projName = projName || '';
app.sandboxPlanId = '';
};
// openSandboxPlan: das Gegenstück zu setPlanTarget()+showTab('plan') für
// einen Terminplan der Planung-Sandbox (js/planung/planung.js). Sandbox-
// Pläne haben keine Projektnummer und werden nie geklont (cloneWeek()
// betrifft nur echte Wochen) — ihre id bleibt also stabil und ist die
// einzige verlässliche Adressierung, anders als bei echten Projekten
// (dort nr/name, siehe setPlanTarget()). Parameter: planId. Keine Rückgabe.
window.openSandboxPlan = function(planId){
app.weekLabel = SANDBOX_WEEK_LABEL;
app.projNr = '';
app.projName = '';
app.sandboxPlanId = planId || '';
showTab('plan');
};
// openPlanView: sucht Sitzung und Projekt im bereits geladenen Bestand
// db.weeks (js/store.js) und rendert den Bereich. Einen eigenen
// Ladevorgang gibt es nicht mehr — der Projektbereich teilt sich den
// Datenbestand mit allen anderen Bereichen der Seite. Wird von
// showTab('plan') gerufen (und von dort aus auch nach jedem erneuten
// Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt
// gefunden und gerendert wurde.
window.openPlanView = function(){
app.showPlanNav();
// Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte
// "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js)
// und Adressierung über die stabile Plan-id statt über nr/name.
if(app.weekLabel === SANDBOX_WEEK_LABEL){
if(!app.sandboxPlanId){
planFail('Kein Terminplan gewählt', 'Diesen Bereich öffnet ein Klick auf einen Terminplan im Bereich „Planung".');
return false;
}
app.week = db.sandboxWeek || null;
app.project = (app.week && Array.isArray(app.week.projects))
? app.week.projects.find(p => p.id === app.sandboxPlanId) || null
: null;
if(!app.project){
planFail('Nicht gefunden', 'Dieser Terminplan wurde nicht gefunden — er wurde möglicherweise gelöscht.');
return false;
}
app.projNr = '';
app.projName = app.project.name || '';
renderPlanPage();
return true;
}
currentDepartment = app.department; // global aus globals.js
bindToolbarButtons();
loadPlanData();
}
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
// anderen plan-Module. Die Buttons binden ihre Handler bereits über das
// onclick-Attribut in project-plan.html; hier werden nur Lücken gefüllt,
// falls ein Button-Element fehlt.
function bindToolbarButtons(){
}
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
// Abteilung über die Server-API (JSON-Dokumente), sortiert die Wochen
// chronologisch und sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt
// Fehlermeldungen der Reihenfolge nach im Seitenkopf an und startet sonst
// renderPlanPage().
function loadPlanData(){
if(!window.fetch){
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
return;
if(!app.weekLabel || (!app.projNr && !app.projName)){
planFail('Kein Projekt gewählt', 'Diesen Bereich öffnet ein Klick auf ein Projekt im Projektboard oder im Tab „Projekte".');
return false;
}
const url = baseUrl() + '/load-all?department=' + encodeURIComponent(app.department);
fetch(url)
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
db = { weeks: [], currentWeek: null, globalPeople: {} };
db.globalPeople[app.department] = Array.isArray(data.people) ? data.people : [];
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
.map(w => {
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return w;
});
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
app.week = db.weeks.find(w => w.label === app.weekLabel);
if(!app.week){
document.getElementById('planTitle').textContent = 'Nicht gefunden';
document.getElementById('planSubtitle').textContent = 'Sitzung "' + esc(app.weekLabel) + '" konnte nicht geladen werden.';
return;
}
app.project = app.week.projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName);
if(!app.project){
document.getElementById('planTitle').textContent = 'Nicht gefunden';
document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.';
return;
}
renderPlanPage();
})
.catch(err => {
document.getElementById('planTitle').textContent = 'Fehler beim Laden';
document.getElementById('planSubtitle').textContent = err.message;
});
}
// Vor dem ersten erfolgreichen load() ist db.weeks noch leer (Deep-Link
// direkt auf "/Projektplan/…"). Dann ist das kein Fehler, sondern nur
// noch nicht so weit: load() ruft showTab(currentTab) erneut auf.
if(!Array.isArray(db.weeks) || db.weeks.length === 0){
const t = document.getElementById('planTitle');
const sub = document.getElementById('planSubtitle');
if(t) t.textContent = app.projName || 'Projektbereich';
if(sub) sub.textContent = 'Sitzungsdaten werden geladen…';
return false;
}
app.week = db.weeks.find(w => w.label === app.weekLabel) || null;
if(!app.week){
planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.');
return false;
}
// Projekt-Nr. ist der stabile Schlüssel; die Bezeichnung dient nur als
// Rückfallebene für Projekte, die (noch) keine Nummer haben.
const projects = app.week.projects || [];
app.project = projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName)
|| (app.projNr ? projects.find(p => (p.nr || '') === app.projNr) : null)
|| projects.find(p => (p.name || '') === app.projName)
|| null;
if(!app.project){
planFail('Nicht gefunden', 'Projekt nicht in dieser Sitzung vorhanden.');
return false;
}
app.projNr = app.project.nr || '';
app.projName = app.project.name || '';
renderPlanPage();
return true;
};
// ensurePlanLocal: stellt sicher, dass das Plan-Objekt des Projekts die
// erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten).
@@ -139,21 +183,98 @@ window.PlanApp = {
return !!app.week.locked;
}
// renderPlanPage: setzt Titel und Untertitel der Seite aus Projekt und Woche,
// blendet den Meilenstein-Button bei gesperrter Woche aus und startet die
// GANTT-Anzeige über fitPlanGanttRange().
// isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
// Planung-Sandbox gehört statt zu einem echten Projekt. Zentraler Merker
// für alle plan-*-Module, die dort Abschnitte ausblenden, die nur bei
// einem echten Projekt Sinn ergeben (Dateien, Leistung, Auftragsstatus,
// Projektnummer, Gewerke, Projektgröße, Kommentar/Verlauf — siehe CLAUDE.md
// "Planung — projektunabhängige Terminplan-Sandbox"). Rückgabe: Boolescher Wert.
function isSandboxPlan(){
return !!app.week && app.week.label === SANDBOX_WEEK_LABEL;
}
// renderPlanPage: setzt die kompakte, einzeilige Titelzeile aus Projekt und
// Woche (Nr.-Badge, Name, Tags, Sitzungslabel, Beteiligte als
// überlappender Avatar-Stapel — siehe renderProjectAssignees()), baut das
// Projektinfo-Panel auf (renderPlanInfo(), js/plan/plan-info.js: Status,
// Gruppe, amtierende Leistungsphase, LPH-Prozentliste) sowie den
// Dateien-Bereich (initPlanFiles(), js/plan/plan-files.js: Upload, Freigabe,
// Kommentare) und den Einstellungen-Bereich (renderPlanSettings(),
// js/plan/plan-settings.js: Honorar, beauftragte Leistungsphasen), blendet
// die Schaltflächen zum Anlegen neuer Aufgaben bei gesperrter Woche aus,
// startet die GANTT-Anzeige über fitPlanGanttRange() und schaltet zuletzt
// auf den sichtbaren Bereich (app.initPlanSections(), js/plan/plan-sections.js).
function renderPlanPage(){
const p = app.project;
const plan = ensurePlanLocal();
document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name;
document.getElementById('planSubtitle').textContent = app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
const sandbox = isSandboxPlan();
const planIdBadge = document.getElementById('planIdBadge');
if(planIdBadge) planIdBadge.textContent = p.nr || '';
document.getElementById('planTitle').textContent = p.name;
// Für die Sandbox NICHT das rohe week.label ("__sandbox__") anzeigen —
// das ist ein interner Marker, kein für Nutzer bestimmter Sitzungsname.
document.getElementById('planSubtitle').textContent = sandbox
? 'Planung — unabhängig von einem laufenden Projekt'
: app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
// GANTT inkl. TODOs
const addMilestoneBtn = document.getElementById('addMilestoneBtn');
const planPeopleList = document.getElementById('planPeopleList');
if(planPeopleList) planPeopleList.innerHTML = renderProjectAssignees();
const tags = Array.isArray(p.tags) ? p.tags : [];
const planTagsList = document.getElementById('planTagsList');
if(planTagsList){
planTagsList.innerHTML = tags.map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
}
// Projektinfo-Panel (Status, Gruppe, amtierende Leistungsphase, LPH-
// Prozentliste) — siehe js/plan/plan-info.js.
renderPlanInfo();
// Dateien-Bereich (Upload, Freigabe, Kommentare) — siehe js/plan/plan-files.js.
initPlanFiles();
// Protokolle-Bereich (Besprechungsprotokolle) — siehe js/plan/plan-protokoll.js.
// Wie initPlanFiles() unbedingt aufgerufen (nicht erst beim Öffnen des
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
renderPlanHistory();
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
// Projektangaben) — siehe js/plan/plan-settings.js.
renderPlanSettings();
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe Test.html.
const locked = isLocked();
if(addMilestoneBtn) addMilestoneBtn.style.display = locked ? 'none' : '';
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'autoScheduleBtn',
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'autoScheduleBtnList']
.forEach(id => {
const btn = document.getElementById(id);
if(btn) btn.style.display = locked ? 'none' : '';
});
// "Dateien" (keine Projektnummer, kein sinnvoller Prüf-Workflow),
// "Leistung" (echte Abrechnung/Auftragsstatus-abhängig) und "Protokolle"
// (hängt wie Dateien an project.nr) ergeben für einen Terminplan ohne
// echtes Projekt keinen Sinn — Bereichsknöpfe ausblenden.
document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]')
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für
// Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent.
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
fitPlanGanttRange();
// Sichtbaren Bereich wählen (URL-Fragment, sonst Dashboard) — siehe
// js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle
// Bereiche befüllt sind, wird einer davon eingeblendet.
app.initPlanSections();
}
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
@@ -166,58 +287,84 @@ window.PlanApp = {
});
}
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen Liste
// planRoleClass: Kopie von roleClass() aus js/board/board.js (dort nicht
// geladen) — erzeugt dieselben CSS-Klassen wie im Board, damit die
// Avatar-Kreise hier dieselben Rollenfarben (.board-avatar in styles.css)
// nutzen. Parameter: role (String). Rückgabe: Klassen-String.
function planRoleClass(role){
return 'role-' + String(role || '').toLowerCase().replace(/\s+/g, '-');
}
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen
// Liste, als überlappender Avatar-Stapel für die kompakte Titelzeile
// (dieselben Kreise/Farben wie im Projektboard-Detailpanel, siehe
// .board-avatar in styles.css). Voller Name + Rolle stehen im Tooltip.
function renderProjectAssignees(){
const assignees = projectAssignees(app.project, app.week);
if(assignees.length === 0) return '<span class="hint">Keine Zuordnungen im Projektboard.</span>';
if(assignees.length === 0) return '';
return assignees.map(a => {
const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : '';
return `<span class="todo-assignee ${cls}">${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}</span>`;
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
return `<span class="board-avatar ${planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>`;
}).join('');
}
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben.
// In-Flight-Schutz: läuft bereits ein Save, wird kein zweiter POST mit
// identischer Revisions-Basis abgeschickt (der würde fälschlich als 409-
// Konflikt gemeldet). Stattdessen wird ein Folgesave eingereiht, der nach
// Abschluss des laufenden Saves mit der dann frischen Revision nachzieht.
let savePlanRunning = false;
let savePlanQueued = false;
// savePlan: merkt die Sitzung des Projektbereichs als geändert vor und
// stößt den gewöhnlichen, entprellten Speicherlauf an (js/store.js).
//
// Früher schrieb der Projektbereich mit einem eigenen sendWeekToServer()
// direkt zum Server, weil er eine eigene Seite war und store.js dort gar
// nicht geladen wurde. In einer Seite wären daraus zwei Schreiber auf
// dieselbe Sitzung geworden: beide senden dieselbe Revisions-Basis, und der
// langsamere bekäme einen scheinbaren 409-Konflikt gemeldet. Deshalb geht
// jetzt auch der Projektbereich durch syncAllToServer() — mitsamt dessen
// In-Flight-Schutz, Wiederholung nach Netzfehlern, Konfliktbehandlung und
// Anmelde-Prüfung. markWeekDirty() bekommt bewusst app.week.label statt
// save() zu rufen: save() markiert die AKTUELL gewählte Sitzung, und ein
// Deep-Link kann den Projektbereich auf eine andere gestellt haben.
function savePlan(){
if(!app.week || !app.project) return;
if(savePlanRunning){ savePlanQueued = true; return; }
savePlanRunning = true;
sendWeekToServer(app.week)
.then(res => {
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res && res.updatedAt) app.week.updatedAt = res.updatedAt;
})
.catch(err => {
if(err && err.conflict){
showToast('Konflikt: Die Sitzung wurde zwischenzeitlich geändert. Bitte Seite neu laden.');
console.error('Speichern fehlgeschlagen (Konflikt):', err.message || err);
} else {
console.error('Speichern fehlgeschlagen:', err);
}
})
.finally(() => {
savePlanRunning = false;
if(savePlanQueued){ savePlanQueued = false; savePlan(); }
});
markWeekDirty(app.week.label);
scheduleSessionSync();
}
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
// JSON-Array auf den Server. Auch eine leere Liste wird gespeichert, damit
// das Löschen der letzten Person erhalten bleibt.
function saveGlobalPeople(){
const people = db.globalPeople[app.department] || [];
sendPeopleToServer(people).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
// userIdByPersonIdPromise: Cache der Zuordnung Person-ID (String, z. B.
// "p_max_mustermann") -> numerische users.id. Seit der Ablösung von
// people/person_roles/person_absences gibt es keinen /save-people-Schreibpfad
// mehr; Abwesenheiten werden direkt gegen /users/:id/absences geschrieben
// (siehe plan-modals.js/plan-drag.js), wofür die numerische User-ID benötigt
// wird. Lazy geladen über GET /users (liefert seit Phase 1 auch personId)
// und für die Lebensdauer der Seite zwischengespeichert.
let userIdByPersonIdPromise = null;
function loadUserIdByPersonId(){
if(!userIdByPersonIdPromise){
userIdByPersonIdPromise = fetch(baseUrl() + '/users')
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
const map = {};
(Array.isArray(data.users) ? data.users : []).forEach(u => {
if(u.personId) map[u.personId] = u.id;
});
return map;
})
.catch(err => {
// Cache zurücksetzen, damit ein späterer Aufruf es erneut versucht.
userIdByPersonIdPromise = null;
throw err;
});
}
return userIdByPersonIdPromise;
}
// getUserIdForPersonId: liefert die numerische users.id für eine Personen-ID
// oder null, falls keine passende (aktive) Benutzer-Zuordnung existiert (z. B.
// eine Person ohne Login-Konto). Rückgabe: Promise.
function getUserIdForPersonId(personId){
return loadUserIdByPersonId().then(map => (map && Object.prototype.hasOwnProperty.call(map, personId)) ? map[personId] : null);
}
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
function findGlobalPerson(personId){
return (db.globalPeople[app.department] || []).find(p => p.id === personId) || null;
return (db.globalPeople || []).find(p => p.id === personId) || null;
}
// countPersonAbsences: zählt die Abwesenheiten einer Person anhand der
@@ -247,14 +394,13 @@ window.PlanApp = {
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
app.ensurePlanLocal = ensurePlanLocal;
app.isLocked = isLocked;
app.isSandboxPlan = isSandboxPlan;
app.removeDependencyRefs = removeDependencyRefs;
app.renderProjectAssignees = renderProjectAssignees;
app.planRoleClass = planRoleClass;
app.savePlan = savePlan;
app.saveGlobalPeople = saveGlobalPeople;
app.getUserIdForPersonId = getUserIdForPersonId;
app.findGlobalPerson = findGlobalPerson;
app.countPersonAbsences = countPersonAbsences;
app.showToast = showToast;
// Seitenstart: erst initialisieren, wenn das DOM geladen ist
document.addEventListener('DOMContentLoaded', init);
})();
+108 -23
View File
@@ -1,15 +1,27 @@
// -----------------------------
// Projektübersicht — Projekte (Listen-Ansicht)
// Projektübersicht — Projektliste (Ablage-Logik)
// -----------------------------
// Diese Datei rendert den Projekte-Tab (Tab "Projekte" in `Test.html`) als
// sortierbare Listenansicht: Projekte werden nach Gruppe ("Aktive Projekte",
// "Kleine Projekte" usw.) gruppiert, inline bearbeitbar und per
// Drag-and-Drop umsortiert. Sie wird von `Test.html` nach `js/people/people.js`
// (vor `js/load/load.js`) geladen und stellt globale Funktionen wie
// `renderProjects()`, `editProjectField()`, `editProjectActiveLP()` sowie die
// Drag-and-Drop-Handler und die Sortier-Hilfe `getDragAfterElement()` bereit.
// Letztere wird auch von `js/people/people.js` (Personen-Sortierung) genutzt;
// `renderBoard()` aus `js/board/board.js` wird nach Änderungen aktualisiert.
// Der eigene Tab "Projekte" ist entfallen. Er listete dieselben Projekte ein
// zweites Mal auf und ließ dieselben Angaben — Nummer, Bezeichnung, Gruppe,
// amtierende Leistungsphase, Schlagwörter, Aufwand — ein zweites Mal
// bearbeiten. Diese Angaben haben ihren einen Ort jetzt im Projektbereich
// (js/plan/plan-settings.js, js/plan/plan-info.js); die Liste selbst ist im
// Projektboard aufgegangen (renderBoard(), js/board/board.js).
//
// Was hier bleibt, betrifft die LISTE, nicht die einzelnen Angaben:
//
// * delProjectFromList() — Projekt aus der Sitzung löschen
// * die Drag-and-Drop-Handler + reorderProjects() — Reihenfolge innerhalb
// einer Gruppe und Wechsel zwischen Gruppen. Das Board spannt dieselbe
// Ablagefläche auf (.projects-sortable mit data-group), damit diese
// Handler unverändert weiterarbeiten.
// * getDragAfterElement() — Sortier-Hilfe, auch von js/people/people.js
// genutzt
//
// editProjectField()/editProjectActiveLP()/addProjectTag()/removeProjectTag()
// stehen weiterhin bereit: Der Beauftragungs-Dialog und die Auslastungs-Maske
// rufen sie auf. renderProjects() rendert nur noch, wenn ein Element
// #projectsList vorhanden ist — sonst übernimmt renderBoard().
// delProjectFromList: löscht ein Projekt (per Bestätigungsdialog) in der
// Listen-Ansicht. Parameter `id`: Projekt-ID. Keine Rückgabe; bei gesperrter
@@ -55,16 +67,67 @@ function editProjectActiveLP(id, value){
save(); renderProjects(); renderBoard(); renderLoad();
}
// addProjectTag: fügt dem Projekt einen neuen Tag hinzu (getrimmt, nicht
// leer, keine exakten Duplikate). Initialisiert `p.tags`, falls es fehlt
// oder kein Array ist. Parameter: `projectId`, `tag` (roher Eingabetext).
// Keine Rückgabe; bei gesperrter Sitzung abgebrochen.
function addProjectTag(projectId, tag){
if(guardWeekLocked()) return;
const val = String(tag || '').trim();
if(!val) return;
const w = currentWeek();
const p = w.projects.find(x=>x.id===projectId);
if(!p) return;
if(!Array.isArray(p.tags)) p.tags = [];
if(p.tags.includes(val)) return;
p.tags.push(val);
save(); renderProjects(); renderBoard();
}
// removeProjectTag: entfernt einen Tag aus dem Projekt (exakter Textvergleich).
// Parameter: `projectId`, `tag`. Keine Rückgabe; bei gesperrter Sitzung
// abgebrochen.
function removeProjectTag(projectId, tag){
if(guardWeekLocked()) return;
const w = currentWeek();
const p = w.projects.find(x=>x.id===projectId);
if(!p) return;
p.tags = (Array.isArray(p.tags) ? p.tags : []).filter(t => t !== tag);
save(); renderProjects(); renderBoard();
}
// handleProjectTagKeydown: fügt bei Enter oder Komma im Tag-Eingabefeld den
// aktuellen Feldinhalt als neuen Tag hinzu (das Eingabefeld wird ohnehin
// beim anschließenden renderProjects() frisch/leer neu aufgebaut). Parameter:
// `event` (KeyboardEvent), `projectId`. Keine Rückgabe.
function handleProjectTagKeydown(event, projectId){
if(event.key !== 'Enter' && event.key !== ','){
return;
}
event.preventDefault();
addProjectTag(projectId, event.target.value);
}
// renderProjects: baut die HTML-Liste der Projekte auf, gruppiert nach `group`
// und sortiert je Gruppe nach `order`. Jedes Projekt erhält Felder für Nr.,
// Name, Gruppe, aktive LP sowie Drag-and-Drop-Handler. Keine Parameter, keine
// Rückgabe.
function renderProjects(){
// Ohne #projectsList gibt es den eigenen Projekte-Bereich nicht mehr — die
// Liste steht im Projektboard. Der Aufruf bleibt dennoch überall stehen
// (renderAll(), editProjectField(), …), damit kein Pfad ins Leere greift.
const listEl = document.getElementById('projectsList');
if(!listEl) return;
const w = currentWeek();
if(!w) return;
const locked = isWeekLocked(w);
let html="";
const groups = {};
// Alle Standardgruppen vorbelegen: Ohne das entsteht für eine leer geräumte
// Gruppe weder Überschrift noch Ablagefläche — ein Projekt ließ sich dann
// nur noch über das Auswahlfeld zurückverschieben, und der Hinweis
// "Keine Projekte." unten war toter Code.
['Aktive Projekte','Kleine Projekte','In Anbahnung','Allgemein'].forEach(g => { groups[g] = []; });
w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); });
Object.keys(groups).forEach(g=>{
groups[g].sort((a,b)=>(a.order||0)-(b.order||0));
@@ -74,28 +137,37 @@ function renderProjects(){
html += `<div class="section-title">${esc(g)}</div>`;
html += `<div class="projects-sortable" data-group="${g}" ${locked ? '' : 'ondragover="handleDragOver(event)" ondrop="handleDrop(event)"'}>`;
groups[g].forEach(p=>{
html += `<div class="person-list-item" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'} data-project-id="${p.id}">
${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999;margin-right:8px">⋮⋮</span>`}
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr">
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung">
<select onchange="editProjectField('${p.id}','group',this.value)">
const tags = Array.isArray(p.tags) ? p.tags : [];
html += `<div class="person-list-item project-item" ${locked ? '' : 'draggable="true" ondragstart="handleDragStart(event)" ondragend="handleDragEnd(event)"'} data-project-id="${p.id}">
${locked ? '' : `<span class="drag-handle" style="cursor:grab;color:#999">${icon('grip')}</span>`}
<input type="text" value="${esc(p.nr)}" onchange="editProjectField('${p.id}','nr',this.value)" style="width:100px" placeholder="Proj-Nr" ${locked ? 'disabled' : ''}>
<input type="text" value="${esc(p.name)}" onchange="editProjectField('${p.id}','name',this.value)" style="width:200px" placeholder="Bezeichnung" ${locked ? 'disabled' : ''}>
<select onchange="editProjectField('${p.id}','group',this.value)" ${locked ? 'disabled' : ''}>
<option ${p.group==='Aktive Projekte'?'selected':''}>Aktive Projekte</option>
<option ${p.group==='Kleine Projekte'?'selected':''}>Kleine Projekte</option>
<option ${p.group==='In Anbahnung'?'selected':''}>In Anbahnung</option>
<option ${p.group==='Allgemein'?'selected':''}>Allgemein</option>
</select>
<select onchange="editProjectActiveLP('${p.id}',this.value)" title="Aktive Leistungsphase wählen">
<select onchange="editProjectActiveLP('${p.id}',this.value)" title="Aktive Leistungsphase wählen" ${locked ? 'disabled' : ''}>
<option value="" ${lpActiveList(p).length===0?'selected':''}>Aktive LP: keine</option>
${AUFWAND_LPS.map(l => `<option value="${l}" ${lpActiveList(p)[0]===l?'selected':''}>${l}</option>`).join('')}
</select>
<button class="secondary small" onclick="openLpModal('${p.id}')">✎ LP-Aufwand</button>
<button class="danger small" onclick="delProjectFromList('${p.id}')">Löschen</button>
<div class="project-tags-row">
<div class="tag-chip-list">
${tags.map(tag => `<span class="tag-chip" data-tag="${esc(tag)}">${esc(tag)}${locked ? '' : ` <span class="tag-remove" title="Tag entfernen" onclick="removeProjectTag('${p.id}', this.closest('.tag-chip').dataset.tag)">×</span>`}</span>`).join('')}
</div>
${locked ? '' : `<input type="text" class="tag-input" placeholder="+ Tag" onkeydown="handleProjectTagKeydown(event,'${p.id}')">`}
</div>
<div class="project-card-footer">
<button class="secondary small" onclick="openLpModal('${p.id}')" ${locked ? 'disabled' : ''}>LP-Aufwand</button>
<button class="danger small" onclick="delProjectFromList('${p.id}')" ${locked ? 'disabled' : ''}>Löschen</button>
</div>
</div>`;
});
if(groups[g].length===0) html+=`<div class="hint" style="padding:4px 2px 10px">Keine Projekte — Projekt hierher ziehen.</div>`;
html += `</div>`;
if(groups[g].length===0) html+=`<div class="hint">Keine Projekte.</div>`;
});
document.getElementById('projectsList').innerHTML=html;
listEl.innerHTML = html;
}
// Drag-and-Drop Handling für Projekte
@@ -135,7 +207,12 @@ function handleDrop(event){
sourceSortable.dataset && sortable.dataset &&
sourceSortable.dataset.group === sortable.dataset.group;
if(isSameGroup){
const afterElement = getDragAfterElement(sortable, event.clientY);
// Die Ablagefläche der Projektliste enthält .board-row-Zeilen, der
// Personen-/Projekte-Aufbau .person-list-item. getDragAfterElement()
// braucht den passenden Selektor, sonst findet es keine Nachbarn und
// hängt jedes Projekt ans Gruppenende.
const sel = sortable.querySelector('.board-row') ? '.board-row:not([style*="opacity"])' : undefined;
const afterElement = getDragAfterElement(sortable, event.clientY, sel);
if(afterElement == null){
sortable.appendChild(draggedProject);
} else {
@@ -151,7 +228,13 @@ function handleDrop(event){
// handleDragEnd: stellt die Deckkraft der gezogenen Zeile wieder her und
// setzt `draggedProject` zurück (Ende des Drag-Vorgangs).
function handleDragEnd(event){
event.currentTarget.style.opacity = '1';
// Attribut entfernen statt auf 1 setzen: getDragAfterElement() schließt jedes
// Element mit style="…opacity…" von der Positionsberechnung aus. Nach einem
// abgebrochenen Ziehen (Loslassen außerhalb der Liste) blieb das Attribut
// stehen, und die Zeile wurde beim nächsten Ablegen ignoriert — das Projekt
// landete dann am Gruppenende statt an der gewünschten Stelle.
event.currentTarget.style.removeProperty('opacity');
if(event.currentTarget.getAttribute('style') === '') event.currentTarget.removeAttribute('style');
draggedProject = null;
}
@@ -197,6 +280,8 @@ function reorderProjects(){
});
});
save();
renderProjects();
// renderProjects() nur, solange der eigene Projekte-Bereich existiert; die
// Projektliste im Board rendert renderBoard().
if(typeof renderProjects === 'function' && document.getElementById('projectsList')) renderProjects();
renderBoard();
}
+27 -22
View File
@@ -4,12 +4,15 @@
// Diese Datei kapselt die HTTP-Kommunikation mit dem Express-Server (siehe
// server.js im Projekt-Root) und wird nach js/date-picker.js von beiden
// HTML-Einstiegspunkten geladen. Sie stellt baseUrl, blobToDataUrl,
// sendWeekToServer, sendPeopleToServer und deleteWeekFromServer bereit.
// Genutzt wird sie von js/store.js (syncAllToServer, Speichern aller Wochen
// und der Personenliste), js/weeks.js (Wochen-Löschung) und
// js/plan/plan-state.js (savePlan schreibt direkt die aktuelle Woche über
// sendWeekToServer). Die Daten werden als strukturierte JSON-Objekte an die
// SQLite-API des Servers übergeben (keine CSV-Strings mehr).
// sendWeekToServer und deleteWeekFromServer bereit. Genutzt wird sie von
// js/store.js (syncAllToServer, Speichern aller Wochen), js/weeks.js
// (Wochen-Löschung) und js/plan/plan-state.js (savePlan schreibt direkt die
// aktuelle Woche über sendWeekToServer). Die Daten werden als strukturierte
// JSON-Objekte an die SQLite-API des Servers übergeben (keine CSV-Strings
// mehr). Die globale Personenliste hat seit der Ablösung von
// people/person_roles/person_absences keinen eigenen Schreibpfad mehr
// (Projektbeteiligte werden über die Benutzerverwaltung gepflegt, siehe
// /admin/users bzw. /users/:id/absences).
// baseUrl: dynamische Server-URL, damit die App auch von anderen Geraeten im Netzwerk erreichbar ist.
// Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde.
@@ -46,7 +49,6 @@ function sendWeekToServer(week){
headers:{'Content-Type':'application/json; charset=utf-8'},
body:JSON.stringify({
week,
department:currentDepartment,
baseUpdatedAt:(week && week.updatedAt) ? week.updatedAt : undefined
})
}).then(r=>{
@@ -59,30 +61,33 @@ function sendWeekToServer(week){
err.updatedAt = json.updatedAt;
throw err;
}
throw new Error('Server HTTP '+r.status);
const err = new Error((json && json.error) || ('Server HTTP '+r.status));
err.status = r.status;
err.label = (week && week.label) || '';
throw err;
});
});
}
// sendPeopleToServer: POSTet die globale Personenliste einer Abteilung als
// JSON-Array an <baseUrl>/save-people. Parameter: people (Array).
// Rückgabe: Promise mit Server-Antwort.
function sendPeopleToServer(people){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/save-people',{
method:'POST',
headers:{'Content-Type':'application/json; charset=utf-8'},
body:JSON.stringify({people, department:currentDepartment})
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
}
// deleteWeekFromServer: fordert das Löschen einer Woche über POST /delete-week an
// Parameter: label (String, das Wochen-Label). Rückgabe: Promise mit Server-Antwort.
// Bei einer Fehler-Antwort (z. B. 403 „Keine Berechtigung“) trägt der geworfene
// Error zusätzlich `status` (HTTP-Statuscode), damit Aufrufer (js/weeks.js)
// gezielt darauf reagieren können.
function deleteWeekFromServer(label){
if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar'));
return fetch(baseUrl() + '/delete-week', {
method: 'POST',
headers: {'Content-Type':'application/json; charset=utf-8'},
body: JSON.stringify({ label, department: currentDepartment })
}).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); });
body: JSON.stringify({ label })
}).then(r=>{
if(!r.ok){
return r.json().catch(()=>({})).then(json => {
const err = new Error((json && json.error) || ('Server HTTP '+r.status));
err.status = r.status;
throw err;
});
}
return r.json();
});
}
+178 -115
View File
@@ -1,36 +1,47 @@
// -----------------------------
// Projektübersicht — Daten laden, Speichern, Abteilung wechseln
// Projektübersicht — Daten laden, Speichern
// -----------------------------
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt alle Sitzungen
// beim Start, speichert den kompletten Datenbestand (alle Wochen + globale
// Personenliste) und verwaltet den Wechsel zwischen Abteilungen (ELT/HLS/ARCH).
// Die Daten liegen serverseitig in einer SQLite-Datenbank und werden als
// strukturierte JSON-Objekte über die API ausgetauscht (keine CSV-Strings).
// Sie wird von `Test.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
// die globalen Funktionen `save()`, `syncAllToServer()`, `switchDepartment()`,
// `load()`, `seed()` und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`,
// `js/board/board.js`, `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das Speichern
// ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms debounced.
// Diese Datei bildet die Schnittstelle zum Server: Sie lädt beim Start alle
// (firmenweit globalen, nicht mehr abteilungsgetrennten) Sitzungen und speichert
// den kompletten Datenbestand (alle Wochen + globale Personenliste). Die Daten
// liegen serverseitig in einer SQLite-Datenbank und werden als strukturierte
// JSON-Objekte über die API ausgetauscht (keine CSV-Strings). Sie wird von
// `Test.html` nach `js/date-picker.js` und `js/server.js` geladen und stellt
// die globalen Funktionen `save()`, `syncAllToServer()`, `load()`, `seed()`
// und `demoProj()` bereit. Aufrufer sind u.a. `js/weeks.js`, `js/board/board.js`,
// `js/people/people.js`, `js/projects/projects.js` und `js/load/load.js`; das
// Speichern ist über `saveSyncTimer` (in `js/globals.js` deklariert) um 500 ms
// debounced.
// save: plant einen (debounced) Sync. Standardmäßig wird die aktuell gewählte
// Woche als geändert markiert, da sich die meisten Bearbeitungen auf die
// aktuelle Sitzung beziehen. Optionen:
// { week:false } – aktuelle Woche NICHT markieren (z. B. reine Personen-Aktionen)
// { people:true } – zusätzlich die globale Personenliste als geändert markieren
function save(opts = {}){
if(opts.week !== false){
const w = currentWeek();
if(w) dirtyWeeks.add(w.label);
if(w) markWeekDirty(w.label);
}
if(opts.people) dirtyPeople = true;
scheduleSessionSync();
}
// Dirty-Tracking: merkt sich, welche Wochen bzw. ob die globale Personenliste
// seit dem letzten Sync lokal geändert wurden. Gespeichert wird nur das, was
// sich wirklich geändert hat (statt wie früher immer alle Wochen).
// Dirty-Tracking: merkt sich, welche Wochen seit dem letzten Sync lokal
// geändert wurden. Gespeichert wird nur das, was sich wirklich geändert hat
// (statt wie früher immer alle Wochen). Die globale Personenliste hat seit der
// Ablösung von people/person_roles/person_absences keinen eigenen Schreibpfad
// mehr (Projektbeteiligte werden über die Benutzerverwaltung gepflegt).
let dirtyWeeks = new Set();
let dirtyPeople = false;
// weekChangeCounter: zählt je Woche die Änderungen mit. syncAllToServer merkt
// sich den Stand beim Absenden und räumt die Dirty-Markierung nur auf, wenn er
// sich bis zur Antwort nicht verändert hat. Ohne diesen Zähler verlor eine
// Änderung, die WÄHREND eines laufenden Speichervorgangs entsteht, ihre
// Markierung: Der eingereihte Nachlauf-Sync fand dann nichts mehr zu senden,
// und das Getippte stand nur noch im Browser.
const weekChangeCounter = new Map();
// syncProblemTimer: Wiederholungsversuch nach einem fehlgeschlagenen Speichern.
let syncProblemTimer = null;
// In-Flight-Schutz für syncAllToServer: läuft bereits ein Sync, wird kein
// zweiter gestartet (sonst senden zwei Syncs dieselbe Woche mit gleicher
@@ -39,6 +50,37 @@ let dirtyPeople = false;
let syncInFlight = false;
let syncQueued = false;
// showSyncProblem: blendet unten rechts ein Band ein, wenn das Speichern
// fehlschlägt. Vorher stand der Fehler ausschließlich in der Browser-Konsole —
// der Nutzer arbeitete ahnungslos weiter, während seit Stunden nichts mehr
// gespeichert wurde. Parameter: message (Text) oder null zum Ausblenden.
function showSyncProblem(message){
let el = document.getElementById('syncProblemBar');
if(!message){
if(el) el.style.display = 'none';
return;
}
if(!el){
el = document.createElement('div');
el.id = 'syncProblemBar';
el.className = 'sync-problem-bar';
document.body.appendChild(el);
}
el.textContent = message;
el.style.display = '';
}
// scheduleSyncRetry: versucht ein fehlgeschlagenes Speichern nach 20 Sekunden
// erneut. Ohne das blieb eine Woche nach einem Netzaussetzer dauerhaft
// ungespeichert, bis der Nutzer zufällig etwas anderes bearbeitete.
function scheduleSyncRetry(){
if(syncProblemTimer || dirtyWeeks.size === 0) return;
syncProblemTimer = setTimeout(() => {
syncProblemTimer = null;
if(dirtyWeeks.size > 0) syncAllToServer();
}, 20000);
}
// scheduleSessionSync: plant den Speichervorgang für alle Wochen und Personen
// über einen 500-ms-Timer (Debounce). Bei erneutem Aufruf wird ein laufender
// Timer zurückgesetzt, damit aufeinanderfolgende Änderungen in einem Sync enden.
@@ -48,22 +90,28 @@ function scheduleSessionSync(){
}
// syncAllToServer: schreibt nur die seit dem letzten Sync geänderten Wochen
// plus – falls markiert – die globale Personenliste zum Server. Konflikte
// (409) auf einer Woche werden über handleWeekConflict() gemeldet. Fehlerhaft
// gespeicherte Wochen bleiben dirty, damit der nächste Sync sie erneut
// versucht; nur erfolgreich gespeicherte werden aus dem Dirty-Satz entfernt.
// zum Server. Konflikte (409) auf einer Woche werden über
// handleWeekConflict() gemeldet. Fehlerhaft gespeicherte Wochen bleiben dirty,
// damit der nächste Sync sie erneut versucht; nur erfolgreich gespeicherte
// werden aus dem Dirty-Satz entfernt.
function syncAllToServer(){
if(syncInFlight){ syncQueued = true; return; }
syncInFlight = true;
// Zuerst globale Personenliste speichern, falls geändert
const jobs = [];
if(dirtyPeople) jobs.push(saveGlobalPeopleToServer());
const weekLabels = Array.from(dirtyWeeks);
let problem = '';
weekLabels.forEach(label => {
const w = db.weeks.find(x => x.label === label);
// Die Planung-Sandbox steckt bewusst nicht in db.weeks (siehe
// db.sandboxWeek, oben in load()) — ohne diesen Zweig würde jede
// Sandbox-Änderung hier lautlos verworfen (dirtyWeeks.delete ohne
// jemals zu speichern), weil db.weeks.find() sie nie findet.
const w = label === SANDBOX_WEEK_LABEL ? db.sandboxWeek : db.weeks.find(x => x.label === label);
if(!w){ dirtyWeeks.delete(label); return; }
// Stand der Änderungen zum Zeitpunkt des Absendens merken (siehe
// weekChangeCounter): Nur wenn er bei der Antwort unverändert ist, sind
// wirklich alle Änderungen dieser Woche beim Server angekommen.
const sentGeneration = weekChangeCounter.get(label) || 0;
jobs.push(sendWeekToServer(w)
.then(res => {
if(res && res.ok){
@@ -71,8 +119,9 @@ function syncAllToServer(){
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res.updatedAt) w.updatedAt = res.updatedAt;
dirtyWeeks.delete(label);
if((weekChangeCounter.get(label) || 0) === sentGeneration) dirtyWeeks.delete(label);
} else {
problem = 'Sitzung "' + w.label + '" konnte nicht gespeichert werden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, res && res.error ? res.error : 'unbekannter Fehler');
}
})
@@ -80,8 +129,15 @@ function syncAllToServer(){
if(err && err.conflict){
dirtyWeeks.delete(label);
handleWeekConflict(w, err);
} else if(err && err.status === 401){
// Sitzung am Server abgelaufen: Anmeldefenster zeigen, statt still
// weiterzuarbeiten und alles Weitere zu verlieren.
problem = 'Nicht mehr angemeldet — bitte neu anmelden, sonst gehen Änderungen verloren.';
if(typeof showLoginOverlay === 'function') showLoginOverlay();
console.error('Speichern abgelehnt (nicht angemeldet):', w.label);
} else {
console.error('Sitzung konnte nicht gespeichert werden:', w.label, err.message || err);
problem = 'Speichern fehlgeschlagen (' + (err && err.message ? err.message : 'unbekannt') + ') — neuer Versuch in 20 Sekunden.';
console.error('Sitzung konnte nicht gespeichert werden:', w.label, (err && err.message) || err);
// Label bleibt dirty -> der nächste Sync versucht es erneut.
}
}));
@@ -89,6 +145,8 @@ function syncAllToServer(){
Promise.all(jobs).then(() => {
syncInFlight = false;
showSyncProblem(problem || null);
if(problem) scheduleSyncRetry();
if(syncQueued){ syncQueued = false; syncAllToServer(); }
});
}
@@ -96,12 +154,9 @@ function syncAllToServer(){
// markWeekDirty: markiert eine bestimmte Woche (label) als geändert – für
// Aktionen, die Wochen AUFßER der aktuellen betreffen (z. B. Auto-Lock).
function markWeekDirty(label){
if(typeof label === 'string' && label) dirtyWeeks.add(label);
}
// markPeopleDirty: markiert die globale Personenliste als geändert.
function markPeopleDirty(){
dirtyPeople = true;
if(typeof label !== 'string' || !label) return;
dirtyWeeks.add(label);
weekChangeCounter.set(label, (weekChangeCounter.get(label) || 0) + 1);
}
// handleWeekConflict: wird aufgerufen, wenn der Server eine zwischenzeitlich
@@ -117,16 +172,14 @@ function handleWeekConflict(week, err){
else load();
}
// reloadWeekFromServer: lädt die aktuelle Abteilung neu und ersetzt NUR die
// reloadWeekFromServer: lädt alle Sitzungen neu und ersetzt NUR die
// übergebene Woche (bzw. entfernt sie, falls sie am Server gelöscht wurde).
// Der Dirty-Zustand der konfliktbehafteten Woche wurde bereits von
// syncAllToServer entfernt; alle anderen Dirty-Änderungen bleiben erhalten.
function reloadWeekFromServer(label){
const requestedDepartment = currentDepartment;
fetch(baseUrl() + '/load-all' + departmentParam())
fetch(baseUrl() + '/load-all')
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
if(currentDepartment !== requestedDepartment) return;
const fresh = (Array.isArray(data.weeks) ? data.weeks : [])
.find(w => w && w.label === label);
const idx = db.weeks.findIndex(w => w.label === label);
@@ -149,93 +202,96 @@ function reloadWeekFromServer(label){
showTab(currentTab);
})
.catch(err => {
// KEIN load() als Rückfall: Das leert dirtyWeeks und ersetzt alle Wochen,
// also gingen auch die noch ungespeicherten Änderungen an ANDEREN
// Sitzungen verloren — genau das, was diese Funktion vermeiden soll.
console.error('Neu laden der Sitzung fehlgeschlagen:', err);
load();
showSyncProblem('Die Sitzung "' + label + '" konnte nicht neu geladen werden. Bitte die Seite neu laden.');
});
}
// saveGlobalPeopleToServer: speichert die globale Personenliste der aktuellen
// Abteilung über `sendPeopleToServer()` (JSON-Array). Auch eine leere Liste
// wird gespeichert, damit das Löschen der letzten Person erhalten bleibt. Bei
// Erfolg wird der Dirty-Status für Personen zurückgesetzt, bei Fehler bleibt
// er erhalten (Retry beim nächsten Sync). Rückgabe: Promise.
function saveGlobalPeopleToServer(){
const people = db.globalPeople[currentDepartment] || [];
return sendPeopleToServer(people)
.then(res => {
if(res && res.ok){
dirtyPeople = false;
console.log('Globale Personenliste gespeichert');
} else {
console.error('Globale Personenliste konnte nicht gespeichert werden:', res && res.error ? res.error : 'unbekannter Fehler');
}
// load: liest alle Sitzungen und die globale Personenliste über `/load-all`
// als JSON vom Server und baut daraus `db.weeks` auf. Sortiert nach
// `weekSortKey()`, wendet `applyAutoLock()` an und rendert die Ansicht. Kein
// Parameter, keine Rückgabe; bei Fehlern oder fehlenden Daten werden
// Beispieldaten über `seed()` geladen.
// loadAppSettings: holt die firmenweiten Einstellungen (GET /app-settings)
// und die Feiertage (GET /holidays) und legt sie in APP_SETTINGS/HOLIDAYS ab
// (js/globals.js). Beides wird zum RECHNEN gebraucht — Auslastung, Ampel,
// Terminplan aus dem Honorar —, deshalb sind die Routen für jedes angemeldete
// Konto lesbar; nur das Ändern ist Administratoren vorbehalten.
//
// Scheitert der Abruf, bleiben die Vorgaben aus js/globals.js stehen. Das ist
// Absicht: Die Anwendung rechnet dann wie vor dem Ausbau der Einstellungen
// weiter, statt mit leeren Werten falsche Zahlen zu zeigen.
// Rückgabe: Promise (wird nie abgelehnt).
function loadAppSettings(){
const settings = fetch(baseUrl() + '/app-settings')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => { if(data && data.settings) APP_SETTINGS = Object.assign({}, APP_SETTINGS, data.settings); })
.catch(err => { console.warn('Firmenweite Einstellungen konnten nicht geladen werden, es gelten die Vorgabewerte:', err); });
const holidays = fetch(baseUrl() + '/holidays')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
const list = Array.isArray(data.holidays) ? data.holidays : [];
HOLIDAYS = new Set(list.map(h => h.date));
HOLIDAY_NAMES = {};
list.forEach(h => { HOLIDAY_NAMES[h.date] = h.name || ''; });
// Die Bundesländer liefert dieselbe Route mit (siehe server.js); die
// Pflege-Maske im Admin-Bereich baut daraus ihre Auswahl.
if(data.states) GERMAN_STATES = data.states;
})
.catch(err => {
console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err);
// dirtyPeople bleibt true -> der nächste Sync versucht es erneut.
});
.catch(err => { console.warn('Feiertage konnten nicht geladen werden:', err); });
// Honorar-Anteile je Leistungsphase: Die Auslastung rechnet den offenen
// Wert einer Phase in Stunden um und braucht dafür deren Anteil am
// Gesamthonorar. Zusammen mit den übrigen Vorgaben geladen, also VOR
// /load-all und damit vor dem ersten renderAll().
const honorar = fetch(baseUrl() + '/honorar-config')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => { HONORAR_ANTEILE = (data && data.anteile) || {}; })
.catch(err => { console.warn('Honorar-Anteile konnten nicht geladen werden, die Auslastung weist sie als fehlend aus:', err); });
return Promise.all([settings, holidays, honorar]);
}
// switchDepartment: wechselt die aktive Abteilung (z.B. "ELT") und lädt deren
// Daten neu. Parameter `dept` ist der Abteilungsschlüssel aus `DEPARTMENTS`.
// Aktualisiert Tab-Markierung und Seitentitel und ruft anschließend `load()`.
// Gibt nichts zurück.
function switchDepartment(dept){
if(!DEPARTMENTS[dept]) return;
// Laufenden Speicher-Debounce der alten Abteilung verwerfen, damit dessen
// Sync nicht nach dem Reset mit dem neuen currentDepartment feuert und die
// Daten der falschen Abteilung schreibt.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
dirtyPeople = false;
currentDepartment = dept;
saveUiState();
updateRouteUrl(dept, currentTab);
// UI-Status der Abteilungs-Tabs aktualisieren
document.querySelectorAll('.dept-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.dept === dept);
});
// Seitentitel anpassen
const info = DEPARTMENTS[dept];
document.title = 'Projektübersicht ' + info.label;
const titleEl = document.getElementById('pageTitle');
if(titleEl) titleEl.textContent = info.icon + ' Projektübersicht ' + info.label;
// Datenbank leeren und neu laden
db = { weeks: [], currentWeek: null, globalPeople: db.globalPeople || {} };
load();
}
// load: liest alle Sitzungen und die globale Personenliste der aktuellen
// Abteilung über `/load-all` als JSON vom Server und baut daraus `db.weeks`
// auf. Sortiert nach `weekSortKey()`, wendet `applyAutoLock()` an und rendert
// die Ansicht. Kein Parameter, keine Rückgabe; bei Fehlern oder fehlenden
// Daten werden Beispieldaten über `seed()` geladen.
function load(){
// Nach einem (Neu-)Load sind die lokalen Daten serverseitig aktuell:
// laufenden Debounce abbrechen und Dirty-Zustand verwerfen.
if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; }
dirtyWeeks.clear();
dirtyPeople = false;
if(!window.fetch){
alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.');
seed(); return;
return;
}
// Abteilung einfrieren, für die diese Anfrage gestartet wurde. Antworten,
// die nach einem zwischenzeitlichen Abteilungswechsel eintreffen, werden
// verworfen (Stale-Response), sonst würden Daten der alten Abteilung unter
// der neuen Abteilung angezeigt und gespeichert.
const requestedDepartment = currentDepartment;
const url = baseUrl() + '/load-all' + departmentParam();
const url = baseUrl() + '/load-all';
console.log('Loading from', url);
fetch(url)
// Erst die firmenweiten Einstellungen und Feiertage, dann die Sitzungen:
// renderAll() am Ende rechnet bereits damit (Ampel-Schwellen, Balkenskala,
// Aufwandsvorlage, Arbeitstage). loadAppSettings() lehnt nie ab, ein
// Fehlschlag lässt also nur die Vorgabewerte stehen.
loadAppSettings().then(() => fetch(url)
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
if(currentDepartment !== requestedDepartment) return;
// Globale Personenliste laden
db.globalPeople[currentDepartment] = Array.isArray(data.people) ? data.people : [];
// Globale Personenliste laden (flaches Array, keine Abteilungstrennung)
db.globalPeople = Array.isArray(data.people) ? data.people : [];
// db.sandboxWeek: die Planung-Sandbox (SANDBOX_WEEK_LABEL) — bewusst
// NICHT Teil von db.weeks (siehe /load-all, server.js), sondern ein
// eigenes, synthetisches Wochen-ähnliches Objekt, hier und nur hier
// aufgebaut. Damit sehen renderBoard()/berichtProjekte()/
// berechneAuslastung()/die Sitzungs-PDFs/Admin→Sitzungen sie nie —
// sie iterieren alle ausschließlich db.weeks. people wird aus der
// globalen Personenliste abgeleitet (dieselbe Form wie week.people),
// damit die Beteiligten-Matrix im Projektbereich auch für einen
// Sandbox-Plan Kandidaten zur Auswahl hat.
db.sandboxWeek = {
id: 'sandbox', label: SANDBOX_WEEK_LABEL, date: '',
people: db.globalPeople.map(p => ({ id: p.id, name: p.name, fullName: p.fullName })),
projects: Array.isArray(data.sandboxPlans) ? data.sandboxPlans : [],
attendance: {}, locked: false, updatedAt: data.sandboxUpdatedAt || ''
};
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
@@ -262,12 +318,11 @@ function load(){
}
// Geschützte Tabs ('projects'/'people') dürfen nur mit Passwort angezeigt
// werden. showTab() öffnet dafür automatisch das Passwort-Modal – auch
// wenn der Tab per Deep-Link-URL (z. B. "/ELT/Projekte") gewählt wurde.
// wenn der Tab per Deep-Link-URL (z. B. "/Projekte") gewählt wurde.
renderAll();
showTab(currentTab);
})
.catch(err => {
if(currentDepartment !== requestedDepartment) return;
console.error('Laden vom Server fehlgeschlagen:', err);
const serverUrl = baseUrl();
const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' +
@@ -275,10 +330,16 @@ function load(){
'- Der Server wurde nicht gestartet\n' +
'- Der Server läuft auf einem anderen Port (Standard: 3003)\n' +
'- Firewall/Antivirus blockiert ' + serverUrl + '\n\n' +
'Es werden jetzt die Beispieldaten angezeigt.';
'Es werden keine Daten angezeigt. Bitte die Seite neu laden, sobald der Server wieder läuft.';
alert(msg);
seed(); renderAll();
});
// BEWUSST KEIN seed(): Die Beispieldaten enthalten eine Woche "KW 30 / 2026"
// und wurden früher über save() an den Server geschickt, sobald die
// Verbindung zurückkam — sie überschrieben damit die echte gleichnamige
// Sitzung. Bei einem Ladefehler bleibt der Bestand jetzt einfach leer.
db.weeks = [];
db.currentWeek = null;
renderAll();
}));
}
// seed: initialisiert Beispiel-Daten (Personen + eine Woche mit Projekten).
@@ -299,7 +360,7 @@ function seed(){
{id:personIdForName('MK'), name:"MK", fullName:"Martin Kluge", role:["Bauüberwachung"]},
{id:personIdForName('HR'), name:"HR", fullName:"Hartwig Rauch", role:["Bauüberwachung"]},
];
db.globalPeople[currentDepartment] = JSON.parse(JSON.stringify(people));
db.globalPeople = JSON.parse(JSON.stringify(people));
const wid = uid();
db.weeks = [{
@@ -315,7 +376,9 @@ function seed(){
save();
}
// demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests)
// demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests).
// projectUid: dauerhafte, wochenunabhängige Identität (siehe saveNewProj()
// in js/load/load.js für die Erklärung, warum sie sich von id unterscheidet).
function demoProj(nr,name,group,status,lph){
return {id:uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined};
return {id:uid(), projectUid: uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined};
}
+83 -59
View File
@@ -1,76 +1,100 @@
// -----------------------------
// Projektübersicht — Tabs (Navigation) & Passwort-Schutz
// Projektübersicht — Tabs (Navigation)
// -----------------------------
// Modul für die Tab-Navigation und den Passwortschutz der Hauptseite (nur von
// Test.html geladen, nach js/showmode.js, vor js/pdf.js). showTab() wechselt
// zwischen Board, Auslastung, Projekte und Projektbeteiligte und fordert für
// die geschützten Tabs 'projects'/'people' das Passwort an; confirmPassword()
// prüft den SHA256-Hash gegen PROTECTED_PASSWORD_HASH (js/globals.js). Die
// ausstehende Aktion wird in requestedAction (js/globals.js) gemerkt – davon
// profitieren auch die Passwort-Gates in js/weeks.js (Löschen/Entsperren von
// Sitzungen). Gerendert werden beim Tab-Wechsel renderBoard()/renderLoad()/
// renderProjects()/renderPeople() aus js/board/board.js, js/load/load.js, js/projects/projects.js,
// js/people/people.js.
// Modul für die Tab-Navigation der Hauptseite (nur von Test.html geladen, nach
// js/showmode.js, vor js/pdf.js). showTab() wechselt zwischen Board,
// Wochenbericht, Auslastung, Projekte, Projektbeteiligte, Meine Aufgaben,
// Einstellungen, Admin und dem Projektbereich eines einzelnen Projekts
// ('plan', siehe TAB_PANELS).
// Einstellungen und Admin sind keine eigenen Tabs mehr, sondern aufklappbare
// Bereiche direkt in der Seitenleiste (siehe toggleSidebarAccordion() in
// js/ui.js). Gerendert werden beim Tab-Wechsel renderBoard()/
// renderWochenbericht()/renderLoad()/renderProjects()/renderPeople()/
// renderDashboard() aus js/board/board.js, js/wochenbericht/wochenbericht.js,
// js/load/load.js, js/projects/projects.js, js/people/people.js,
// js/dashboard/dashboard.js. Der frühere clientseitige Passwortschutz für die
// Tabs 'projects'/'people' wurde entfernt — der Zugriffsschutz läuft jetzt
// serverseitig über die Session (siehe js/auth.js, server.js).
//
// TAB_PANELS: alle Bereiche der Hauptseite. 'plan' ist der Projektbereich
// EINES Projekts (#plan) — früher die eigenständige Seite project-plan.html,
// die in einem eigenen Dokument geöffnet wurde. Seit dem Umbau ist er ein
// Bereich wie jeder andere: Menüband und Seitenleiste bleiben stehen, der
// Wechsel dorthin läuft über denselben showTab()-Weg wie der Wechsel vom
// Dashboard ins Projektboard.
const TAB_PANELS = ['board','wochenbericht','load','people','planung','dashboard','settings','admin','plan'];
// showTab: wechselt zwischen Ansichten; für sensible Tabs wird ein Passwort verlangt
function showTab(t, skipAuth = false){
// Für die Tabs 'projects' und 'people' ist ein Passwortschutz vorgesehen
if((t==='projects' || t==='people') && !skipAuth && !isAuthenticated){
requestedAction = { type: 'tab', tab: t };
document.getElementById('passwordModal').style.display='flex';
document.getElementById('passwordInput').value='';
document.getElementById('passwordInput').focus();
return;
}
function showTab(t){
// Der Bereich „Admin" steht nur Administratoren offen. Ohne diese Weiche
// zeigte ein weitergereichter Deep-Link „/Admin" einem Mitglied eine leere
// Seite — die Karten darin rendert renderAdminArea() für Nicht-Admins gar
// nicht erst. (Ein UI-Gate, mehr nicht: Die Routen dahinter sind
// serverseitig mit requireRole('admin') abgesichert.)
if(t === 'admin' && !(currentUser && currentUser.role === 'admin')) t = DEFAULT_TAB;
if(t !== currentTab) previousTab = currentTab;
currentTab = t;
saveUiState();
updateRouteUrl(currentDepartment, t);
updateRouteUrl(t);
['board','projects','load','people'].forEach(x=>{
TAB_PANELS.forEach(x=>{
const el = document.getElementById(x);
if(el) el.style.display=(x===t?'block':'none');
});
// Der Projektbereich steht als einziger Bereich AUSSERHALB von .container
// (er bringt eigene Kopfzeile und Innenabstände mit). Der leere Container
// hinterließe sonst seinen Innenabstand als blinden Streifen über der
// Kopfzeile des Projektbereichs.
const containerEl = document.querySelector('.app-main > .container');
if(containerEl) containerEl.style.display = (t === 'plan') ? 'none' : '';
document.querySelectorAll('.tab').forEach(el=>el.classList.toggle('active',el.dataset.tab===t));
if(t==='board' && currentWeek()) renderBoard();
if(t==='projects') renderProjects();
if(t==='wochenbericht') renderWochenbericht();
if(t==='load') renderLoad();
if(t==='people') renderPeople();
if(t==='planung') renderPlanung();
if(t==='dashboard') renderDashboard();
if(t==='settings') renderSettings();
if(t==='admin') renderAdminArea();
syncAdminNavState(t);
// Der Projektbereich wird erst NACH dem Einblenden gerendert: das GANTT
// rechnet mit der Containerbreite, die in einem ausgeblendeten Bereich 0
// wäre. Das Ziel (Sitzung + Projekt) steht vorher über setPlanTarget().
if(t==='plan') openPlanView();
syncPlanNavItem();
}
// ---------- Passwort-Schutz Implementierung ----------
// confirmPassword: prüft das eingegebene Passwort gegen den gespeicherten Hash
function confirmPassword(){
const pwd = document.getElementById('passwordInput').value;
if(!pwd) { alert("Bitte Passwort eingeben!"); return; }
const pwdHash = CryptoJS.SHA256(pwd).toString();
if(pwdHash === PROTECTED_PASSWORD_HASH){
// Sitzungs-Authentifizierung merken, damit Tab-Wechsel und Abteilungs-
// wechsel innerhalb dieser Sitzung kein erneutes Passwort verlangen.
isAuthenticated = true;
// Nur die zuletzt angeforderte Aktion ausführen (nicht eine evtl. bereits
// durch closePasswordModal verworfenen Aktion erneut starten) – daher die
// Aktion hier erneut über requestedAction prüfen statt sie festzuhalten.
setTimeout(() => {
const action = requestedAction;
requestedAction = null;
if(action?.type==='tab') showTab(action.tab, true);
else if(action?.type==='deleteWeek') deleteWeek(true);
else if(action?.type==='unlockWeek') unlockCurrentWeek(true);
else if(action?.type==='importDb') importDb(true);
}, 100);
document.getElementById('passwordModal').style.display='none';
} else {
alert("Falsches Passwort!");
document.getElementById('passwordInput').value='';
document.getElementById('passwordInput').focus();
// syncAdminNavState: „Admin" ist in der Seitenleiste kein .tab-Knopf,
// sondern der Kopf einer aufklappbaren Gruppe — die allgemeine
// .tab-Hervorhebung oben greift dort also nicht. Diese Funktion markiert ihn
// stattdessen, solange der Bereich offen ist, und klappt die Gruppe dabei
// auf, damit der gewählte Abschnitt sichtbar wird.
// Parameter: t (aktiver Bereich). Keine Rückgabe.
function syncAdminNavState(t){
const toggle = document.getElementById('adminAccordionToggle');
const subnav = document.getElementById('adminSubnav');
if(!toggle || !subnav) return;
const aktiv = (t === 'admin');
toggle.classList.toggle('active', aktiv);
if(aktiv && !subnav.classList.contains('open')){
subnav.classList.add('open');
toggle.classList.add('expanded');
toggle.setAttribute('aria-expanded', 'true');
}
}
// closePasswordModal: schließt das Passwort-Modal ohne Ausführung und
// verwirft die gemerkte Aktion (requestedAction = null).
function closePasswordModal(){
document.getElementById('passwordModal').style.display='none';
requestedAction = null;
}
// syncPlanNavItem: hält den Eintrag des Projektbereichs in der Seitenleiste
// aktuell. Er ist der einzige Navigationspunkt, der nicht immer da ist — er
// erscheint erst, wenn ein Projekt geöffnet wurde, und trägt dann dessen
// Bezeichnung. Damit zeigt die Seitenleiste auch im Projektbereich, wo man
// gerade ist, statt (wie es ohne ihn wäre) gar keinen Punkt hervorzuheben.
// Keine Parameter, keine Rückgabe.
function syncPlanNavItem(){
const item = document.getElementById('planNavItem');
if(!item) return;
const app = window.PlanApp;
const name = app && app.projName ? app.projName : '';
item.style.display = name ? '' : 'none';
const label = document.getElementById('planNavItemLabel');
if(label) label.textContent = name || 'Projektbereich';
item.title = name ? ('Projektbereich: ' + name) : 'Projektbereich';
}
+112 -105
View File
@@ -2,30 +2,19 @@
// Projektübersicht — Sitzungs-/Wochen-Verwaltung
// -----------------------------
// Diese Datei verwaltet die Sitzungen (Wochen): Auswahl, Anzeige des Datums,
// Erzeugung neuer Sitzungen, Duplizieren, Sperren (Auto-Lock) und Löschen.
// Sie wird von `Test.html` nach `js/store.js` geladen und stellt globale
// Funktionen wie `currentWeek()`, `switchWeek()`, `newWeek()`, `cloneWeek()`,
// `applyAutoLock()`, `guardWeekLocked()` und `deleteWeek()` bereit. Genutzt wird
// Sperren (Auto-Lock) und Löschen. Sie wird von `Test.html` nach `js/store.js`
// geladen und stellt globale Funktionen wie `currentWeek()`, `switchWeek()`,
// `applyAutoLock()`, `guardWeekLocked()`, `unlockWeek()` und `deleteWeekById()`
// bereit. Genutzt wird
// sie u.a. von `js/board/board.js` (`renderBoard`, `isWeekLocked`), `js/store.js`
// (`load`) und `js/tabs-auth.js` (Passwort-Aktionen für Entsperren/Löschen).
// refreshWeekSelect: aktualisiert das Dropdown `weekSelect` mit allen Sitzungen
// der aktuellen Abteilung und markiert die aktuell ausgewählte. Kein Parameter,
// keine Rückgabe.
function refreshWeekSelect(){
const sel = document.getElementById('weekSelect');
sel.innerHTML = db.weeks.map(w=>`<option value="${w.id}" ${w.id===db.currentWeek?'selected':''}>${w.label}</option>`).join('');
}
// updateWeekDate: zeigt das Datum der aktuellen Sitzung im Display
// `weekDateDisplay` an (formatiert über `formatWeekDate()`). Kein Parameter,
// keine Rückgabe; leer, wenn keine Woche gewählt ist.
function updateWeekDate(){
const disp = document.getElementById('weekDateDisplay');
const w = currentWeek();
if(!disp) return;
disp.textContent = w?.date ? `Datum: ${formatWeekDate(w.date)}` : '';
}
//
// Neue Sitzungen entstehen seit dem automatischen Sitzungswechsel NICHT MEHR
// hier — `ensureCurrentWeekRollover()` (server.js) legt sie serverseitig an,
// sobald der Kalender in eine neue ISO-Woche wechselt, unabhängig davon, ob
// irgendjemand die Anwendung gerade geöffnet hat (siehe CLAUDE.md). Die
// früheren Funktionen `newWeek()`/`cloneWeek()`/`validateWeekLabel()` sind
// deshalb entfallen.
// currentWeek: liefert das Woche-Objekt zur aktuellen Auswahl aus `db.weeks`.
// Kein Parameter; Rückgabe: das gefundene Woche-Objekt oder undefined.
@@ -37,53 +26,30 @@ function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); }
// Keine Rückgabe.
function switchWeek(id){ db.currentWeek = id; renderAll(); }
// newWeek: erzeugt über zwei prompt-Dialoge eine neue leere Sitzung (Bezeichnung
// und Datum). Die neue Woche übernimmt die globale Personenliste der Abteilung,
// wird angehängt, per `applyAutoLock()` gesperrt und als aktuelle Woche gesetzt.
// Keine Parameter, keine Rückgabe.
function newWeek(){
const label = prompt("Bezeichnung der neuen Sitzung (z.B. KW 31 / 2026):","KW / 2026");
if(!label) return;
const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || "";
// Neue Woche übernimmt die aktuell globale Personenliste
const people = JSON.parse(JSON.stringify(db.globalPeople[currentDepartment] || []));
const w = {id:uid(), label, date, people, projects:[], attendance:{}, locked:false};
db.weeks.push(w);
applyAutoLock();
db.currentWeek=w.id; save(); renderAll();
}
// cloneWeek: dupliziert die aktuelle Sitzung inkl. Projekten (Dialog für
// Bezeichnung und Datum). Projekte erhalten neue IDs; Status-Markierungen
// werden auf `statusState='duplicated'` gesetzt. Keine Parameter, keine Rückgabe.
function cloneWeek(){
const cur = currentWeek(); if(!cur) return;
const label = prompt("Bezeichnung der duplizierten Sitzung:", cur.label+" (Kopie)");
if(!label) return;
const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || "";
const w = JSON.parse(JSON.stringify(cur));
w.id=uid(); w.label=label; w.date=date; w.attendance={}; w.locked=false;
// Neue IDs für duplizierte Projekte und Kennzeichnung
w.projects.forEach(p=>{
p.id = uid();
if(p.status && p.status.trim() !== "") p.statusState = 'duplicated';
else delete p.statusState;
});
db.weeks.push(w);
applyAutoLock();
db.currentWeek=w.id; save(); renderAll();
}
// -------- Sperren (Auto-Lock) und Entsperren --------
// applyAutoLock: sperrt alle Sitzungen bis auf die chronologisch
// letzte (aktuellste). Wird beim Laden, Erstellen und Duplizieren verwendet,
// damit historische Sitzungen nicht mehr unbeabsichtigt bearbeitet werden.
//
// Firmenweit abschaltbar (Einstellung 'autoLockAktiv'): In manchen Büros wird
// an mehreren Sitzungen parallel gearbeitet, dort steht die automatische
// Sperre im Weg. Ist sie aus, wird NICHT nachträglich entsperrt — bereits
// gesperrte Sitzungen bleiben es, das Entsperren bleibt eine bewusste
// Admin-Handlung (POST /unlock-week).
function applyAutoLock(){
if(db.weeks.length === 0) return;
const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
if(!Number(APP_SETTINGS.autoLockAktiv)) return;
// Die Planung-Sandbox (SANDBOX_WEEK_LABEL) landet nie in db.weeks (siehe
// load(), js/store.js) und ist von dieser Schleife also strukturell schon
// ausgenommen — dieser Filter ist ein reines Sicherheitsnetz, falls das
// je nicht mehr zuträfe: sie darf nie als "späteste Woche" zählen oder
// gesperrt werden, sie ist keine echte Sitzung.
const echteWochen = db.weeks.filter(w => w.label !== SANDBOX_WEEK_LABEL);
if(echteWochen.length === 0) return;
const sorted = [...echteWochen].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
const latestId = sorted[sorted.length - 1].id;
db.weeks.forEach(w => {
echteWochen.forEach(w => {
const wasLocked = !!w.locked;
w.locked = (w.id !== latestId);
// Nur tatsächlich geänderte Lock-Zustände übertragen (beim bloßen Laden
@@ -111,52 +77,93 @@ function guardWeekLocked(){
return false;
}
// unlockCurrentWeek: Entsperrt die aktuelle Sitzung nach Passwortprüfung.
// Parameter `skipAuth=true` überspringt die Passwortabfrage (Aufruf nach
// erfolgreicher Authentifizierung). Keine Rückgabe.
function unlockCurrentWeek(skipAuth = false){
const w = currentWeek();
// unlockWeek: entsperrt EINE Sitzung serverseitig (POST /unlock-week,
// admin-only) und übernimmt bei Erfolg den Sperr-Zustand sowie die neue
// Revision (`updatedAt`) lokal.
//
// Verallgemeinert gegenüber der früheren Fassung, die nur `currentWeek()`
// kannte: Seit die Sitzungsauswahl aus dem Menüband entfernt wurde
// (Sitzungen dienen nur noch der Speicherung und dem Verlauf), muss sich
// auch eine ANDERE als die gerade offene Sitzung entsperren lassen — die
// Sitzungsliste im Admin-Bereich (renderAdminSitzungen(), js/admin/admin.js)
// braucht genau das.
// Parameter: w (Woche-Objekt aus db.weeks), nachRender (optionale Funktion
// statt renderAll() — die Admin-Liste soll nur sich selbst neu zeichnen,
// nicht die ganze Seite, die ohnehin die entsperrte Sitzung nicht zeigt).
// Keine Rückgabe.
function unlockWeek(w, nachRender){
if(!w || !w.locked) return;
if(!skipAuth && !isAuthenticated){
requestedAction = { type: 'unlockWeek' };
document.getElementById('passwordModal').style.display='flex';
document.getElementById('passwordInput').value='';
document.getElementById('passwordInput').focus();
return;
}
w.locked = false;
save(); renderAll();
requestedAction = null;
fetch(baseUrl() + '/unlock-week', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ label: w.label })
}).then(r => {
if(r.status === 403){ alert('Nur Administratoren dürfen Sitzungen entsperren.'); return null; }
return r.json().then(json => ({ status: r.status, json }));
}).then(result => {
if(!result) return;
const { status, json } = result;
if(status === 200 && json && json.ok){
w.locked = false;
if(json.updatedAt) w.updatedAt = json.updatedAt;
(nachRender || renderAll)();
} else {
alert('Entsperren fehlgeschlagen: ' + (json && json.error ? json.error : 'unbekannter Fehler'));
}
}).catch(err => {
alert('Entsperren fehlgeschlagen: ' + (err.message || err));
});
}
// deleteWeek: löscht die aktuell ausgewählte Sitzung (nur wenn mehr als 1
// vorhanden) auf dem Server. Parameter `skipAuth=true` überspringt die
// Passwortabfrage. Danach wird die letzte verbleibende Woche aktiviert und neu
// gerendert. Keine Rückgabe.
function deleteWeek(skipAuth = false){
// unlockCurrentWeek: entsperrt die gerade offene Sitzung. Aufrufer ist
// ausschließlich der Sperr-Hinweis auf dem Board/Wochenbericht
// (renderBoard()/renderWochenbericht()), der nur erscheint, wenn
// `currentWeek()` selbst gesperrt ist — im Normalbetrieb mit aktiver
// Auto-Sperre kommt das praktisch nicht vor, da immer nur die jüngste
// Sitzung offen bleibt. Dünner Wrapper um `unlockWeek()`.
function unlockCurrentWeek(){ unlockWeek(currentWeek()); }
// deleteWeekById: löscht EINE Sitzung (nicht zwingend die aktuell offene)
// mitsamt allen Projekten, Zuordnungen und Plänen dieser Sitzung auf dem
// Server (admin-only, POST /delete-week). Mindestens eine Sitzung muss
// bestehen bleiben. Die lokale Woche wird erst nach erfolgreicher
// Server-Antwort entfernt (sonst würde sie bei fehlender Berechtigung
// lokal verschwinden, obwohl sie auf dem Server weiterhin existiert).
//
// Ersetzt die frühere `deleteWeek()`, die stillschweigend "die im Menüband
// gewählte Sitzung" meinte — ohne diese Auswahl im Menüband ist eine
// explizite Ziel-Sitzung nötig; Aufrufer ist jetzt ausschließlich die
// Sitzungsliste im Admin-Bereich.
// Parameter: weekId, nachDelete (optionale Zusatzfunktion, die NACH dem
// üblichen renderAll() zusätzlich läuft — die Admin-Liste zeichnet sich
// darüber selbst neu, weil renderAll() sie nicht kennt). Keine Rückgabe.
function deleteWeekById(weekId, nachDelete){
if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; }
if(!skipAuth && !isAuthenticated){
requestedAction = { type: 'deleteWeek' };
document.getElementById('passwordModal').style.display='flex';
document.getElementById('passwordInput').value='';
document.getElementById('passwordInput').focus();
return;
}
if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; }
const current = currentWeek();
if(current){
const label = current.label;
console.log('Lösche Woche auf Server:', label);
deleteWeekFromServer(label).then(res=>{
if(res && res.ok){ console.log('Woche gelöscht:', label); }
else { alert('Wochen-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); }
}).catch(err=>{ alert('Wochen-Löschung fehlgeschlagen: ' + (err.message || err)); });
}
db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek);
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
db.currentWeek = db.weeks[db.weeks.length - 1].id;
applyAutoLock();
save();
renderAll();
requestedAction = null;
const w = db.weeks.find(x => x.id === weekId);
if(!w) return;
if(!confirm('Sitzung "' + w.label + '" wirklich löschen?')) return;
console.log('Lösche Woche auf Server:', w.label);
deleteWeekFromServer(w.label).then(res=>{
if(!(res && res.ok)){
alert('Wochen-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler'));
return;
}
console.log('Woche gelöscht:', w.label);
const warAktuell = (weekId === db.currentWeek);
db.weeks = db.weeks.filter(x=>x.id!==weekId);
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
// Zeigte die Oberfläche gerade die gelöschte Sitzung (oder zeigt sie aus
// irgendeinem Grund gar keine mehr existierende), auf die jüngste
// verbleibende wechseln — sonst verwiese db.currentWeek ins Leere.
if(warAktuell || !db.weeks.some(x => x.id === db.currentWeek)){
db.currentWeek = db.weeks[db.weeks.length - 1].id;
}
applyAutoLock();
save();
renderAll();
if(typeof nachDelete === 'function') nachDelete();
}).catch(err=>{
if(err && err.status === 403){ alert('Nur Administratoren dürfen Sitzungen löschen.'); return; }
alert('Wochen-Löschung fehlgeschlagen: ' + (err.message || err));
});
}
+7 -3
View File
@@ -727,7 +727,7 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
/* Ladehinweis im Import-Dialog (#protokollImportLoading, Test.html): ohne
ihn wirkte der Dialog während eines länger laufenden KI-Aufrufs (bis zu
KI_TIMEOUT_MS_LOKAL = 5 Minuten bei einem eigenen Server, server.js) wie
KI_TIMEOUT_MS_LOKAL = 30 Minuten bei einem eigenen Server, server.js) wie
hängengeblieben — Felder und Knöpfe werden während der Anfrage zusätzlich
gesperrt (setzeProtokollImportLadezustand(), plan-protokoll.js), damit
kein zweiter Import gleichzeitig losgeschickt wird.
@@ -787,6 +787,11 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
.protokoll-punkt.is-neu { background: var(--protokoll-neu-bg); border-color: var(--protokoll-neu-border);
border-left: 3px solid var(--protokoll-neu-border); padding-left: 9px; }
.protokoll-punkt.is-erledigt { opacity: .6; }
/* info: rein informativ, keine eigene Handlung nötig — läuft wie erledigt
nicht ins nächste Protokoll weiter (legeProtokollAn(), server.js), ist
aber keine abgeschlossene Aufgabe, deshalb bewusst eine eigene, mildere
Abstufung statt derselben Ausgrautheit wie bei erledigt. */
.protokoll-punkt.is-info { opacity: .85; }
.protokoll-punkt-top-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.protokoll-punkt-nr { display: inline-block; width: 46px; flex-shrink: 0; font-variant-numeric: tabular-nums;
font-weight: 650; font-size: 12.5px; text-align: center; padding: 5px 4px; color: var(--text-muted);
@@ -796,8 +801,7 @@ html[data-theme="dark"] { --seg-hover: rgba(255,255,255,.10); }
.protokoll-chip-neu { background: var(--protokoll-neu-border); color: #3d2e00; font-size: 10px; font-weight: 700;
letter-spacing: .03em; text-transform: uppercase; padding: 2px 7px; border-radius: var(--r-pill); flex-shrink: 0; }
.protokoll-punkt-thema { flex: 1; min-width: 160px; font-weight: 600; font-size: 13px; }
.protokoll-punkt-erledigt-toggle { display: flex; align-items: center; gap: 5px; font-size: 12px;
color: var(--text-muted); flex-shrink: 0; }
.protokoll-punkt-status { flex-shrink: 0; font-size: 12px; padding: 5px 8px; color: var(--text-muted); }
.protokoll-punkt-text { width: 100%; resize: vertical; font: inherit; font-size: 12.5px; color: var(--text-muted);
margin: 0 0 8px; box-sizing: border-box; }
.protokoll-punkt-foot { display: flex; gap: 18px; flex-wrap: wrap; font-size: 12px; color: var(--text-subtle); }
-330
View File
@@ -1,330 +0,0 @@
<!DOCTYPE html>
<!--
PROJEKTPLAN-ANSICHT: zeigt für ein einzelnes Projekt ein GANTT-Diagramm mit TODO- und
Meilenstein-Zeilen, Abhängigkeiten, Zoom, kritischem Pfad, Auto-Schedule, Ressourcen-
Auslastung und Abwesenheiten der Projektbeteiligten. Die Seite wird über openProjectPlan()
in js/gantt.js in einem neuen Tab geöffnet und per Deep-Link-Route
"/<Abteilung>/Projektplan/<Projektname>" adressiert; Woche und Projekt-Nr.
kommen als URL-Parameter (weekLabel, projNr). Sie lädt styles.css und ausschließlich die gemeinsamen
Module js/globals.js, js/utils.js, js/ui.js, js/date-picker.js, js/server.js
und js/gantt.js sowie danach die Plan-Module js/plan/plan-state.js,
js/plan/plan-build-gantt.js, js/plan/plan-gantt.js, js/plan/plan-drag.js,
js/plan/plan-modals.js und js/plan/plan-schedule.js. WICHTIG: store, weeks, board, people,
projects, load, showmode, tabs-auth, pdf und init dürfen hier NICHT geladen werden, da sie
Elemente der Hauptseite Test.html voraussetzen. Änderungen werden über savePlan() in
js/plan/plan-state.js nur für die aktuelle Woche direkt an den Server geschrieben.
-->
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projektplan</title>
<link rel="stylesheet" href="/styles.css?v=3">
<style>
.plan-page { min-height: 100vh; display: flex; flex-direction: column; }
.plan-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 24px; background: var(--surface); border-bottom: 1px solid var(--line-soft);
position: sticky; top: 0; z-index: 20;
}
.plan-header h1 { font-size: 17px; margin: 0; font-weight: 600; }
.plan-header .subtitle { color: var(--text-muted); font-size: 13px; }
.plan-body { flex: 1; display: flex; gap: 0; overflow: hidden; }
.plan-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--bg); min-width: 0; }
.plan-main-header {
padding: 16px 20px 10px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
}
.plan-main-content { flex: 1; overflow: auto; padding: 0 20px 20px; min-width: 0; }
.todo-item {
background: var(--surface-alt); border: 1px solid var(--line-soft); border-radius: var(--r-md);
padding: 10px 12px; margin-bottom: 10px;
}
.todo-item.done .todo-title { text-decoration: line-through; opacity: .6; }
.todo-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.todo-row:last-child { margin-bottom: 0; }
.todo-title { flex: 1; font-weight: 500; font-size: 13px; }
.todo-dates { display: flex; gap: 8px; flex-wrap: wrap; font-size: 12px; color: var(--text-muted); }
.todo-dates input[type=date] { padding: 4px 7px; font-size: 12px; }
.todo-assignee-list { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.todo-assignee {
font-size: 11px; padding: 2px 8px; border-radius: var(--r-pill);
background: var(--surface-sunk); border: 1px solid var(--line-soft); color: var(--text-muted);
}
.todo-assignee.main { background: var(--green); color: #fff; border-color: transparent; }
.todo-assignee.optional { background: #d18b00; color: #fff; border-color: transparent; }
.empty-todos { color: var(--text-subtle); font-size: 13px; text-align: center; padding: 24px 0; }
.plan-people { margin-top: 14px; }
.plan-people-title { font-size: 12px; font-weight: 600; color: var(--text-muted); margin-bottom: 6px; }
.plan-people-list { display: flex; flex-wrap: wrap; gap: 6px; }
.plan-people-chip {
font-size: 12px; padding: 4px 10px; border-radius: var(--r-pill);
background: var(--accent-soft); color: var(--text); border: 1px solid var(--line-soft);
}
.milestone-list { display: flex; flex-direction: column; gap: 6px; }
.milestone-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.milestone-row input[type=date] { padding: 4px 7px; font-size: 12px; }
.todo-list-main {
margin-top: 18px;
background: var(--surface);
border: 1px solid var(--line-soft);
border-radius: var(--r-lg);
padding: 14px 18px;
}
.todo-list-title {
font-size: 15px; font-weight: 600; margin: 0 0 10px;
}
.todo-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.todo-row-item {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
padding: 8px 10px;
background: var(--surface-alt);
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
}
.todo-row-item.done { opacity: .55; text-decoration: line-through; }
.todo-row-item input[type=text] { flex: 1 1 200px; min-width: 140px; }
.todo-row-item input[type=date] { padding: 4px 7px; font-size: 12px; }
.todo-add-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--line-soft);
}
.plan-toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px);
background: var(--text); color: #fff; padding: 10px 18px; border-radius: var(--r-pill);
font-size: 13px; opacity: 0; pointer-events: none; transition: opacity .25s, transform .25s;
z-index: 1000; box-shadow: var(--sh-3);
}
.plan-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.plan-resource-grid {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(140px, max-content)) !important;
gap: 10px !important;
justify-content: start !important;
}
.plan-resource-card {
padding: 8px 10px !important;
min-height: 0 !important;
width: 140px !important;
max-width: 140px !important;
}
.plan-resource-card h3 {
font-size: 12px !important;
margin-bottom: 2px !important;
}
.plan-resource-clickable { cursor: pointer; transition: box-shadow .15s, transform .15s; }
.plan-resource-clickable:hover { box-shadow: var(--sh-2); transform: translateY(-1px); }
.todo-depends-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow-y: auto;
border: 1px solid var(--line-soft);
border-radius: var(--r-md);
padding: 8px;
background: var(--surface-alt);
}
.todo-depends-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 6px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 12px;
transition: background .15s;
}
.todo-depends-item:hover { background: var(--surface-sunk); }
.todo-depends-item input { flex-shrink: 0; }
.todo-depends-group {
font-size: 11px;
color: var(--text-muted);
margin-top: 6px;
margin-bottom: 2px;
text-transform: uppercase;
letter-spacing: .03em;
}
.todo-depends-group:first-child { margin-top: 0; }
/* Drag-Handle für TODO- und Meilenstein-Zeilen direkt im GANTT */
.gantt-row.gantt-todo,
.gantt-row.gantt-milestone-row { cursor: default; }
.gantt-row.gantt-todo .gantt-label,
.gantt-row.gantt-milestone-row .gantt-label { display: flex; align-items: center; gap: 8px; }
.gantt-todo-drag {
color: var(--text-subtle); cursor: grab; font-size: 13px; line-height: 1;
user-select: none; flex-shrink: 0;
}
.gantt-row.gantt-todo.dragging,
.gantt-row.gantt-milestone-row.dragging { opacity: .5; }
.gantt-todo-drag:active { cursor: grabbing; }
.gantt-row.gantt-milestone-row .gantt-todo-drag { color: var(--text-subtle); }
@media (max-width: 900px) {
.plan-body { flex-direction: column; overflow: auto; }
}
</style>
</head>
<body>
<div class="plan-page">
<div class="plan-header">
<div>
<h1 id="planTitle">Projektplan</h1>
<div class="subtitle" id="planSubtitle"></div>
</div>
<div class="toolbar">
<button class="secondary" onclick="todoMail()">E-Mail erstellen</button>
<button class="secondary" onclick="window.close()">Schließen</button>
</div>
</div>
<div class="plan-body">
<div class="plan-main">
<div class="plan-main-header">
<label>Zeitraum:</label>
<input type="text" id="planGanttRange" class="range-picker-display" placeholder="Zeitraum wählen…" readonly
onclick="openRangePicker({ anchor: this, startInputId: 'planGanttStart', endInputId: 'planGanttEnd', displayId: 'planGanttRange', onApply: renderPlanGantt })">
<input type="hidden" id="planGanttStart">
<input type="hidden" id="planGanttEnd">
<button class="secondary" onclick="fitPlanGanttRange()">Zeitraum anpassen</button>
<label><input type="checkbox" id="planShowVacation" checked onchange="renderPlanGantt()"> Urlaub anzeigen</label>
<button class="secondary" onclick="zoomPlanGantt(-1)" title="Verkleinern">−</button>
<button class="secondary" onclick="zoomPlanGantt(1)" title="Vergrößern">+</button>
<button onclick="addPlanMilestone()" id="addMilestoneBtn">+ Meilenstein</button>
<button onclick="addTodo()" id="addTodoBtn">+ TODO</button>
<button onclick="autoScheduleTodos()" id="autoScheduleBtn" title="Aufgaben nach Abhängigkeiten automatisch anordnen">Auto-Schedule</button>
<button onclick="toggleCriticalPath()" id="criticalPathBtn" title="Kritischen Pfad ein-/ausblenden">Kritischer Pfad</button>
</div>
<div class="plan-main-content plan-main-content-full">
<div id="planGanttContainer" class="gantt-container"></div>
<div id="resourceLoadContainer" style="margin-top:16px"></div>
</div>
</div>
</div>
</div>
<!-- Meilenstein-Modal -->
<div class="modal-bg" id="milestoneModal" style="display:none">
<div class="modal">
<h2>Meilenstein</h2>
<div class="form-row">
<label>Name</label>
<input type="text" id="milestoneEditName" placeholder="Name">
</div>
<div class="form-row">
<label>Datum</label>
<input type="text" id="milestoneEditDate" class="range-picker-display" placeholder="Datum wählen…" readonly onclick="openDatePicker({ anchor: this, inputId: 'milestoneEditDate' })">
</div>
<div class="form-row">
<label>Position</label>
<select id="milestoneEditPosition">
<option value="">Oben in der Projektzeile</option>
</select>
</div>
<div class="form-row">
<label>Vorgänger (mehrfach möglich)</label>
<div id="milestoneEditDependsOn" class="todo-depends-list"></div>
</div>
<div class="toolbar">
<button onclick="saveMilestoneEdit()">Speichern</button>
<button class="danger" onclick="deleteMilestoneEdit()">Löschen</button>
<button class="secondary" onclick="closeMilestoneModal()">Abbrechen</button>
</div>
</div>
</div>
<!-- TODO-Modal -->
<div class="modal-bg" id="todoModal" style="display:none">
<div class="modal">
<h2>TODO</h2>
<div class="form-row">
<label>Titel</label>
<input type="text" id="todoEditTitle" placeholder="Titel">
</div>
<div class="form-row">
<label>Zeitraum</label>
<input type="text" id="todoEditRange" class="range-picker-display" placeholder="Start – Ende wählen…" readonly
onclick="openRangePicker({ anchor: this, startInputId: 'todoEditStart', endInputId: 'todoEditEnd', displayId: 'todoEditRange' })">
<input type="hidden" id="todoEditStart">
<input type="hidden" id="todoEditEnd">
</div>
<div class="form-row">
<label>Vorgänger (mehrfach möglich)</label>
<div id="todoEditDependsOn" class="todo-depends-list">
<label class="todo-depends-item">
<input type="checkbox" value="" checked> (Keine Abhängigkeit)
</label>
</div>
</div>
<div class="form-row">
<label>Zuständige</label>
<div id="todoEditAssignees" class="role-checkboxes"></div>
</div>
<div class="form-row">
<label>Fortschritt (%)</label>
<input type="range" id="todoEditProgress" min="0" max="100" value="0" oninput="document.getElementById('todoEditProgressVal').textContent=this.value+'%'"><span id="todoEditProgressVal">0%</span>
</div>
<div class="form-row">
<label><input type="checkbox" id="todoEditDone"> Erledigt</label>
</div>
<div class="toolbar">
<button onclick="saveTodoEdit()">Speichern</button>
<button class="danger" onclick="deleteTodoEdit()">Löschen</button>
<button class="secondary" onclick="closeTodoModal()">Abbrechen</button>
</div>
</div>
</div>
<!-- Abwesenheits-Modal (Urlaube der Projektbeteiligten) -->
<div class="modal-bg" id="absenceModal" style="display:none">
<div class="modal">
<h2 id="absenceModalTitle">Abwesenheiten</h2>
<div class="form-row">
<div id="absenceModalList"></div>
</div>
<div class="absence-form" id="absenceModalForm">
<input type="text" id="absenceEditRange" class="range-picker-display" placeholder="Zeitraum wählen…" readonly
onclick="openRangePicker({ anchor: this, startInputId: 'absenceEditStart', endInputId: 'absenceEditEnd', displayId: 'absenceEditRange' })">
<input type="hidden" id="absenceEditStart">
<input type="hidden" id="absenceEditEnd">
<select id="absenceEditType">
<option>Urlaub</option>
<option>Krank</option>
<option>Fortbildung</option>
<option>Sonstiges</option>
</select>
<input type="text" id="absenceEditNote" placeholder="Notiz (optional)" style="width:140px">
<button class="secondary small" onclick="addPlanAbsence()">+ Abwesenheit</button>
</div>
<div class="toolbar" style="margin-top:12px">
<button class="secondary" onclick="closeAbsenceModal()">Schließen</button>
</div>
</div>
</div>
<script src="/js/globals.js?v=5"></script>
<script src="/js/utils.js?v=5"></script>
<script src="/js/ui.js?v=5"></script>
<script src="/js/date-picker.js?v=5"></script>
<script src="/js/server.js?v=5"></script>
<script src="/js/gantt.js?v=5"></script>
<script src="/js/plan/plan-state.js?v=5"></script>
<script src="/js/plan/plan-build-gantt.js?v=5"></script>
<script src="/js/plan/plan-gantt.js?v=5"></script>
<script src="/js/plan/plan-drag.js?v=5"></script>
<script src="/js/plan/plan-modals.js?v=5"></script>
<script src="/js/plan/plan-schedule.js?v=5"></script>
</body>
</html>
+3149 -507
View File
File diff suppressed because it is too large. Load diff