Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung

Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je
Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige
Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit
sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte.
Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt.
Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Sonnet 5 committed 2026-10-02 15:14:47 +02:00
1 parent bc30f2900b
commit db63a29365
47 files changed
+16486 -3768

No files matched your search

+114 -327
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`.
// Diese Datei rendert den Personen-Tab (Tab "Projektbeteiligte" in `App.html`)
// 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 `App.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();
}