diff --git a/AGENTS.md b/AGENTS.md index 7683395..093811d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ user-facing text. │ ├── Test.html # main app (served at /) │ ├── js/ # shared modules (classic scripts, global fns) │ │ ├── globals.js # Konstanten (LPHS, ROLES, LPH-Farben, DEPARTMENTS) + db/currentDepartment -│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, weekSortKey-Quelle +│ │ ├── utils.js # esc, uid, Datum-/Differenz-Helfer, personIdForName, peopleByRole, assignKey, weekSortKey-Quelle │ │ ├── ui.js # LocalStorage-UI-State, Header-Collapse, Theme │ │ ├── date-picker.js # Datums-/Bereichspicker-Overlays │ │ ├── csv.js # CSV-Parser/Serializer + weekSortKey, filenameForWeek @@ -147,6 +147,17 @@ docker compose up --build # Docker variant (compose file is at repo root) `_people.csv` — not just the changed week. Exception: `js/plan/plan-state.js` `savePlan()` bypasses `save()` and writes only the current week's CSV directly via `sendCsvToServer()`. +- `switchDepartment()` cancels a pending save debounce before switching, and + `load()` discards stale `/load-all` responses after a department switch + (frozen `requestedDepartment`), so late responses can never render or persist + data under the wrong department. +- Person drag-and-drop in the people tab: drag handle (`.drag-handle`) binds + `ondragstart`/`ondragend`, the `.people-sortable` container binds + `ondragover`/`ondrop`; insertion is computed by `getDragAfterElement(container, + y, selector)` (`js/projects/projects.js`, used with `.person-card` here and + `.person-list-item` in the projects tab). `reorderPeople()` updates both + `db.globalPeople` and the current week's person copy, then `save()`. No drag + handles are rendered on locked weeks. - Weeks are sorted by calendar week extracted from the label (`KW XX / YYYY`) via `weekSortKey()`, not alphabetically/by filename. - `applyAutoLock()` locks every week except the chronologically last one after diff --git a/public/js/board/board.js b/public/js/board/board.js index ad12263..3b18be7 100644 --- a/public/js/board/board.js +++ b/public/js/board/board.js @@ -52,33 +52,8 @@ function formatStatus(command, value){ document.execCommand(command, false, value || null); } -// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt. -// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle -// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung. -function peopleByRole(target){ - const m = {}; ROLES.forEach(r=>m[r]=[]); - const people = []; - if(Array.isArray(target)){ - target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); }); - } else if(target && Array.isArray(target.people)){ - people.push(...target.people); - } else if(Array.isArray(currentWeek()?.people)){ - people.push(...currentWeek().people); - } - const seen = new Set(); - people.forEach(p=>{ - const personRoles = Array.isArray(p.role) ? p.role : [p.role]; - personRoles.forEach(role=>{ - if(!m[role]) m[role]=[]; - const key = `${p.id}|${role}`; - if(!seen.has(key)){ - seen.add(key); - m[role].push({person:p, role}); - } - }); - }); - return m; -} +// peopleByRole liegt jetzt im gemeinsamen Modul js/utils.js (benötigt auch +// csv.js für die Projektplan-Seite, die board.js nicht lädt). // roleClass: erzeugt CSS-Klassen für eine Rolle bzw. ein Rollen-Array // (Rollenname kleingeschrieben, Leerzeichen → Bindestrich). Parameter: `role` diff --git a/public/js/csv.js b/public/js/csv.js index 013d029..d6adf65 100644 --- a/public/js/csv.js +++ b/public/js/csv.js @@ -112,10 +112,10 @@ function parseCsvText(text){ return text.replace(/^[\ufeff\u00EF\u00BB\u00BF]+/,'').split(/\r?\n/).filter(line => line.trim() !== '').map(parseCsvLine); } -// Toleranter CSV-Zeilenparser. Versteht sowohl sauberes CSV als auch -// das ältere Format, bei dem Felder nur ein öffnendes Anführungszeichen -// hatten und das Semikolon innerhalb eines quoted Feldes gleichzeitig -// als Trennzeichen diente. +// Toleranter CSV-Zeilenparser. Versteht sauber gequotetes CSV (wie es +// csvEscape/weekToCsv erzeugen). Semikolons innerhalb eines quoted Feldes sind +// Feldinhalt und kein Trennzeichen – so bleibt weekToCsv()/parseSessionCsv() +// auch bei Status-Texten, die mit ";" enden, invers. // parseCsvLine: zerlegt eine einzelne CSV-Zeile in ein Feld-Array // Parameter: line (String). Rückgabe: Array der Felder. function parseCsvLine(line){ @@ -130,12 +130,6 @@ function parseCsvLine(line){ if(inQuotes){ if(c === '"' && n === '"'){ current += '"'; i += 2; - } else if(c === ';' && n === '"'){ - // Altes Trennzeichen \";\\" innerhalb eines quoted Feldes - result.push(current); - current = ''; - inQuotes = false; - i += 1; } else if(c === '"' && (n === ';' || n === undefined)){ result.push(current); current = ''; diff --git a/public/js/load/load.js b/public/js/load/load.js index cc56181..1ca70b3 100644 --- a/public/js/load/load.js +++ b/public/js/load/load.js @@ -6,7 +6,7 @@ // people → projects → load → gantt → …). Es stellt renderLoad(), das LP- // Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES // aus js/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked() -// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/board/board.js/js/utils.js. +// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/utils.js. // Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat, // pdfLoadAmpelColor, LOAD_BAR_MAX) wird zusätzlich von js/pdf.js für den // PDF-Export wiederverwendet. diff --git a/public/js/pdf.js b/public/js/pdf.js index e32f511..0915bc5 100644 --- a/public/js/pdf.js +++ b/public/js/pdf.js @@ -8,7 +8,7 @@ // vor js/init.js); exportPDF() hängt am Export-Button von Test.html und nutzt // die per CDN eingebundene Bibliothek pdfmake (offline nicht verfügbar). Es // liest die aktuelle Woche über currentWeek() aus js/store.js, Personen über -// peopleByRole() aus js/board/board.js, Helfer wie assignKey()/sortGroups()/ +// peopleByRole() aus js/utils.js, Helfer wie assignKey()/sortGroups()/ // formatWeekDate() aus js/utils.js sowie die Auslastungs-Berechnung // (berechneAuslastung, lpActiveList, faktorFormat, pdfLoadAmpelColor, // LOAD_BAR_MAX) aus js/load/load.js. Das Modul stellt ausschließlich exportPDF() @@ -272,6 +272,12 @@ function pdfProjectRow(p, allPeople, alt){ ]; allPeople.forEach(entry => { + // Platzhalter-Einträge für leere Rollen (person === null) → leere Zelle, + // damit Spaltenzahl mit der Kopfzeile übereinstimmt. + if(!entry.person){ + row.push({ text: '', style: 'tdCenter', fillColor: base || '#FFFFFF' }); + return; + } const s = (p.assign || {})[assignKey(entry.person.id, entry.role)] || 0; const txt = s === 1 ? 'X' : s === 2 ? '(X)' : ''; const bg = s === 1 ? PDF_ASSIGN_MAIN @@ -468,17 +474,25 @@ async function exportPDF(){ try { const grouped = peopleByRole(w); - const allPeople = []; - ROLES.forEach(r => (grouped[r] || []).forEach(e => { - // role explizit setzen, falls peopleByRole sie nicht mitliefert - allPeople.push({ person: e.person, role: e.role || r }); - })); + // displayPeople: Spalten der Tabelle inkl. Platzhalter für leere Rollen – + // pdfHeaderRows erzwingt pro Rolle mindestens eine Spalte (colSpan/“–”), + // deshalb müssen Zeilen, Breiten und colCount dieselbe Spaltenzahl nutzen, + // sonst wirft pdfmake bei einer leeren Rolle einen Layout-Fehler. + const displayPeople = []; + ROLES.forEach(r => { + const list = grouped[r] || []; + if(list.length === 0){ + displayPeople.push({ person: null, role: r }); + } else { + list.forEach(e => displayPeople.push({ person: e.person, role: e.role || r })); + } + }); - pdfCheckWidth(allPeople); + pdfCheckWidth(displayPeople); const logo = await loadLogoPng(); - const colCount = 3 + allPeople.length + LPHS.length; - const widths = pdfColumnWidths(allPeople); + const colCount = 3 + displayPeople.length + LPHS.length; + const widths = pdfColumnWidths(displayPeople); // --- Deckblatt --- const cover = []; @@ -521,7 +535,7 @@ async function exportPDF(){ const header = pdfHeaderRows(grouped); const body = [ pdfGroupTitleRow(groupName, colCount), header[0], header[1] ]; - projects.forEach((p, i) => body.push(pdfProjectRow(p, allPeople, i % 2 === 0))); + projects.forEach((p, i) => body.push(pdfProjectRow(p, displayPeople, i % 2 === 0))); content.push({ table: { headerRows: 3, widths: widths, body: body, dontBreakRows: true }, diff --git a/public/js/people/people.js b/public/js/people/people.js index 34397fd..321b74b 100644 --- a/public/js/people/people.js +++ b/public/js/people/people.js @@ -54,12 +54,9 @@ function addPerson(){ 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; - // 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 (nur wenn nicht in Zuordnungen genutzt) + // 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 + '|'))); @@ -67,6 +64,15 @@ function delPerson(id){ 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); } @@ -176,12 +182,14 @@ function togglePersonRole(id, role, checked){ function renderPeople(){ const people = db.globalPeople[currentDepartment] || []; const locked = isWeekLocked(currentWeek()); - let html = `
`; + let html = `
`; people.forEach(p=>{ const isOpen = personAbsenceOpen[p.id]; html += `
`; html += `
- ⋮⋮ + ${locked ? '' : `⋮⋮`} ${isOpen ? '▼' : '▶'} @@ -273,7 +281,7 @@ function handlePersonDrop(event){ if(!draggedPerson || draggedPerson === targetCard) return; const sortable = document.querySelector('.people-sortable'); if(!sortable) return; - const afterElement = getDragAfterElement(sortable, event.clientY); + const afterElement = getDragAfterElement(sortable, event.clientY, '.person-card:not([style*="opacity"])'); if(afterElement == null){ sortable.appendChild(draggedPerson); } else { @@ -291,8 +299,9 @@ function handlePersonDragEnd(event){ } // reorderPeople: übernimmt die im DOM vorgefundene Reihenfolge der -// Personen-Karten in die globale Personenliste der Abteilung und speichert sie. -// Keine Parameter, keine Rückgabe; abgebrochen bei gesperrter Sitzung. +// 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(){ if(guardWeekLocked()) return; const sortable = document.querySelector('.people-sortable'); @@ -306,6 +315,15 @@ function reorderPeople(){ }); 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(); } } diff --git a/public/js/plan/plan-build-gantt.js b/public/js/plan/plan-build-gantt.js index 1961a9f..753d2a4 100644 --- a/public/js/plan/plan-build-gantt.js +++ b/public/js/plan/plan-build-gantt.js @@ -141,6 +141,7 @@ const row = buildMilestoneRow(m, mIdx); insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; + insertedMilestoneIds.add(m.id); }); // Aufgabenzeile diff --git a/public/js/plan/plan-drag.js b/public/js/plan/plan-drag.js index 4b635c5..ed2c916 100644 --- a/public/js/plan/plan-drag.js +++ b/public/js/plan/plan-drag.js @@ -85,19 +85,21 @@ } // getGanttRowDragAfterElement: ermittelt unter allen nicht gezogenen - // Zeilen jenes Element, das der übergebenen y-Position am nächsten liegt. - // Rückgabe: Element oder undefined (dann wäre die Zeile am Chart-Ende). + // Zeilen jenes Element, das der übergebenen y-Position am nächsten liegt + // und dessen Mitte unterhalb des Cursors beginnt. Rückgabe: Element oder + // null, wenn keine Zeile mehr unter dem Cursor liegt (Curser unterhalb der + // letzten Zeile → die gezogene Zeile gehört ans Chart-Ende). function getGanttRowDragAfterElement(chart, y){ const draggableElements = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR + ':not(.dragging)')]; - return draggableElements.reduce((closest, child) => { + const closest = draggableElements.reduce((acc, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; - if(offset < 0 && offset > closest.offset){ + if(offset < 0 && offset > acc.offset){ return { offset: offset, element: child }; - } else { - return closest; } - }, { offset: Number.NEGATIVE_INFINITY }).element; + return acc; + }, { offset: Number.NEGATIVE_INFINITY }); + return closest.element || null; } // reorderPlanRows: übernimmt die im DOM gefundene Zeilen-Reihenfolge in den @@ -151,9 +153,11 @@ position = 'after:' + prevRow.dataset.todoId; } else if(nextRow && nextRow.classList.contains('gantt-todo')){ position = 'before:' + nextRow.dataset.todoId; - } else if(prevRow && prevRow.classList.contains('gantt-milestone-row')){ - // Zwei Meilensteine hintereinander: beziehe dich auf die nächste TODO danach oder letzte davor - // Suche nächste TODO nach diesem Block + } else if((!prevRow || !prevRow.classList.contains('gantt-todo')) && (!nextRow || !nextRow.classList.contains('gantt-todo'))){ + // Weder vor noch hinter diesem Meilenstein steht direkt eine TODO – + // das ist der Fall für einen führenden Meilenstein-Block (vor der + // ersten TODO) oder mehrere Meilensteine hintereinander. Beziehe dich + // auf die nächste TODO danach oder die letzte TODO davor. let nextTodoId = null; for(let i = idx + 1; i < rows.length; i++){ if(rows[i].classList.contains('gantt-todo')){ nextTodoId = rows[i].dataset.todoId; break; } @@ -161,7 +165,6 @@ if(nextTodoId){ position = 'before:' + nextTodoId; } else { - // Keine TODO danach -> letzte TODO davor suchen let prevTodoId = null; for(let i = idx - 1; i >= 0; i--){ if(rows[i].classList.contains('gantt-todo')){ prevTodoId = rows[i].dataset.todoId; break; } diff --git a/public/js/plan/plan-state.js b/public/js/plan/plan-state.js index fed88d1..5074d5a 100644 --- a/public/js/plan/plan-state.js +++ b/public/js/plan/plan-state.js @@ -41,7 +41,12 @@ window.PlanApp = { app.department = routeMatch ? routeMatch[1] : (app.params.get('department') || 'ELT'); app.weekLabel = app.params.get('weekLabel'); app.projNr = app.params.get('projNr') || ''; - app.projName = routeMatch && routeMatch[2] ? decodeURIComponent(routeMatch[2]) : (app.params.get('projName') || ''); + 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 diff --git a/public/js/projects/projects.js b/public/js/projects/projects.js index 6efdb97..e155aed 100644 --- a/public/js/projects/projects.js +++ b/public/js/projects/projects.js @@ -24,11 +24,23 @@ function delProjectFromList(id){ // editProjectField: ändert ein Feld eines Projekts (z.B. Name oder Gruppe). // Parameter: `id` (Projekt-ID), `field` (Feldname), `val` (neuer Wert). -// Keine Rückgabe. +// Beim Wechsel der Gruppe wird das Projekt ans Ende der Zielgruppe angehängt +// (neue `order` = höchste Order der Zielgruppe + 1), damit es nicht anhand +// seiner alten `order` in der Liste landet. Keine Rückgabe. function editProjectField(id,field,val){ if(guardWeekLocked()) return; - const p = currentWeek().projects.find(x=>x.id===id); - if(p){ p[field]=val; save(); renderProjects(); renderBoard(); } + const w = currentWeek(); + const p = w.projects.find(x=>x.id===id); + if(!p) return; + if(field === 'group' && val && val !== p.group){ + p.group = val; + const targetProjects = w.projects.filter(x => x.id !== id && x.group === val); + const maxOrder = targetProjects.reduce((m,x)=>Math.max(m, x.order || 0), -1); + p.order = maxOrder + 1; + } else { + p[field] = val; + } + save(); renderProjects(); renderBoard(); } // editProjectActiveLP: setzt bzw. hebt die amtierende Leistungsphase eines @@ -59,9 +71,9 @@ function renderProjects(){ sortGroups(Object.keys(groups)).forEach(g=>{ html += `
${esc(g)}
`; - html += `
`; + html += `
`; groups[g].forEach(p=>{ - html += `
+ html += `
⋮⋮ @@ -106,18 +118,31 @@ function handleDragOver(event){ event.dataTransfer.dropEffect = 'move'; } -// handleDrop: legt die gezogene Projekt-Zeile an der Position ab, die -// `getDragAfterElement` relativ zur Mausposition ermittelt, und speichert -// die neue Reihenfolge über `reorderProjects()`. +// handleDrop: legt die gezogene Projekt-Zeile ab. Der Handler hängt am +// Gruppen-Container (`.projects-sortable`), sodass das Ablegen über der +// gesamten Gruppe inklusive leerer Flächen möglich ist. Beim Wechsel zwischen +// Gruppen (z. B. "Kleine Projekte" → "Aktive Projekte") wird das Projekt ans +// Ende der Zielgruppe angehängt; innerhalb derselben Gruppe wird die Position +// per `getDragAfterElement` relativ zur Mausposition bestimmt. Danach speichert +// `reorderProjects()` die neue Reihenfolge. function handleDrop(event){ event.preventDefault(); - if(!draggedProject || draggedProject === event.currentTarget) return; - const sortable = event.currentTarget.parentElement; - const afterElement = getDragAfterElement(sortable, event.clientY); - if(afterElement == null){ - sortable.appendChild(draggedProject); + if(!draggedProject) return; + const sortable = event.currentTarget; + const sourceSortable = draggedProject.parentElement; + const isSameGroup = sourceSortable && + sourceSortable.dataset && sortable.dataset && + sourceSortable.dataset.group === sortable.dataset.group; + if(isSameGroup){ + const afterElement = getDragAfterElement(sortable, event.clientY); + if(afterElement == null){ + sortable.appendChild(draggedProject); + } else { + sortable.insertBefore(draggedProject, afterElement); + } } else { - sortable.insertBefore(draggedProject, afterElement); + // Gruppenwechsel: ans Ende der Zielgruppe anhängen + sortable.appendChild(draggedProject); } reorderProjects(); } @@ -131,11 +156,14 @@ function handleDragEnd(event){ // getDragAfterElement: ermittelt das Element in `container`, vor dem die // gezogene Zeile bei Mausposition `y` abgelegt werden soll (Suche nach dem -// Element mit der kleinsten negativen Distanz zur Mitte). Rückgabe: -// Ziel-Element oder undefined (→ ans Ende anfügen). Wird auch von -// `js/people/people.js` für die Personen-Sortierung verwendet. -function getDragAfterElement(container, y){ - const draggableElements = [...container.querySelectorAll('.person-list-item:not([style*="opacity"])')]; +// Element mit der kleinsten negativen Distanz zur Mitte). Parameter: +// `selector` = CSS-Selektor der sortierbaren Elemente (standardmäßig die +// Projekt-Zeilen, steht aber auch der Personen-Liste für `.person-card` zur +// Verfügung). Rückgabe: Ziel-Element oder undefined (→ ans Ende anfügen). +// Wird auch von `js/people/people.js` für die Personen-Sortierung verwendet. +function getDragAfterElement(container, y, selector){ + const sel = selector || '.person-list-item:not([style*="opacity"])'; + const draggableElements = [...container.querySelectorAll(sel)]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; diff --git a/public/js/showmode.js b/public/js/showmode.js index c67dcd4..bf98cc5 100644 --- a/public/js/showmode.js +++ b/public/js/showmode.js @@ -18,6 +18,9 @@ let showModeTimer = null; let showModeResumeTimer = null; // showModePaused: true, wenn der Durchlauf gerade pausiert ist let showModePaused = false; +// showModePollTimer: Timer-Handle des Poll-Intervalls in waitForLoadThenRender +// (wird in stopShowMode mit aufgeräumt, sonst rendert es nach dem Beenden weiter) +let showModePollTimer = null; // showModeOriginal: Abteilung und Tab vor dem Start, wird beim Beenden wiederhergestellt let showModeOriginal = { department: DEFAULT_DEPARTMENT, tab: DEFAULT_TAB }; const SHOW_MODE_SLIDE_DURATION = 10000; // 10 Sekunden pro Slide @@ -62,16 +65,19 @@ function stopShowMode(){ showModePaused = false; clearTimeout(showModeTimer); clearTimeout(showModeResumeTimer); + if(showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; } const overlay = document.getElementById('showModeOverlay'); if(overlay) overlay.style.display = 'none'; - // Ursprünglichen Zustand wiederherstellen + // Ursprünglichen Zustand wiederherstellen. Geschützte Tabs ('projects'/ + // 'people') werden nur ohne Passwort angezeigt, wenn bereits authentifiziert – + // sonst öffnet showTab (ohne skipAuth) das Passwort-Modal. currentTab = showModeOriginal.tab; if(currentDepartment !== showModeOriginal.department){ switchDepartment(showModeOriginal.department); } else { - showTab(currentTab, true); + showTab(currentTab, isAuthenticated); renderAll(); } } @@ -155,11 +161,14 @@ function showModeNextSlide(){ // plant die nächste Folie. Parameter: view = 'board' oder 'load'. function waitForLoadThenRender(view){ let attempts = 0; - const check = setInterval(() => { + showModePollTimer = setInterval(() => { attempts++; + // Poll nur fortsetzen, solange der Show Mode noch läuft (sonst würde nach + // stopShowMode der gerade wiederhergestellte Zustand überschrieben). + if(!showModeActive && showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; return; } // Wenn Wochen geladen sind oder nach Seed gefallen ist - if(db.weeks.length > 0 || attempts > 30){ - clearInterval(check); + if((db.weeks.length > 0 || attempts > 30) && showModeActive){ + if(showModePollTimer){ clearInterval(showModePollTimer); showModePollTimer = null; } showTab(view, true); renderAll(); // Kurze Verzögerung, damit das DOM aktualisiert ist, bevor kopiert wird diff --git a/public/js/store.js b/public/js/store.js index 39f7232..a759592 100644 --- a/public/js/store.js +++ b/public/js/store.js @@ -60,6 +60,10 @@ function saveGlobalPeopleToServer(){ // 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 + // CSV der falschen Abteilung schreibt. + if(saveSyncTimer){ clearTimeout(saveSyncTimer); saveSyncTimer = null; } currentDepartment = dept; saveUiState(); updateRouteUrl(dept, currentTab); @@ -90,11 +94,17 @@ function load(){ alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.'); seed(); 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(); console.log('Loading from', url); 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] = parseGlobalPeopleCsv(data.people); @@ -126,6 +136,7 @@ function load(){ 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' + diff --git a/public/js/tabs-auth.js b/public/js/tabs-auth.js index 9618be8..ba9b382 100644 --- a/public/js/tabs-auth.js +++ b/public/js/tabs-auth.js @@ -47,14 +47,20 @@ function confirmPassword(){ const pwdHash = CryptoJS.SHA256(pwd).toString(); if(pwdHash === PROTECTED_PASSWORD_HASH){ - const action = requestedAction; - requestedAction = null; - document.getElementById('passwordModal').style.display='none'; + // 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); }, 100); + document.getElementById('passwordModal').style.display='none'; } else { alert("Falsches Passwort!"); document.getElementById('passwordInput').value=''; diff --git a/public/js/utils.js b/public/js/utils.js index 82c65e3..74d22ef 100644 --- a/public/js/utils.js +++ b/public/js/utils.js @@ -35,6 +35,36 @@ function sortGroups(arr){ }); } +// peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt. +// Parameter `target`: Woche(n) mit `people`-Array (oder undefined → aktuelle +// Woche). Rückgabe: Objekt `{ Rolle: [{person, role}] }` mit Deduplizierung. +// Liegt im gemeinsamen Modul, weil auch csv.js (weekToCsv für savePlan auf der +// Projektplan-Seite) und pdf.js darauf zugreifen. +function peopleByRole(target){ + const m = {}; ROLES.forEach(r=>m[r]=[]); + const people = []; + if(Array.isArray(target)){ + target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); }); + } else if(target && Array.isArray(target.people)){ + people.push(...target.people); + } else if(Array.isArray(currentWeek()?.people)){ + people.push(...currentWeek().people); + } + const seen = new Set(); + people.forEach(p=>{ + const personRoles = Array.isArray(p.role) ? p.role : [p.role]; + personRoles.forEach(role=>{ + if(!m[role]) m[role]=[]; + const key = `${p.id}|${role}`; + if(!seen.has(key)){ + seen.add(key); + m[role].push({person:p, role}); + } + }); + }); + return m; +} + // Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und // Zuordnungen auch nach einem Neuladen stabil bleiben. // Parameter: name (String). Rückgabe: String wie 'p_ab'. diff --git a/sessions/ELT/KW_33___2026.csv b/sessions/ELT/KW_33___2026.csv index 63827de..ca05d29 100644 --- a/sessions/ELT/KW_33___2026.csv +++ b/sessions/ELT/KW_33___2026.csv @@ -4,28 +4,28 @@ Pr.-Nr.;Bezeichnung;Status;AP;AK;ED;SK;SE;WS;ED;SP;THK;RR;MK;TW;SG;ED;SK;SP;THK; ANWESENHEIT;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 18-4007;Janusz-Korczak-Gesamtsch. Bottrop;- KW 35 NW Raum Planung
- KW 44 Möbel
;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;90%;;;;;Aktive Projekte;0;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";"{""LPH 5"":{""Planung"":0.6},""LPH 8"":{""Planung"":0,""Projektleitung"":0.2}}"; 21-4030;NSHV Köln Immobilienprüfung in Hof;- Thorsten W. klärt und übernimmt Schriftverkehr
- LV liegt zur Sichtung bei T.W.
;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;;;;Aktive Projekte;2;;; -22-4066;DSW 21, Umbau E-Mobilität;- Elt-Montagen ab KW 33.;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;6;;; -24-4043;S-K ODL-Heim Sanierung Krefeld;- Vorraussichtlich LV-Abgabe XX.XX
- LPH5 mündlich freigegeben
- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;10;;; -21-4001;Feuerwehrgeräteh. Rodenkirchen;- KNX muß noch programmiert werden. Abnahme 27.07
- Fertigstellung Ende Juli
- Abnahmen laufen bis Ende KW31
- Blackbuilding Test 26.07
;X;;X;;;;X;X;;;;;;X;;;;;100%;100%;100%;;100%;100%;100%;;;Aktive Projekte;1;"{""start"":"""",""end"":"""",""milestones"":[{""id"":""mslrvh7hjau1e"",""name"":""Übergabe"",""date"":""2026-09-12"",""dependsOn"":[""mslv6h76cmx8k""],""position"":""after:mslv6h76cmx8k""}],""people"":[""p_ap"",""p_ak"",""p_sk"",""p_sg""],""progress"":""0"",""todos"":[{""id"":""msluufbp114g1"",""title"":""Test"",""start"":""2026-08-10"",""end"":""2026-08-14"",""done"":false,""dependsOn"":[],""assignees"":[""p_ap"",""p_ed""],""progress"":0},{""id"":""mslv6h76cmx8k"",""title"":""Test 2"",""start"":""2026-08-24"",""end"":""2026-09-04"",""done"":false,""dependsOn"":[""msluufbp114g1""],""assignees"":[""p_ap""],""progress"":41},{""id"":""mslw4l3qp1v18"",""title"":""Test 3"",""start"":""2026-09-14"",""end"":""2026-09-22"",""done"":false,""dependsOn"":[""m_mslrvh7hjau1e""],""assignees"":[],""progress"":0},{""id"":""msoltaa1itx48"",""title"":""Gehen"",""start"":""2026-09-23"",""end"":""2026-09-25"",""done"":false,""progress"":0,""dependsOn"":[""mslw4l3qp1v18""],""assignees"":[""p_ws""]}]}";"{""LPH 1"":{""Planung"":0.8},""LPH 2"":{""Planung"":0.8},""LPH 3"":{""Planung"":0.8},""LPH 4"":{""Planung"":0.8},""LPH 5"":{""Planung"":0.8}}";"[""LPH 3""]" +22-4066;DSW 21, Umbau E-Mobilität;- Elt-Montagen ab KW 33.;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;20%;;Aktive Projekte;7;;; +24-4043;S-K ODL-Heim Sanierung Krefeld;- Vorraussichtlich LV-Abgabe XX.XX
- LPH5 mündlich freigegeben
- Neuer EG Grundriss mit diversen Änderungen kommt noch von Architekten
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;;;;;Aktive Projekte;11;;; +21-4001;Feuerwehrgeräteh. Rodenkirchen;- KNX muß noch programmiert werden. Abnahme 27.07
- Fertigstellung Ende Juli
- Abnahmen laufen bis Ende KW31
- Blackbuilding Test 26.07
;X;;X;;;;X;X;;;;;;X;;;;;100%;100%;100%;;100%;100%;100%;;;Aktive Projekte;1;"{""start"":"""",""end"":"""",""milestones"":[{""id"":""mslrvh7hjau1e"",""name"":""Übergabe"",""date"":""2026-09-12"",""dependsOn"":[""mslv6h76cmx8k""],""position"":""after:mslv6h76cmx8k""}],""people"":[""p_ap"",""p_ak"",""p_sk"",""p_sg""],""progress"":""0"",""todos"":[{""id"":""msluufbp114g1"",""title"":""Test"",""start"":""2026-08-10"",""end"":""2026-08-14"",""done"":false,""dependsOn"":[],""assignees"":[""p_ap"",""p_ed""],""progress"":0},{""id"":""mslv6h76cmx8k"",""title"":""Test 2"",""start"":""2026-08-24"",""end"":""2026-09-04"",""done"":false,""dependsOn"":[""msluufbp114g1""],""assignees"":[""p_ap""],""progress"":41},{""id"":""mslw4l3qp1v18"",""title"":""Test 3"",""start"":""2026-09-14"",""end"":""2026-09-22"",""done"":false,""dependsOn"":[""m_mslrvh7hjau1e""],""assignees"":[],""progress"":0},{""id"":""msoltaa1itx48"",""title"":""Gehen"",""start"":""2026-09-23"",""end"":""2026-09-25"",""done"":false,""progress"":0,""dependsOn"":[""mslw4l3qp1v18""],""assignees"":[""p_ws""]}]}";"{""LPH 1"":{""Planung"":0.8},""LPH 2"":{""Planung"":0.8},""LPH 3"":{""Planung"":0.8},""LPH 4"":{""Planung"":0.8},""LPH 5"":{""Planung"":0.8}}";"[""LPH 5""]" 22-4027;Sporthalle Soest;- SV und VOB Abnahme erfolgt.
- Nachprüfung VOB 24.07
- Keine Infos über Nachprüfung
;X;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;3;;;"[""LPH 5""]" 22-4056;Gymnasium Hemer;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;4;;; 22-4056-1;Gymnasium Hemer (Deckensanierung);- Beginn der Arbeiten vorraussichtlich KW 30 (KW 32 Kickoff);;;;;;;;;;;;;;;;;;;;;100%;;100%;100%;;;;Aktive Projekte;5;;; -23-4042;GWK Neufelder Straße;- Nachweis für E-30 Leitungen notwendig. Rechnungen wurden nicht anerkannt.
- 22.07 Aufschaltung BMA und Amok
- KW 31 VOB Abnahme
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;7;;; -23-4070;Umbau Bürogebäude NEW, MV10;"- neue NSHV anbieten(Warten auf Infos),
- 28.07 Sachverständiger Abnahme ELT&SiBe
- 30.07 Sachverständigen Abnahme BMA
";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;8;;; -23-4072;S-K OGS-Kaldenkirchen Schulanbau;- Feininstallation beginnt, Fertigstellung voraussichtlich 01.08;;;;;;;;;;;;;;;;;;;;;;;;;;70%;;Aktive Projekte;9;;; -24-4049;BMA Remscheid;"- Feuerwehrabnahme organisiert die Errichterfirma.
- SV-Abnahmen 15 & 16.07: nicht abgenommen
";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;11;;; +23-4042;GWK Neufelder Straße;- Nachweis für E-30 Leitungen notwendig. Rechnungen wurden nicht anerkannt.
- 22.07 Aufschaltung BMA und Amok
- KW 31 VOB Abnahme
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;95%;;Aktive Projekte;8;;; +23-4070;Umbau Bürogebäude NEW, MV10;"- neue NSHV anbieten(Warten auf Infos),
- 28.07 Sachverständiger Abnahme ELT&SiBe
- 30.07 Sachverständigen Abnahme BMA
";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;9;;; +23-4072;S-K OGS-Kaldenkirchen Schulanbau;- Feininstallation beginnt, Fertigstellung voraussichtlich 01.08;;;;;;;;;;;;;;;;;;;;;;;;;;70%;;Aktive Projekte;10;;; +24-4049;BMA Remscheid;"- Feuerwehrabnahme organisiert die Errichterfirma.
- SV-Abnahmen 15 & 16.07: nicht abgenommen
";;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;90%;;Aktive Projekte;12;;; 24-4069;S-K Erw. GS Blankenheim Neubau;- nur backup;;;;;;;;;;;;;;;;;;;;;;;100%;100%;;;;Kleine Projekte;0;;; -24-4096;Umbau Wohngebäude Kirchhoff;- Position HA wird von uns bestimmt.
- Notwendiges Leerrohr für Leuchten im Vordach ist zu klären.
- Abgabe Kostenberechnung bis Ende KW29
- LV Fertig bis Ende KW 32
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;85%;0%;;;Aktive Projekte;12;;; -25-4003;Märkische Bank Boele;- Feininstallation
- Ab KW30 Einbringung Tresore
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;80%;;Aktive Projekte;13;;; -25-4028;TenBrinke, Amprion, Wietmarschen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;14;;; -25-4028-1;TenBrinke, Amprion, Meerbusch;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;15;;; -25-4028-2;TenBrinke, Amprion, Gelsenkirchen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;16;;; -26-4018;TUI Immobilien GmbH Salzgitter;;;;;;;;;;;;;;;;;;;;50%;;;;;;;;;Aktive Projekte;17;;; +24-4096;Umbau Wohngebäude Kirchhoff;- Position HA wird von uns bestimmt.
- Notwendiges Leerrohr für Leuchten im Vordach ist zu klären.
- Abgabe Kostenberechnung bis Ende KW29
- LV Fertig bis Ende KW 32
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;85%;0%;;;Aktive Projekte;13;;; +25-4003;Märkische Bank Boele;- Feininstallation
- Ab KW30 Einbringung Tresore
;;;;;;;;;;;;;;;;;;;100%;100%;100%;;100%;100%;100%;80%;;Aktive Projekte;14;;; +25-4028;TenBrinke, Amprion, Wietmarschen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;15;;; +25-4028-1;TenBrinke, Amprion, Meerbusch;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;16;;; +25-4028-2;TenBrinke, Amprion, Gelsenkirchen;;;;;;;;;;;;;;;;;;;;100%;100%;100%;;95%;;;;;Aktive Projekte;17;;; +26-4018;TUI Immobilien GmbH Salzgitter;;;;;;;;;;;;;;;;;;;;50%;;;;;;;;;Aktive Projekte;18;;; 26-4023;HA Rettungsw. Nahmer Gasst. - Los 1;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;0;;; 26-4024;HA Rettungsw. Dahl Am Obergr. - Los 2;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;1;;; -26-4025;HA, SeePark Hengstey;- LPH2 erfolgt, weitere Arbeiten folgen.;;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;18;;; -26-4026;Stadt Hemer, Anbau OGS Oesetalschule;"- Bearbeitung LPH3 in Absprache mit Achitektur S+D";;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;19;;; -26-4046;AXA Außenbeleuchtung;- Warten auf Unterlagen (Grundlagen & Vorplanung);;;;;;;;;;;;;;;;;;;10%;;;;;;;;;Aktive Projekte;20;;; +26-4025;HA, SeePark Hengstey;- LPH2 erfolgt, weitere Arbeiten folgen.;;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;19;;; +26-4026;Stadt Hemer, Anbau OGS Oesetalschule;"- Bearbeitung LPH3 in Absprache mit Achitektur S+D";;;;;;;;;;;;;;;;;;;100%;100%;;;;;;;;Aktive Projekte;20;;; +26-4046;AXA Außenbeleuchtung;- Warten auf Unterlagen (Grundlagen & Vorplanung);;;;;;;;;;;;;;;;;;;10%;;;;;;;;;Aktive Projekte;21;;; ;KFW Köln/Bonn Sicherheitskonzept;- Auftrag erhalten;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;2;;; ;Swisstal;;;;;;;;;;;;;;;;;;;;;;;;;;;;;In Anbahnung;3;"{""start"":"""",""end"":"""",""milestones"":[],""people"":[],""progress"":0}";; ;Plancodierung/Benennung;- Von Rieke erstellt. Info folgt ? KW31?;;;;;;;;;;;;;;;;;;;;;;;;;;;;Allgemein;0;;;