Files
projektuebersicht/public/js/projects/projects.js
T
erikandClaude Opus 5.5 a2266662ad Dashboard-Reihenfolge, Drei-Punkte-Menü, Mehrrollen-Kürzel, Login-Rechte
- Meine Aufgaben: Projekte in der Reihenfolge der Projektliste, je Konto
  per Ziehen umsortierbar (users.dashboard_reihenfolge)
- Projektliste: Öffnen/Löschen hinter einem "⋯"-Menü
- Avatare je Person statt je Rolle; mehrere Rollen als senkrechte
  Farbstreifen; Titelleiste zieht Rollenänderungen sofort nach
- Nach dem Anmelden vollständiges Konto über /whoami laden (Seitenleiste
  zeigte sonst erst nach Neuladen alle Einträge)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:21:29 +01:00

296 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// Projektübersicht — Projektliste (Ablage-Logik)
// -----------------------------
// 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
// Sitzung abgebrochen. Nur für Projektleiter und Administratoren
// (darfProjektlisteAendern(), js/auth.js; serverseitig in /save-week
// durchgesetzt). Der Server legt das Projekt beim Speichern in den
// Papierkorb, aus dem es ein Administrator 30 Tage lang zurückholen kann
// (Admin → Papierkorb, js/admin/admin.js).
function delProjectFromList(id, ohneRueckfrage){
if(guardWeekLocked()) return;
if(!darfProjektlisteAendern()){
alert('Projekte löschen dürfen nur Projektleiter und Administratoren.');
return;
}
const p = currentWeek().projects.find(x => x.id === id);
const bez = p ? ((p.nr ? p.nr + ' ' : '') + (p.name || '')).trim() : '';
if(!ohneRueckfrage && !confirm('Projekt ' + (bez ? '„' + bez + '" ' : '') + 'löschen?\n\nEin Administrator kann es 30 Tage lang wiederherstellen.')) return;
const w = currentWeek();
w.projects = w.projects.filter(p=>p.id!==id);
save(); renderProjects(); renderBoard();
}
// editProjectField: ändert ein Feld eines Projekts (z.B. Name oder Gruppe).
// Parameter: `id` (Projekt-ID), `field` (Feldname), `val` (neuer Wert).
// 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 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
// Projekts auf (`p.aktuelleLP`). Parameter: `id` (Projekt-ID), `value`
// (LPH-Name oder leer). Aktualisiert Projekte, Board und Auslastung.
function editProjectActiveLP(id, value){
if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===id);
if(!p) return;
if(value) p.aktuelleLP = [value];
else delete p.aktuelleLP;
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));
});
sortGroups(Object.keys(groups)).forEach(g=>{
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=>{
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" ${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>
<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="danger small" onclick="delProjectFromList('${p.id}')" ${locked || !darfProjektlisteAendern() ? '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>`;
});
listEl.innerHTML = html;
}
// Drag-and-Drop Handling für Projekte
// draggedProject: die während eines Drag-and-Drop-Vorgangs gezogene
// Projekt-Zeile (DOM-Element) bzw. null nach dem Loslassen.
let draggedProject = null;
// handleDragStart: startet das Ziehen einer Projekt-Zeile, merkt sich das
// Element in `draggedProject` und verringert seine Deckkraft.
function handleDragStart(event){
draggedProject = event.currentTarget;
event.dataTransfer.effectAllowed = 'move';
event.currentTarget.style.opacity = '0.5';
}
// handleDragOver: erlaubt das Ablegen während des Überfahrens eines Ziels
// (verhindert Standardverhalten, setzt `dropEffect='move'`).
function handleDragOver(event){
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
// 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) return;
const sortable = event.currentTarget;
const sourceSortable = draggedProject.parentElement;
const isSameGroup = sourceSortable &&
sourceSortable.dataset && sortable.dataset &&
sourceSortable.dataset.group === sortable.dataset.group;
if(isSameGroup){
// 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 {
sortable.insertBefore(draggedProject, afterElement);
}
} else {
// Gruppenwechsel: ans Ende der Zielgruppe anhängen
sortable.appendChild(draggedProject);
}
reorderProjects();
}
// handleDragEnd: stellt die Deckkraft der gezogenen Zeile wieder her und
// setzt `draggedProject` zurück (Ende des Drag-Vorgangs).
function handleDragEnd(event){
// 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;
}
// 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). 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;
if(offset < 0 && offset > closest.offset){
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// reorderProjects: übernimmt die im DOM vorgefundene Reihenfolge der
// Projekt-Zeilen in die Daten (je Gruppe `order`-Werte) und aktualisiert
// Gruppe/Zuordnung pro Projekt. Keine Parameter, keine Rückgabe; bei
// gesperrter Sitzung abgebrochen.
function reorderProjects(){
if(guardWeekLocked()) return;
const w = currentWeek();
const sortables = document.querySelectorAll('.projects-sortable');
sortables.forEach(sortable => {
const group = sortable.dataset.group;
const items = sortable.querySelectorAll('[data-project-id]');
items.forEach((item, index) => {
const projectId = item.dataset.projectId;
const project = w.projects.find(p => p.id === projectId);
if(project){
project.order = index;
project.group = group;
}
});
});
save();
// renderProjects() nur, solange der eigene Projekte-Bereich existiert; die
// Projektliste im Board rendert renderBoard().
if(typeof renderProjects === 'function' && document.getElementById('projectsList')) renderProjects();
renderBoard();
}