- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues Notizfeld je Aufgabe (plan_todos.notiz) - Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile, "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen - Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang - Glas-Darstellung, Logo als Favicon - Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und Prüfskript inkl. WLAN-Wächter Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
296 lines
14 KiB
JavaScript
296 lines
14 KiB
JavaScript
// -----------------------------
|
||
// 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){
|
||
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(!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();
|
||
} |