367 lines
16 KiB
JavaScript
367 lines
16 KiB
JavaScript
// -----------------------------
|
||
// Projektübersicht — Personen-Verwaltung (global pro Abteilung)
|
||
// -----------------------------
|
||
// 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`.
|
||
|
||
// 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(); 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(); 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(); 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(); 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(); 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.
|
||
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 isOpen = personAbsenceOpen[p.id];
|
||
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>
|
||
</div>`;
|
||
|
||
// Aufklappbare Abwesenheiten
|
||
html += `<div class="person-absences" style="display:${isOpen ? 'block' : 'none'}">`;
|
||
const 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>`;
|
||
}
|
||
html += `</div></div>`;
|
||
});
|
||
html += `</div>`;
|
||
if(people.length===0) html = '<div class="hint">Keine Personen vorhanden.</div>';
|
||
document.getElementById('peopleList').innerHTML=html;
|
||
}
|
||
|
||
// personAbsenceOpen: UI-Zustand, welche Personen-Karte ihre Abwesenheiten
|
||
// aufgeklappt darstellt (Key = Personen-ID, Wert = boolean).
|
||
let personAbsenceOpen = {};
|
||
|
||
// togglePersonAbsences: klappt die Abwesenheits-Liste einer Person auf bzw. zu
|
||
// (Umschalten des Eintrags in `personAbsenceOpen`) und rendert neu.
|
||
function togglePersonAbsences(personId){
|
||
personAbsenceOpen[personId] = !personAbsenceOpen[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(){
|
||
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();
|
||
}
|
||
}
|
||
|
||
// ---------- 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){
|
||
if(guardWeekLocked()) return;
|
||
const people = db.globalPeople[currentDepartment] || [];
|
||
const person = 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();
|
||
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();
|
||
renderPeople();
|
||
if(currentTab === 'gantt') renderGantt();
|
||
} |