Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb
- 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>
This commit is contained in:
1 parent
f4fb3c550b
commit
b7e0bd0d92
56 files changed
+12265
-2974
No files matched your search
@@ -86,7 +86,7 @@
|
||||
// aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die
|
||||
// TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als
|
||||
// HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO-
|
||||
// Datum), options (Optionen inkl. editable und criticalPath). Rückgabe:
|
||||
// Datum), options (Optionen inkl. editable). Rückgabe:
|
||||
// String mit dem kompletten GANTT-HTML.
|
||||
window.buildGanttHtml = function(projects, start, end, options = {}){
|
||||
if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options);
|
||||
@@ -169,12 +169,11 @@
|
||||
const span = daysBetween(barStart, barEnd) + 1;
|
||||
const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : '';
|
||||
const handles = editable ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : '';
|
||||
const isCritical = options.criticalPath && options.criticalPath.includes(todo.id);
|
||||
const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0));
|
||||
const conflictTitle = conflicts.length > 0
|
||||
? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ')
|
||||
: '';
|
||||
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}${conflicts.length > 0 ? ' conflict-absence' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}${esc(conflictTitle)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
|
||||
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${conflicts.length > 0 ? ' conflict-absence' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}${esc(conflictTitle)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
|
||||
}
|
||||
}
|
||||
// Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt.
|
||||
@@ -204,10 +203,22 @@
|
||||
? `<span class="gantt-conflict-badge" title="${esc(conflicts.map(c => `${c.name}: ${c.type} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('\n'))}">${icon('warning')}</span>`
|
||||
: '';
|
||||
|
||||
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}">
|
||||
// Ruhigere Namensspalte (Eriks Wahl "A"): Vorgänger stehen nicht mehr
|
||||
// als Schildchen im Namen, sondern im Tooltip (und als Linie im
|
||||
// Diagramm); rechts die Kürzel der Zuständigen.
|
||||
const vorgaengerText = depBadge ? '\nVorgänger: ' + (depBadge.match(/title="Vorgänger: ([^"]*)"/) || [,''])[1] : '';
|
||||
const vorgaengerKonflikt = depBadge.indexOf('conflict') !== -1;
|
||||
const leute = (Array.isArray(todo.assignees) ? todo.assignees : []).map(id => {
|
||||
const pers = ((app.week && app.week.people) || []).find(x => x.id === id);
|
||||
return pers ? pers.name : '';
|
||||
}).filter(Boolean);
|
||||
const leuteHtml = leute.length
|
||||
? `<span class="gantt-leute">${leute.slice(0, 3).map(k => `<span>${esc(k)}</span>`).join('')}${leute.length > 3 ? `<span>+${leute.length - 3}</span>` : ''}</span>` : '';
|
||||
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}${vorgaengerText}">
|
||||
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
|
||||
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
|
||||
${conflictBadge}
|
||||
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}</span>
|
||||
${vorgaengerKonflikt ? '<span class="gantt-vorgaenger-warn" title="Beginnt vor dem Ende eines Vorgängers">!</span>' : ''}
|
||||
${conflictBadge}${leuteHtml}
|
||||
</div>
|
||||
<div class="gantt-timeline">${barHtml}</div>`;
|
||||
return row;
|
||||
@@ -260,16 +271,73 @@
|
||||
}
|
||||
|
||||
if(!projectRow) return;
|
||||
// Die Projektzeile trägt die Gesamtdauer und Meilensteine ohne eigene
|
||||
// Zeile; sie heißt jetzt schlicht so, statt den Projektnamen ein
|
||||
// zweites Mal zu wiederholen.
|
||||
projectRow.classList.add('tp-projektzeile');
|
||||
const projektLabel = projectRow.querySelector('.gantt-label');
|
||||
if(projektLabel) projektLabel.textContent = 'Projekt gesamt';
|
||||
let insertAfter = projectRow;
|
||||
const insertedMilestoneIds = new Set();
|
||||
|
||||
todos.forEach((todo, tIdx) => {
|
||||
// Gliederung nach Leistungsphasen (Eriks Wahl "A"): Phasenbalken
|
||||
// (balkenPhase(), load.js) werden Gruppenköpfe; jede andere Aufgabe
|
||||
// steht unter der Phase, in deren Balken sie beginnt. Nur die
|
||||
// ANZEIGE wird umsortiert — plan.todos übernimmt die Reihenfolge erst,
|
||||
// wenn jemand eine Zeile zieht (reorderPlanRows(), plan-drag.js).
|
||||
const istPhase = t => typeof balkenPhase === 'function' && !!balkenPhase(t);
|
||||
const hatBalken = t => t.start && t.end && t.showInGantt !== false;
|
||||
const phasenKoepfe = todos.filter(t => hatBalken(t) && istPhase(t))
|
||||
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
|
||||
const gruppeVon = t => {
|
||||
if(istPhase(t)) return t.id;
|
||||
const s0 = parseDateISO(t.start);
|
||||
const k = s0 && phasenKoepfe.find(p => s0 >= parseDateISO(p.start) && s0 <= parseDateISO(p.end));
|
||||
return k ? k.id : '';
|
||||
};
|
||||
let reihe = todos;
|
||||
if(phasenKoepfe.length){
|
||||
reihe = [];
|
||||
phasenKoepfe.forEach(p => { reihe.push(p); todos.forEach(t => { if(!istPhase(t) && gruppeVon(t) === p.id) reihe.push(t); }); });
|
||||
todos.forEach(t => { if(reihe.indexOf(t) === -1) reihe.push(t); });
|
||||
}
|
||||
const heuteIsoWert = formatDateISO(new Date());
|
||||
const filter = options.filter || 'alle';
|
||||
const passtFilter = t => filter === 'alle'
|
||||
|| (filter === 'meine' && options.eigenePersonId && Array.isArray(t.assignees) && t.assignees.indexOf(options.eigenePersonId) !== -1)
|
||||
|| (filter === 'ueberfaellig' && !t.done && t.end && t.end < heuteIsoWert);
|
||||
const phasenZu = options.phasenZu || {};
|
||||
let aktuelleGruppeZu = false;
|
||||
let restKopfGesetzt = false;
|
||||
const einfuegen = row => { insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; };
|
||||
|
||||
// Meilensteine ohne Aufgabenbezug: je eine eigene Zeile direkt unter
|
||||
// "Projekt gesamt", nach Datum sortiert (Eriks Meldung: ohne Aufgaben
|
||||
// standen alle Meilensteine übereinander in derselben Zeile).
|
||||
milestones.filter(m => !m.position)
|
||||
.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')))
|
||||
.forEach(m => einfuegen(buildMilestoneRow(m, milestones.indexOf(m))));
|
||||
|
||||
reihe.forEach(todo => {
|
||||
const tIdx = todos.indexOf(todo);
|
||||
if(phasenKoepfe.length && hatBalken(todo)){
|
||||
if(istPhase(todo)) aktuelleGruppeZu = !!phasenZu[balkenPhase(todo)];
|
||||
else if(!gruppeVon(todo) && !restKopfGesetzt){
|
||||
// Aufgaben außerhalb jedes Phasenbalkens bekommen einen eigenen Kopf.
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'gantt-row tp-gruppe';
|
||||
kopf.innerHTML = '<div class="gantt-label">Weitere Aufgaben</div><div class="gantt-timeline"></div>';
|
||||
einfuegen(kopf);
|
||||
restKopfGesetzt = true;
|
||||
aktuelleGruppeZu = false;
|
||||
}
|
||||
}
|
||||
// Meilensteine, die VOR dieser Aufgabe positioniert sind
|
||||
milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => {
|
||||
const mIdx = milestones.indexOf(m);
|
||||
const row = buildMilestoneRow(m, mIdx);
|
||||
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
|
||||
insertAfter = row;
|
||||
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
|
||||
einfuegen(row);
|
||||
insertedMilestoneIds.add(m.id);
|
||||
});
|
||||
|
||||
@@ -292,16 +360,40 @@
|
||||
// kippt.
|
||||
if(todo.start && todo.end && todo.showInGantt !== false){
|
||||
const todoRow = buildTodoRow(todo, tIdx);
|
||||
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
|
||||
insertAfter = todoRow;
|
||||
if(phasenKoepfe.length && istPhase(todo)){
|
||||
const ph = balkenPhase(todo);
|
||||
const zu = !!phasenZu[ph];
|
||||
const kinder = todos.filter(t => !istPhase(t) && hatBalken(t) && gruppeVon(t) === todo.id);
|
||||
todoRow.classList.add('gantt-phase');
|
||||
const label = todoRow.querySelector('.gantt-label');
|
||||
if(label){
|
||||
const knopf = document.createElement('button');
|
||||
knopf.type = 'button';
|
||||
knopf.className = 'tp-klappen';
|
||||
knopf.setAttribute('aria-expanded', String(!zu));
|
||||
knopf.setAttribute('aria-label', (zu ? 'Aufklappen: ' : 'Zuklappen: ') + ph);
|
||||
knopf.setAttribute('onclick', `event.stopPropagation(); tpPhaseUmschalten('${ph.replace(/'/g, '')}')`);
|
||||
knopf.textContent = zu ? '▶' : '▼';
|
||||
label.insertBefore(knopf, label.firstChild);
|
||||
const info = document.createElement('span');
|
||||
info.className = 'tp-phase-info';
|
||||
info.textContent = kinder.length + ' · ' + (parseInt(todo.progress, 10) || 0) + ' %';
|
||||
info.title = (kinder.length === 1 ? '1 Aufgabe' : kinder.length + ' Aufgaben') + ', Phase zu ' + (parseInt(todo.progress, 10) || 0) + ' % erledigt';
|
||||
label.appendChild(info);
|
||||
}
|
||||
} else {
|
||||
if(aktuelleGruppeZu) todoRow.classList.add('gantt-zu');
|
||||
if(!passtFilter(todo)) todoRow.classList.add('gantt-gefiltert');
|
||||
}
|
||||
einfuegen(todoRow);
|
||||
}
|
||||
|
||||
// Meilensteine, die NACH dieser Aufgabe positioniert sind
|
||||
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
|
||||
const mIdx = milestones.indexOf(m);
|
||||
const row = buildMilestoneRow(m, mIdx);
|
||||
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
|
||||
insertAfter = row;
|
||||
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
|
||||
einfuegen(row);
|
||||
insertedMilestoneIds.add(m.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,14 +130,11 @@
|
||||
const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
|
||||
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
|
||||
|
||||
// Sicherheitscheck: Anzahl muss stimmen. Nicht jedes Meilenstein wird als
|
||||
// eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten
|
||||
// Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position
|
||||
// liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur
|
||||
// die positionierten zählen.
|
||||
// Sicherheitscheck: Anzahl muss stimmen. Jeder Meilenstein hat eine
|
||||
// eigene .gantt-milestone-row (auch ohne Position, dann oben unter
|
||||
// "Projekt gesamt").
|
||||
if(todoRowIds.length !== scheduledIds.size) return;
|
||||
const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id);
|
||||
if(milestoneRowIds.length !== positionedIds.length) return;
|
||||
if(milestoneRowIds.length !== milestones.length) return;
|
||||
|
||||
// TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden
|
||||
// gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne
|
||||
@@ -153,7 +150,7 @@
|
||||
// Regeln:
|
||||
// - Steht der Meilenstein direkt vor einer TODO -> before:todoId
|
||||
// - Steht er direkt nach einer TODO -> after:todoId
|
||||
// - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (oben in Projektzeile)
|
||||
// - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (eigene Zeile oben)
|
||||
// - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId
|
||||
rows.forEach((row, idx) => {
|
||||
if(!row.classList.contains('gantt-milestone-row')) return;
|
||||
@@ -165,7 +162,11 @@
|
||||
const prevRow = rows[idx - 1];
|
||||
const nextRow = rows[idx + 1];
|
||||
|
||||
if(prevRow && prevRow.classList.contains('gantt-todo')){
|
||||
const todoDavor = rows.slice(0, idx).some(r => r.classList.contains('gantt-todo'));
|
||||
if(!todoDavor){
|
||||
// Vor der ersten Aufgabe: ohne Position (eigene Zeile oben).
|
||||
position = '';
|
||||
} else if(prevRow && prevRow.classList.contains('gantt-todo')){
|
||||
position = 'after:' + prevRow.dataset.todoId;
|
||||
} else if(nextRow && nextRow.classList.contains('gantt-todo')){
|
||||
position = 'before:' + nextRow.dataset.todoId;
|
||||
@@ -449,6 +450,14 @@
|
||||
const globalAbsence = globalPerson && Array.isArray(globalPerson.absences)
|
||||
? globalPerson.absences.find(x => x.id === absenceId) : null;
|
||||
if(!globalAbsence) return;
|
||||
// Abwesenheit einer anderen Person: Art und Notiz kennt nur sie selbst
|
||||
// (loadGlobalPeople() schickt sie verdeckt). Das Verschieben legt neu an
|
||||
// und bräuchte die Art — sonst wäre der Eintrag nach dem Löschen weg.
|
||||
if(globalAbsence.verdeckt){
|
||||
app.showToast('Abwesenheiten anderer Personen lassen sich hier nicht verschieben.');
|
||||
renderPlanGantt();
|
||||
return;
|
||||
}
|
||||
const type = globalAbsence.type || '';
|
||||
const note = globalAbsence.note || '';
|
||||
// Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
const hint = document.getElementById('filesNoProjectHint');
|
||||
const uploadSection = document.getElementById('filesUploadSection');
|
||||
if(hint) hint.style.display = hasNr ? 'none' : '';
|
||||
if(uploadSection) uploadSection.style.display = hasNr ? '' : 'none';
|
||||
if(uploadSection) uploadSection.style.display = (hasNr && hatRecht('proj.dateien')) ? '' : 'none';
|
||||
const fileInput = document.getElementById('fileUploadInput');
|
||||
if(fileInput) fileInput.value = '';
|
||||
if(hasNr) loadReviewerCheckboxes();
|
||||
@@ -212,7 +212,7 @@
|
||||
// Dieselbe Regel wie im Server (DELETE /project-files/:id): der Uploader,
|
||||
// solange die Datei noch offen ist — oder ein Administrator. Vorher stand
|
||||
// der Knopf bei jeder Datei und endete für andere in einer Fehlermeldung.
|
||||
const isAdmin = !!(typeof currentUser !== 'undefined' && currentUser && currentUser.role === 'admin');
|
||||
const isAdmin = hatRecht('proj.dateienFremd'); // Recht „Fremde Dateien löschen“
|
||||
const isUploader = !!(typeof currentUser !== 'undefined' && currentUser && f.uploadedByUserId === currentUser.id);
|
||||
const canDelete = isAdmin || (isUploader && f.status === 'pending');
|
||||
const reviewerChips = (f.reviewers || []).map(r =>
|
||||
|
||||
+187
-76
@@ -10,7 +10,7 @@
|
||||
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
|
||||
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
|
||||
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten;
|
||||
// Team-Auslastung dagegen über berechneAuslastung() aus js/load/load.js,
|
||||
// Team-Auslastung dagegen über aufgabenVerlaufJePerson() aus js/load/load.js,
|
||||
// siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im
|
||||
// Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die
|
||||
// zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
|
||||
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den
|
||||
// Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und
|
||||
// Projektzeitraum), berücksichtigt Urlaub, Sperrstatus und
|
||||
// Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
|
||||
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
|
||||
window.renderPlanGantt = function(){
|
||||
@@ -41,16 +41,23 @@
|
||||
document.getElementById('planGanttStart').value = formatDateISO(start);
|
||||
document.getElementById('planGanttEnd').value = formatDateISO(end);
|
||||
}
|
||||
// Zeitraum auch sichtbar ins Feld schreiben (es zeigte sonst nur den
|
||||
// Platzhalter, solange niemand einen Zeitraum gewählt hatte).
|
||||
const zeitraumAnzeige = document.getElementById('planGanttRange');
|
||||
if(zeitraumAnzeige) zeitraumAnzeige.value = formatDateDE(start) + ' – ' + formatDateDE(end);
|
||||
const showVacation = document.getElementById('planShowVacation').checked;
|
||||
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
|
||||
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
|
||||
const ghost = collectGhostDates();
|
||||
container.innerHTML = buildGanttHtml([app.project], start, end, {
|
||||
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
|
||||
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath,
|
||||
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay,
|
||||
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
|
||||
filter: app.ganttFilter || 'alle', phasenZu: app.phasenZu || (app.phasenZu = {}),
|
||||
eigenePersonId: (typeof currentUser !== 'undefined' && currentUser && currentUser.personId) || '',
|
||||
});
|
||||
renderOhneTermin();
|
||||
container.scrollLeft = prevScroll;
|
||||
if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container);
|
||||
requestAnimationFrame(() => renderGanttDependencies(container));
|
||||
renderPlanWarnings();
|
||||
renderResourceLoad();
|
||||
@@ -61,6 +68,7 @@
|
||||
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
|
||||
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
|
||||
if(typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||
if(typeof renderPlanPhasen === 'function') renderPlanPhasen();
|
||||
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
|
||||
};
|
||||
|
||||
@@ -121,71 +129,68 @@
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis().
|
||||
// Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile),
|
||||
// warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen
|
||||
// Aufgaben). Rückgabe: HTML-String.
|
||||
function kpiTile(label, value, sub, warn){
|
||||
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
|
||||
// bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label,
|
||||
// value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String.
|
||||
function bandZelle(label, value, sub, warn){
|
||||
return `<div class="plan-lage-zelle">
|
||||
<div class="kpi-label">${esc(label)}</div>
|
||||
<div class="kpi-value">${esc(value)}</div>
|
||||
<div class="kpi-sub">${esc(sub)}</div>
|
||||
<div class="kpi-sub${warn ? ' ist-rot' : ''}">${esc(sub)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
|
||||
// #planKpiRow in App.html) mit vier Kennzahlen, die alle aus den
|
||||
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
|
||||
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
|
||||
// überfällige), nächster anstehender Meilenstein und Team-Auslastung
|
||||
// (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im
|
||||
// Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie
|
||||
// nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist.
|
||||
// renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow,
|
||||
// Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring
|
||||
// (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene
|
||||
// Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung
|
||||
// (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der
|
||||
// frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den
|
||||
// Stand eines Projekts zählt die Leistungsphase. Wird nach jedem
|
||||
// GANTT-Rendering aufgerufen, damit es sofort aktuell ist.
|
||||
window.renderPlanKpis = function(){
|
||||
const row = document.getElementById('planKpiRow');
|
||||
if(!row || !app.project) return;
|
||||
const p = app.project;
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let progressTile;
|
||||
if(todos.length === 0){
|
||||
progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt');
|
||||
} else {
|
||||
const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length);
|
||||
const doneCount = todos.filter(t => t.done).length;
|
||||
progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt');
|
||||
}
|
||||
const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || '';
|
||||
const roh = lph && p.lph ? p.lph[lph] : '';
|
||||
const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0));
|
||||
const phase = `<div class="plan-lage-zelle plan-lage-phase">
|
||||
<span class="plan-lage-ring" style="--p:${pct || 0}"></span>
|
||||
<div><div class="kpi-label">Laufende Phase</div>
|
||||
<div class="kpi-value">${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}</div>
|
||||
<div class="kpi-sub">${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}</div></div>
|
||||
</div>`;
|
||||
|
||||
const open = todos.filter(t => !t.done);
|
||||
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today);
|
||||
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'Im Zeitplan' : 'Alle Aufgaben erledigt');
|
||||
const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0);
|
||||
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
|
||||
|
||||
const upcoming = milestones
|
||||
.filter(m => m.date && parseDateISO(m.date) >= today)
|
||||
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||
let milestoneTile;
|
||||
if(upcoming.length > 0){
|
||||
milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein');
|
||||
} else {
|
||||
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
|
||||
}
|
||||
const msZelle = upcoming.length > 0
|
||||
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
|
||||
: bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt');
|
||||
|
||||
const { loads } = computeResourceLoads();
|
||||
const loadValues = Object.values(loads);
|
||||
let loadTile;
|
||||
let loadZelle;
|
||||
if(loadValues.length === 0){
|
||||
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten zugeordnet');
|
||||
loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet');
|
||||
} else {
|
||||
const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length);
|
||||
const top = loadValues.slice().sort((a, b) => b.quote - a.quote)[0];
|
||||
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.stunden > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch nicht bewertbar');
|
||||
loadZelle = bandZelle('Team-Auslastung', avgPct + ' %', top.stunden > 0
|
||||
? 'höchste: ' + (top.person.fullName || top.person.name) + ' ' + Math.round(top.quote * 100) + ' %' : 'noch nicht bewertbar');
|
||||
}
|
||||
|
||||
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
|
||||
row.innerHTML = phase + bandZelle('Offene Aufgaben', String(open.length), openSub, overdue.length > 0) + msZelle + loadZelle;
|
||||
};
|
||||
|
||||
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
|
||||
@@ -202,7 +207,13 @@
|
||||
let svg = chart.querySelector('.gantt-dependencies-svg');
|
||||
if(svg) svg.remove();
|
||||
|
||||
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'));
|
||||
// Linien abgeschaltet (Menü "Mehr") — dann nichts zeichnen.
|
||||
const pfeile = document.getElementById('planShowPfeile');
|
||||
if(pfeile && !pfeile.checked) return;
|
||||
// Ausgeblendete Zeilen (zugeklappte Phase, Filter) haben keine Lage —
|
||||
// Linien dorthin würden ins Leere zeigen.
|
||||
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'))
|
||||
.filter(b => b.offsetParent !== null);
|
||||
if(nodeBars.length === 0) return;
|
||||
|
||||
const chartRect = chart.getBoundingClientRect();
|
||||
@@ -283,6 +294,97 @@
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ruhige Leiste (Eriks Wahl "A"): Maßstab, Heute, Filter, Menüs.
|
||||
// ---------------------------------------------------------------------------
|
||||
const TP_STUFEN = { fit: null, woche: 8, tag: 32 };
|
||||
|
||||
// tpMassstab: Maßstab "Übersicht" (an die Breite angepasst), "Wochen" oder
|
||||
// "Tage" — feste Pixel je Tag statt der früheren −/+-Knöpfe.
|
||||
window.tpMassstab = function(stufe){
|
||||
app.pxPerDay = TP_STUFEN[stufe] === undefined ? null : TP_STUFEN[stufe];
|
||||
document.querySelectorAll('#tpMassstab button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe)));
|
||||
renderPlanGantt();
|
||||
if(app.pxPerDay) tpZuHeuteScrollen();
|
||||
};
|
||||
|
||||
function tpZuHeuteScrollen(){
|
||||
const c = document.getElementById('planGanttContainer');
|
||||
const heute = c && c.querySelector('.gantt-grid-today');
|
||||
if(!c || !heute) return;
|
||||
const r = heute.getBoundingClientRect(), cr = c.getBoundingClientRect();
|
||||
c.scrollLeft += (r.left - cr.left) - c.clientWidth / 3;
|
||||
}
|
||||
|
||||
// tpHeute: Zeitraum ab zwei Wochen vor heute, gleiche Länge wie bisher
|
||||
// (mindestens 12 Wochen), und heute ins Bild holen.
|
||||
window.tpHeute = function(){
|
||||
const s = parseDateISO(document.getElementById('planGanttStart').value);
|
||||
const e = parseDateISO(document.getElementById('planGanttEnd').value);
|
||||
const tage = s && e ? Math.max(84, daysBetween(s, e)) : 84;
|
||||
const von = new Date(); von.setHours(0, 0, 0, 0); von.setDate(von.getDate() - 14);
|
||||
const bis = new Date(von); bis.setDate(bis.getDate() + tage);
|
||||
document.getElementById('planGanttStart').value = formatDateISO(von);
|
||||
document.getElementById('planGanttEnd').value = formatDateISO(bis);
|
||||
const anzeige = document.getElementById('planGanttRange');
|
||||
if(anzeige) anzeige.value = formatDateDE(von) + ' – ' + formatDateDE(bis);
|
||||
renderPlanGantt();
|
||||
if(app.pxPerDay) tpZuHeuteScrollen();
|
||||
};
|
||||
|
||||
// tpFilter: Alle / Meine (eigene Zuständigkeit) / Überfällig. Gefilterte
|
||||
// Zeilen werden nur ausgeblendet, nicht weggelassen — das Umsortieren per
|
||||
// Ziehen (plan-drag.js) zählt die Zeilen im DOM.
|
||||
window.tpFilter = function(filter){
|
||||
app.ganttFilter = filter;
|
||||
document.querySelectorAll('#tpFilter button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// tpPhaseUmschalten: klappt eine Leistungsphase im Terminplan auf/zu.
|
||||
window.tpPhaseUmschalten = function(phase){
|
||||
if(!app.phasenZu) app.phasenZu = {};
|
||||
app.phasenZu[phase] = !app.phasenZu[phase];
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// tpMenueZu: schließt die Menüs "Mehr" und "+ Neu".
|
||||
window.tpMenueZu = function(){
|
||||
document.querySelectorAll('.tp-menue[open]').forEach(d => d.removeAttribute('open'));
|
||||
};
|
||||
// Ein Klick außerhalb schließt ein offenes Menü; ein Menü öffnen schließt
|
||||
// das andere.
|
||||
document.addEventListener('click', e => {
|
||||
document.querySelectorAll('.tp-menue[open]').forEach(d => { if(!d.contains(e.target)) d.removeAttribute('open'); });
|
||||
});
|
||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') window.tpMenueZu(); });
|
||||
// Die Liste hängt rechtsbündig unter dem Knopf. Bricht die Leiste in einem
|
||||
// schmalen Fenster um, steht "+ Neu" links und die Liste rutschte unter die
|
||||
// Seitenleiste — dann linksbündig aufklappen (Klasse .nach-rechts).
|
||||
document.addEventListener('toggle', e => {
|
||||
const d = e.target;
|
||||
if(!d.classList || !d.classList.contains('tp-menue') || !d.open) return;
|
||||
const liste = d.querySelector('.tp-menue-liste');
|
||||
if(!liste) return;
|
||||
liste.classList.remove('nach-rechts');
|
||||
const seite = document.querySelector('.side-menu');
|
||||
const r = seite ? seite.getBoundingClientRect() : null;
|
||||
const linkeGrenze = r && r.width && getComputedStyle(seite).position !== 'fixed' ? r.right : 0;
|
||||
if(liste.getBoundingClientRect().left < linkeGrenze + 8) liste.classList.add('nach-rechts');
|
||||
}, true);
|
||||
|
||||
// renderOhneTermin: Hinweis unter dem Diagramm auf Aufgaben, die keinen
|
||||
// Balken haben (ohne Zeitraum oder bewusst ausgeblendet) — sie stehen nur
|
||||
// unter "Aufgaben". Vorher fehlte jeder Hinweis, dass es sie gibt.
|
||||
function renderOhneTermin(){
|
||||
const el = document.getElementById('planOhneTermin');
|
||||
if(!el) return;
|
||||
const todos = (app.project && app.project.plan && Array.isArray(app.project.plan.todos)) ? app.project.plan.todos : [];
|
||||
const ohne = todos.filter(t => !t.done && !(t.start && t.end && t.showInGantt !== false));
|
||||
el.hidden = ohne.length === 0;
|
||||
el.innerHTML = ohne.length ? `<span class="tp-ohne-zahl">${ohne.length}</span> ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin <span class="tp-ohne-hinweis">stehen nur unter „Aufgaben“ →</span>` : '';
|
||||
}
|
||||
|
||||
// zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
|
||||
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
|
||||
window.zoomPlanGantt = function(dir){
|
||||
@@ -291,7 +393,7 @@
|
||||
let end = parseDateISO(document.getElementById('planGanttEnd').value);
|
||||
if(!start || !end) return;
|
||||
const totalDays = Math.max(1, daysBetween(start, end) + 1);
|
||||
const cw = container ? Math.max(200, container.clientWidth - 250) : 900;
|
||||
const cw = container ? Math.max(200, container.clientWidth - (ganttSpaltenBreite() + 30)) : 900;
|
||||
const fitPx = Math.max(3, cw / totalDays);
|
||||
const base = app.pxPerDay || fitPx;
|
||||
let next;
|
||||
@@ -308,33 +410,20 @@
|
||||
// ---------- Ressourcen-Auslastung ----------
|
||||
|
||||
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten
|
||||
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel
|
||||
// (berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor(),
|
||||
// js/load/load.js) — Wochenstunden aus Honorar × Phasenanteil ×
|
||||
// Restfortschritt, bezogen auf die Kapazität der Person.
|
||||
//
|
||||
// Vorher zählte diese Stelle etwas ganz anderes: Aufgabentage aus den
|
||||
// TODO-Zeiträumen DIESES Terminplans, ohne jeden Bezug zu Honorar oder
|
||||
// Kapazität — ein Blick auf dieselbe Person zeigte hier eine andere Zahl
|
||||
// als im Auslastungs-Tab. Erik: "Die Auslastung der Projektbeteiligten im
|
||||
// Terminplan ist eine andere als die in der sonstigen Anwendung... gleich-
|
||||
// setzen mit der in der sonstigen Anwendung." Jetzt wird für JEDES Projekt
|
||||
// der Sitzung (dieses UND alle übrigen) genau derselbe
|
||||
// berechneAuslastung()-Aufruf gemacht wie in renderLoad() — dieselbe
|
||||
// Person zeigt hier also exakt dieselbe Gesamtzahl wie im Auslastungs-Tab,
|
||||
// nur aufgeteilt in den Anteil DIESES Projekts und den Anteil der übrigen
|
||||
// Projekte der Sitzung (sonst wäre eine Überbuchung hier unsichtbar:
|
||||
// jemand kann in diesem Projekt entspannt aussehen und trotzdem in vier
|
||||
// weiteren ausgebucht sein — dieselbe Überlegung wie zuvor, nur jetzt in
|
||||
// Stunden statt Tagen).
|
||||
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel:
|
||||
// Stunden dieser Woche aus Aufgaben mit Aufwand, gleich geteilt auf die
|
||||
// Zuständigen (aufgabenVerlaufJePerson(), js/load/load.js), bezogen auf die
|
||||
// Kapazität der Person. Für JEDES Projekt der Sitzung, aufgeteilt in den
|
||||
// Anteil DIESES Projekts und den der übrigen — sonst sähe jemand hier
|
||||
// entspannt aus, der in vier anderen Projekten ausgebucht ist.
|
||||
//
|
||||
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
|
||||
// Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit
|
||||
// beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt
|
||||
// Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere,
|
||||
// stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos
|
||||
// (Titel der eigenen TODOs in DIESEM Projekt, rein informativ — zählt
|
||||
// nicht mehr in die Stundenrechnung mit ein).
|
||||
// (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken),
|
||||
// offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen).
|
||||
function computeResourceLoads(){
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
@@ -353,25 +442,42 @@
|
||||
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
|
||||
});
|
||||
|
||||
// Zuständige einer Aufgabe zählen mit, auch wenn sie unter „Beteiligte“
|
||||
// fehlen — wie auf der Auslastungsseite. Sonst fielen ihre
|
||||
// Aufgabenstunden hier stillschweigend weg.
|
||||
const personen = (app.week && app.week.people) || [];
|
||||
todos.forEach(t => {
|
||||
if(!Array.isArray(t.assignees)) return;
|
||||
if(!Array.isArray(t.assignees) || balkenPhase(t)) return;
|
||||
t.assignees.forEach(pid => {
|
||||
if(loads[pid]) loads[pid].todos.push(t.title);
|
||||
if(!loads[pid]){
|
||||
const person = personen.find(x => x.id === pid);
|
||||
if(!person) return;
|
||||
loads[pid] = {
|
||||
person, roles: ['nur Aufgabe'], todos: [],
|
||||
kapazitaet: kapazitaetsFaktor(person) * vollzeit,
|
||||
stundenProjekt: 0, stundenAndere: 0, andereProjekte: [],
|
||||
};
|
||||
}
|
||||
loads[pid].todos.push(t.title);
|
||||
});
|
||||
});
|
||||
|
||||
// Offene Aufgabenstunden dieses Projekts insgesamt (nicht nur diese
|
||||
// Woche) — liegt eine Aufgabe erst später, zeigte die Wochenzahl sonst 0.
|
||||
Object.values(loads).forEach(l => { l.offenProjekt = 0; });
|
||||
offeneAufwandsAufgaben(app.project).forEach(({ t, rest }) => {
|
||||
t.assignees.forEach(pid => { if(loads[pid]) loads[pid].offenProjekt += rest / t.assignees.length; });
|
||||
});
|
||||
|
||||
// Stunden dieser Woche allein aus Aufgaben mit Aufwand — dieselbe
|
||||
// Rechnung wie auf der Auslastungsseite (aufgabenVerlaufJePerson(), load.js).
|
||||
((app.week && app.week.projects) || []).forEach(pr => {
|
||||
const aktivePhase = lpActiveList(pr)[0];
|
||||
Object.keys(pr.assign || {}).forEach(aKey => {
|
||||
const pid = aKey.slice(0, aKey.indexOf('|'));
|
||||
if(!loads[pid]) return; // nur Personen, die auch in DIESEM Projekt beteiligt sind
|
||||
const rolle = aKey.slice(aKey.indexOf('|') + 1);
|
||||
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign);
|
||||
if(al.stunden <= 0) return;
|
||||
if(pr.id === app.project.id){
|
||||
loads[pid].stundenProjekt += al.stunden;
|
||||
} else {
|
||||
loads[pid].stundenAndere += al.stunden;
|
||||
const av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {};
|
||||
Object.keys(av).forEach(pid => {
|
||||
if(!loads[pid] || !(av[pid][0] > 0)) return;
|
||||
if(pr.id === app.project.id) loads[pid].stundenProjekt += av[pid][0];
|
||||
else {
|
||||
loads[pid].stundenAndere += av[pid][0];
|
||||
const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt');
|
||||
if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label);
|
||||
}
|
||||
@@ -456,10 +562,15 @@
|
||||
</div>`
|
||||
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
|
||||
|
||||
const offenText = l.offenProjekt > 0
|
||||
? `<div class="plan-resource-other muted">${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts</div>`
|
||||
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
|
||||
|
||||
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
|
||||
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
|
||||
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h</span></div>
|
||||
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
|
||||
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
|
||||
${offenText}
|
||||
${otherBadge}
|
||||
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
|
||||
${absBadge}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Verlauf über mehrere Sitzungen
|
||||
// -----------------------------
|
||||
// Dieses Modul macht die Sitzungs-Historie eines Projekts sichtbar: die
|
||||
// Kompakt-Karte "Verlauf" unter dem GANTT zeigt den letzten Kommentarwechsel,
|
||||
// ein Klick öffnet #planHistoryModal mit dem vollständigen Verlauf.
|
||||
// Dieses Modul liefert die Fassungen eines Projekts über die Sitzungen
|
||||
// (Vorwochen-Helfer für den GANTT). Angezeigt wird der Kommentar-Verlauf
|
||||
// im Projekt-Dashboard von plan-info.js.
|
||||
//
|
||||
// Der Verlauf selbst wird NICHT hier gezeichnet, sondern von
|
||||
// renderProjektVerlauf() in js/utils.js — derselben Darstellung, die auch der
|
||||
// Wochenbericht unter der Projektkarte und die Projektliste zeigen. Zuvor
|
||||
// Der Verlauf in Wochenbericht und Projektliste wird von
|
||||
// renderProjektVerlauf() in js/utils.js gezeichnet. Zuvor
|
||||
// hatte jede dieser Stellen ihre eigene Fassung mit eigener Auswahl (hier nur
|
||||
// Sitzungen mit Kommentarwechsel, im Bericht auch Termine und
|
||||
// Leistungsphasen), weshalb derselbe Projektverlauf je nach Aufrufort anders
|
||||
// aussah und sich widersprechen konnte. Der Dialog übergibt kein weekLabel:
|
||||
// Er kennt keine "aktuelle" Fassung, die auszulassen wäre, und zeigt deshalb
|
||||
// alle Sitzungen einschließlich der geöffneten.
|
||||
// aussah und sich widersprechen konnte.
|
||||
//
|
||||
// Hintergrund: Beim wöchentlichen cloneWeek() (js/weeks.js) wird der
|
||||
// Kommentartext in die neue Sitzung kopiert und dort überschrieben. Der alte
|
||||
@@ -86,75 +83,10 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
// historyEntries: die Verlaufsliste des aktuell geöffneten Projekts
|
||||
// (neueste zuerst, Wiederholungen desselben Textes gefiltert). Logik in
|
||||
// js/utils.js, siehe projectStatusHistory().
|
||||
function historyEntries(){
|
||||
return projectStatusHistory(app.project && app.project.projectUid);
|
||||
}
|
||||
|
||||
// renderPlanHistory: füllt die Kompakt-Karte "Verlauf" (#planHistorySummary
|
||||
// in App.html) — Anzahl der Einträge und die Sitzung des letzten
|
||||
// Kommentarwechsels. Wird von renderPlanPage() (plan-state.js) nach dem
|
||||
// Laden aufgerufen. Keine Parameter, keine Rückgabe.
|
||||
window.renderPlanHistory = function(){
|
||||
const summaryEl = document.getElementById('planHistorySummary');
|
||||
if(!summaryEl || !app.project) return;
|
||||
|
||||
// Ein Sandbox-Plan der Planung lebt in genau einer, nie geklonten Sitzung
|
||||
// (SANDBOX_WEEK_LABEL) — es gibt also nie einen "Verlauf über mehrere
|
||||
// Sitzungen" zu zeigen, nur immer denselben einen Schnappschuss.
|
||||
if(app.isSandboxPlan && app.isSandboxPlan()){
|
||||
summaryEl.innerHTML = '<span class="hint">Kein Verlauf — dieser Terminplan gehört zu keiner Sitzung.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
if(!app.project.projectUid){
|
||||
summaryEl.innerHTML = '<span class="hint">Für dieses Projekt ist kein Verlauf verfügbar.</span>';
|
||||
return;
|
||||
}
|
||||
const entries = historyEntries();
|
||||
if(entries.length === 0){
|
||||
summaryEl.innerHTML = '<span class="hint">Noch keine Kommentare erfasst.</span>';
|
||||
return;
|
||||
}
|
||||
const latest = entries[0];
|
||||
const versionCount = projectVersions().length;
|
||||
summaryEl.innerHTML = `<div class="plan-mini-card-preview">${esc(latest.text)}</div>
|
||||
<div class="plan-mini-chip-row">
|
||||
<span class="plan-mini-chip">${entries.length} Eintrag${entries.length === 1 ? '' : 'e'}</span>
|
||||
<span class="plan-mini-chip">zuletzt ${esc(latest.week.label)}</span>
|
||||
<span class="plan-mini-chip">${versionCount} Sitzung${versionCount === 1 ? '' : 'en'}</span>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// openHistoryModal/closeHistoryModal: öffnen bzw. schließen das
|
||||
// Verlaufs-Modal (#planHistoryModal). Der Inhalt wird beim Öffnen frisch
|
||||
// aufgebaut, damit ein gerade im Projektinfo-Modal geänderter Kommentar
|
||||
// sofort mit auftaucht.
|
||||
window.openHistoryModal = function(){
|
||||
const listEl = document.getElementById('planHistoryList');
|
||||
if(listEl){
|
||||
if(!app.project || !app.project.projectUid){
|
||||
listEl.innerHTML = '<span class="hint">Dieses Projekt hat keine dauerhafte Projekt-Kennung und kann daher nicht über Sitzungen hinweg verfolgt werden.</span>';
|
||||
} else {
|
||||
// Dieselbe Darstellung wie im Wochenbericht und im Projektboard
|
||||
// (renderProjektVerlauf(), js/utils.js). Der Dialog hatte zuvor eine
|
||||
// eigene, enger gefasste Liste — sie zeigte nur Kommentartexte und
|
||||
// konnte dem Verlauf an den anderen Stellen widersprechen.
|
||||
//
|
||||
// weekLabel bleibt leer: Im Dialog gibt es keine "aktuelle" Fassung,
|
||||
// die auszulassen wäre — hier sollen ALLE Sitzungen erscheinen,
|
||||
// einschließlich der gerade geöffneten.
|
||||
listEl.innerHTML = renderProjektVerlauf(app.project, '', app.isLocked());
|
||||
}
|
||||
}
|
||||
document.getElementById('planHistoryModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
window.closeHistoryModal = function(){
|
||||
document.getElementById('planHistoryModal').style.display = 'none';
|
||||
};
|
||||
// Die Kompakt-Karte "Verlauf" (renderPlanHistory()) und der Dialog
|
||||
// #planHistoryModal (openHistoryModal()/closeHistoryModal()) sind entfallen:
|
||||
// Der Kommentar-Verlauf steht jetzt in der Kachel "Kommentar der Sitzung"
|
||||
// des Projekt-Dashboards (renderPlanKommentarVerlauf(), plan-info.js).
|
||||
|
||||
// Export für die anderen plan-Module (plan-gantt.js/plan-build-gantt.js:
|
||||
// Geister-Balken der letzten geplanten Sitzung).
|
||||
|
||||
@@ -94,7 +94,11 @@
|
||||
}
|
||||
|
||||
const stundensatz = honorarConfig.stundensatz;
|
||||
// Beginnt der Plan an einem Wochenende/Feiertag, auf den nächsten
|
||||
// Arbeitstag schieben — wie der Assistent "Neues Projekt". Sonst stand der
|
||||
// Balken ab Samstag, gezählt wurde aber erst ab Montag.
|
||||
let cursor = startDate;
|
||||
while(!isWorkday(cursor)) cursor = addDays(cursor, 1);
|
||||
let summeStunden = 0, summeTage = 0, summeHonorar = 0;
|
||||
|
||||
const zeilen = phasen.map(lph => {
|
||||
@@ -137,7 +141,7 @@
|
||||
// Eingaben gelten als 0 = "kein Honorar hinterlegt".
|
||||
// Parameter: val (Eingabewert). Keine Rückgabe.
|
||||
window.setHonorarBetrag = function(val){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
const v = parseFloat(String(val).replace(',', '.'));
|
||||
app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0;
|
||||
app.savePlan();
|
||||
@@ -152,7 +156,7 @@
|
||||
// Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan
|
||||
// chronologisch aufsteigt. Parameter: lph, checked (Boolean).
|
||||
window.toggleBeauftragteLp = function(lph, checked){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
const p = app.project;
|
||||
const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
|
||||
if(checked) set.add(lph); else set.delete(lph);
|
||||
@@ -260,7 +264,7 @@
|
||||
function renderHonorarHint(){
|
||||
const el = document.getElementById('honorarAdminHint');
|
||||
if(!el) return;
|
||||
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
|
||||
el.style.display = hatRecht('a.honorar') ? '' : 'none';
|
||||
}
|
||||
|
||||
// invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste
|
||||
@@ -326,6 +330,9 @@
|
||||
plan.todos.push({
|
||||
id: uid(),
|
||||
title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`,
|
||||
// Kennzeichen für die Auslastung: Die Reststunden dieser Phase
|
||||
// werden auf die Wochen dieses Balkens verteilt (load.js).
|
||||
lph: z.lph,
|
||||
start: formatDateISO(z.start),
|
||||
end: formatDateISO(z.ende),
|
||||
done: false,
|
||||
|
||||
+136
-6
@@ -70,9 +70,109 @@
|
||||
// amtierende Leistungsphase, LPH-Prozentliste) aus app.project auf. Wird
|
||||
// nach jedem Laden (renderPlanPage(), plan-state.js) sowie nach jeder
|
||||
// Änderung hier selbst erneut aufgerufen. Keine Parameter, keine Rückgabe.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kommentar-Verlauf im Projekt-Dashboard
|
||||
// ---------------------------------------------------------------------------
|
||||
// planKommentarDaten: sucht über alle Sitzungen dieses Projekts (bis
|
||||
// einschließlich der angezeigten) den jüngsten Kommentar und listet die
|
||||
// früheren je Gewerk. Ein Eintrag im Verlauf entsteht nur, wenn sich der
|
||||
// Text eines Gewerks gegenüber der Sitzung davor geändert hat — ein
|
||||
// unverändert mitgeführter Kommentar stünde sonst jede Woche erneut da.
|
||||
// Rückgabe: { letzter: {week, k, aktuell} | null, verlauf: [{week, gewerk, html, autor, at}] }
|
||||
function planKommentarDaten(p, week){
|
||||
const bisKey = week && week.label ? weekSortKey(week.label) : null;
|
||||
let versionen = (typeof projectVersionsForUid === 'function' && p.projectUid)
|
||||
? projectVersionsForUid(p.projectUid).filter(v => !bisKey || weekSortKey(v.week.label) <= bisKey)
|
||||
: [];
|
||||
// Angezeigte Sitzung immer mit dem lebenden Projektobjekt (Sandbox, oder
|
||||
// ein Projekt, das in db.weeks noch nicht steht).
|
||||
versionen = versionen.filter(v => !week || v.week.label !== week.label);
|
||||
versionen.push({ week: week || { label: '' }, project: p });
|
||||
|
||||
let letzter = null;
|
||||
for(let i = versionen.length - 1; i >= 0; i--){
|
||||
const k = kommentarVon(versionen[i].project, null);
|
||||
if(statusPlainText(k.status)){
|
||||
letzter = { week: versionen[i].week, k, aktuell: i === versionen.length - 1 };
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const gewerke = (typeof DEPARTMENTS === 'object' && DEPARTMENTS) ? Object.keys(DEPARTMENTS) : ['ELT','HLS','ARCH'];
|
||||
const verlauf = [];
|
||||
gewerke.forEach(g => {
|
||||
let vorher = '';
|
||||
versionen.forEach(v => {
|
||||
const k = kommentarVon(v.project, g);
|
||||
const text = statusPlainText(k.status);
|
||||
if(text && text !== vorher && !(letzter && v.week === letzter.week)){
|
||||
verlauf.push({ week: v.week, gewerk: g, html: sanitizeStatusHtml(k.status), autor: k.statusAuthor || '', at: k.statusAt || '' });
|
||||
}
|
||||
vorher = text;
|
||||
});
|
||||
});
|
||||
// Derselbe Text in mehreren Gewerken derselben Sitzung (Altbestand: die
|
||||
// Umstellung auf Kommentare je Gewerk hat ihn in jedes kopiert) wird ein
|
||||
// Eintrag mit allen Gewerken, statt zweimal untereinander zu stehen.
|
||||
const zusammen = [];
|
||||
verlauf.forEach(e => {
|
||||
const gleich = zusammen.find(z => z.week === e.week && statusPlainText(z.html) === statusPlainText(e.html));
|
||||
if(gleich){ gleich.gewerk += ', ' + e.gewerk; if(!gleich.autor) gleich.autor = e.autor; }
|
||||
else zusammen.push(Object.assign({}, e));
|
||||
});
|
||||
zusammen.sort((a, b) => weekSortKey(b.week.label).localeCompare(weekSortKey(a.week.label))
|
||||
|| String(b.at).localeCompare(String(a.at)));
|
||||
return { letzter, verlauf: zusammen };
|
||||
}
|
||||
|
||||
// "KW 43 / 2026" -> "KW 43" (das Jahr nur, wenn es nicht das laufende ist)
|
||||
function kurzKw(label){
|
||||
const m = /^(KW\s*\d+)\s*\/\s*(\d{4})$/.exec(String(label || '').trim());
|
||||
if(!m) return label || '';
|
||||
return m[2] === String(new Date().getFullYear()) ? m[1] : m[1] + ' / ' + m[2];
|
||||
}
|
||||
|
||||
const PLAN_VERLAUF_KURZ = 3;
|
||||
let planVerlaufAlle = false;
|
||||
let planVerlaufProjekt = null;
|
||||
|
||||
function renderPlanKommentarVerlauf(verlauf){
|
||||
const el = document.getElementById('planStatusVerlauf');
|
||||
if(!el) return;
|
||||
const uid = app.project && (app.project.projectUid || app.project.id);
|
||||
if(uid !== planVerlaufProjekt){ planVerlaufProjekt = uid; planVerlaufAlle = false; }
|
||||
if(!verlauf.length){ el.hidden = true; el.innerHTML = ''; return; }
|
||||
el.hidden = false;
|
||||
const zeigen = planVerlaufAlle ? verlauf : verlauf.slice(0, PLAN_VERLAUF_KURZ);
|
||||
const zeilen = zeigen.map((e, i) => `
|
||||
<li class="plan-verlauf-eintrag${i === 0 ? ' ist-juengster' : ''}">
|
||||
<span class="plan-verlauf-kw">${esc(kurzKw(e.week.label))}</span>
|
||||
<span class="plan-verlauf-punkt" aria-hidden="true"></span>
|
||||
<div class="plan-verlauf-inhalt">
|
||||
<div class="plan-verlauf-text">${e.html}</div>
|
||||
<div class="plan-verlauf-meta">${esc(e.gewerk)}${e.autor ? ' · ' + esc(e.autor) : ''}</div>
|
||||
</div>
|
||||
</li>`).join('');
|
||||
const mehr = verlauf.length > PLAN_VERLAUF_KURZ
|
||||
? `<button type="button" class="plan-verlauf-mehr" onclick="planVerlaufUmschalten()">${planVerlaufAlle ? 'Weniger anzeigen' : 'Alle ' + verlauf.length + ' anzeigen'}</button>`
|
||||
: '';
|
||||
el.innerHTML = `
|
||||
<div class="plan-verlauf-kopf">
|
||||
<span class="plan-verlauf-titel">Verlauf</span>
|
||||
<span class="plan-verlauf-anzahl">${verlauf.length === 1 ? '1 früherer Kommentar' : verlauf.length + ' frühere Kommentare'}</span>
|
||||
</div>
|
||||
<ol class="plan-verlauf-liste">${zeilen}</ol>${mehr}`;
|
||||
}
|
||||
|
||||
window.planVerlaufUmschalten = function(){
|
||||
planVerlaufAlle = !planVerlaufAlle;
|
||||
window.renderPlanInfo();
|
||||
};
|
||||
|
||||
|
||||
window.renderPlanInfo = function(){
|
||||
const p = app.project;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.stammGesperrt();
|
||||
|
||||
// Kommentar, Fortschritt und Zuordnung sind hier NUR ZU SEHEN.
|
||||
//
|
||||
@@ -83,15 +183,42 @@
|
||||
// Personen konnten dasselbe Feld an verschiedenen Stellen pflegen.
|
||||
// Was hier bleibt, sind die Stammdaten des Projekts (Bereich
|
||||
// "Einstellungen", siehe plan-settings.js).
|
||||
// Gezeigt wird der ZULETZT GESCHRIEBENE Kommentar (Eriks Wunsch, Entwurf
|
||||
// "A"): Hat die Sitzung noch keinen, der jüngste aus einer früheren — mit
|
||||
// Hinweis, aus welcher. Darunter der Verlauf der früheren Kommentare.
|
||||
const kom = planKommentarDaten(p, app.week);
|
||||
const editEl = document.getElementById('planStatusEdit');
|
||||
if(editEl){
|
||||
editEl.className = 'status-edit is-ansicht';
|
||||
editEl.contentEditable = 'false';
|
||||
const text = sanitizeStatusHtml(p.status || '');
|
||||
editEl.innerHTML = text || '<span class="hint" style="margin:0">Für diese Sitzung ist noch kein Kommentar erfasst.</span>';
|
||||
// Alle Gewerke zusammen, je mit Gewerk davor (kommentarVon(), js/utils.js).
|
||||
const text = kom.letzter ? sanitizeStatusHtml(kom.letzter.k.status) : '';
|
||||
editEl.innerHTML = text || '<span class="hint" style="margin:0">Zu diesem Projekt ist noch kein Kommentar erfasst.</span>';
|
||||
}
|
||||
const hinweisEl = document.getElementById('planStatusHinweis');
|
||||
if(hinweisEl){
|
||||
const aelter = kom.letzter && !kom.letzter.aktuell;
|
||||
hinweisEl.hidden = !aelter;
|
||||
hinweisEl.innerHTML = aelter
|
||||
? `In dieser Sitzung noch kein Kommentar — angezeigt wird der letzte aus <b>${esc(kurzKw(kom.letzter.week.label))}</b>.`
|
||||
: '';
|
||||
}
|
||||
// Wer den Kommentar wann geschrieben hat (jüngster Eintrag aller Gewerke).
|
||||
const autorEl = document.getElementById('planStatusAutor');
|
||||
if(autorEl){
|
||||
const k = kom.letzter ? kom.letzter.k : {};
|
||||
autorEl.innerHTML = k.status && k.statusAuthor
|
||||
? `<span class="plan-autor-ava">${esc(initialsForName(k.statusAuthor))}</span>${esc(k.statusAuthor)}`
|
||||
+ (k.statusAt && typeof formatCommentStamp === 'function' ? ', ' + esc(formatCommentStamp(k.statusAt)) : '')
|
||||
: '';
|
||||
}
|
||||
renderPlanKommentarVerlauf(kom.verlauf);
|
||||
const statusWrap = document.getElementById('planStatusWrap');
|
||||
if(statusWrap) statusWrap.classList.add('locked-cell');
|
||||
// Nicht mehr als gesperrt markieren: .locked-cell dimmt auf 55 % und legt im
|
||||
// Dunkelmodus ein Grau darunter — der Kommentar war dadurch kaum lesbar.
|
||||
// Dass er hier nur angezeigt wird, zeigen schon .is-ansicht und der
|
||||
// Verweis "Im Wochenbericht bearbeiten".
|
||||
if(statusWrap) statusWrap.classList.remove('locked-cell');
|
||||
setzeBerichtVerweis('planStatusVerweis', locked);
|
||||
|
||||
// Gruppe — Stammdatum, bleibt hier pflegbar
|
||||
@@ -176,7 +303,7 @@
|
||||
// setPlanGroup: ändert die Gruppe des Projekts. Parameter: val (String).
|
||||
// Keine Rückgabe; bei gesperrter Woche abgebrochen.
|
||||
window.setPlanGroup = function(val){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
app.project.group = val;
|
||||
app.savePlan();
|
||||
};
|
||||
@@ -185,11 +312,14 @@
|
||||
// Projekts auf. Parameter: val (LPH-Name oder leer). Rendert das
|
||||
// Infopanel neu, damit die Markierung in der LPH-Liste sofort mitzieht.
|
||||
window.setPlanActiveLp = function(val){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
if(val) app.project.aktuelleLP = [val];
|
||||
else delete app.project.aktuelleLP;
|
||||
app.savePlan();
|
||||
window.renderPlanInfo();
|
||||
// Abschnitt "Leistungsphasen" der Einstellungen: Kennzeichen "läuft"
|
||||
// und die Kurzfassung in der Unternavigation nachziehen.
|
||||
if(typeof renderPlanSettings === 'function') renderPlanSettings();
|
||||
};
|
||||
|
||||
// Die Schreibfunktionen setPlanLph()/setPlanAssign()/planStatusBlur() sind
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
// plan-mail-eintrag.js — "Termin aus Mail" (Eriks Wunsch): Mailtext per
|
||||
// Drag & Drop (markierter Text oder eine ganze .eml-Datei) oder Strg+V auf
|
||||
// den Terminplan bzw. die Aufgabenliste eines Projekts; eine KI macht daraus
|
||||
// Aufgaben. Der Terminplan ist nur die grafische Darstellung der
|
||||
// Aufgabenliste — deshalb entstehen hier ausschließlich Aufgaben
|
||||
// (plan.todos), keine Meilensteine.
|
||||
//
|
||||
// Nichts wird ohne Prüfung angelegt: Jeder Vorschlag öffnet das gewohnte
|
||||
// Aufgaben-Panel vorausgefüllt (openTodoModal(null, prefill)), die Mail
|
||||
// steht als Notiz darin (t.notiz). Mehrere Vorschläge erst als Liste zum
|
||||
// Abhaken, dann nacheinander im Panel. Server: POST /plan-eintrag-aus-mail
|
||||
// (speichert nichts).
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
const ANBIETER_KEY = 'projuebersicht-mail-ki';
|
||||
const ZIEL_ABSCHNITTE = ['terminplan', 'aufgaben'];
|
||||
let tiefe = 0; // dragenter/-leave-Zähler (Kindelemente feuern mit)
|
||||
let laeuft = false;
|
||||
|
||||
function aktiverAbschnitt(){
|
||||
if(typeof currentTab === 'undefined' || currentTab !== 'plan') return '';
|
||||
const s = document.querySelector('.plan-section.is-active');
|
||||
return s ? s.dataset.section : '';
|
||||
}
|
||||
|
||||
function ablageErlaubt(){
|
||||
return ZIEL_ABSCHNITTE.includes(aktiverAbschnitt()) && app.project && !app.isLocked();
|
||||
}
|
||||
|
||||
function bringtMail(dt){
|
||||
if(!dt) return false;
|
||||
const typen = Array.from(dt.types || []);
|
||||
return typen.includes('Files') || typen.includes('text/plain') || typen.includes('text/html');
|
||||
}
|
||||
|
||||
// ---------- Ablagefläche ----------
|
||||
|
||||
function flaeche(){
|
||||
let el = document.getElementById('mailAblage');
|
||||
if(!el){
|
||||
el = document.createElement('div');
|
||||
el.id = 'mailAblage';
|
||||
el.className = 'mail-ablage';
|
||||
el.hidden = true;
|
||||
el.innerHTML = `<div class="mail-ablage-karte">${icon('mail')}
|
||||
<b id="mailAblageTitel">Mail hier ablegen</b>
|
||||
<span id="mailAblageText">Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.</span></div>`;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function zeigeFlaeche(titel, text){
|
||||
const el = flaeche();
|
||||
document.getElementById('mailAblageTitel').textContent = titel;
|
||||
if(text !== undefined) document.getElementById('mailAblageText').textContent = text;
|
||||
el.classList.toggle('ist-beschaeftigt', laeuft);
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function versteckeFlaeche(){
|
||||
const el = document.getElementById('mailAblage');
|
||||
if(el) el.hidden = true;
|
||||
}
|
||||
|
||||
document.addEventListener('dragenter', e => {
|
||||
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return;
|
||||
tiefe++;
|
||||
zeigeFlaeche('Mail hier ablegen', 'Die KI macht daraus einen Eintrag im Terminplan. Der Mailtext geht dafür an den KI-Anbieter aus Einstellungen → KI-Zugangsdaten.');
|
||||
});
|
||||
document.addEventListener('dragover', e => {
|
||||
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
document.addEventListener('dragleave', () => {
|
||||
if(tiefe === 0) return;
|
||||
tiefe--;
|
||||
if(tiefe === 0 && !laeuft) versteckeFlaeche();
|
||||
});
|
||||
document.addEventListener('drop', e => {
|
||||
if(tiefe === 0 && !ablageErlaubt()) return;
|
||||
tiefe = 0;
|
||||
if(laeuft || !ablageErlaubt() || !bringtMail(e.dataTransfer)){ if(!laeuft) versteckeFlaeche(); return; }
|
||||
e.preventDefault();
|
||||
ausDataTransfer(e.dataTransfer);
|
||||
});
|
||||
|
||||
// Strg+V als Ausweg, wenn ein Mailprogramm das Ziehen nicht unterstützt —
|
||||
// nur außerhalb von Eingabefeldern, damit normales Einfügen unberührt bleibt.
|
||||
document.addEventListener('paste', e => {
|
||||
if(laeuft || !ablageErlaubt()) return;
|
||||
const ziel = e.target;
|
||||
if(ziel && (ziel.closest('input, textarea, select, [contenteditable="true"], .modal-bg'))) return;
|
||||
const dt = e.clipboardData;
|
||||
if(!dt) return;
|
||||
const datei = Array.from(dt.files || []).find(istEml);
|
||||
const text = (dt.getData('text/plain') || '').trim();
|
||||
if(!datei && !text) return;
|
||||
e.preventDefault();
|
||||
ausDataTransfer(dt);
|
||||
});
|
||||
|
||||
function istEml(f){ return f && /\.eml$/i.test(f.name || '') || (f && f.type === 'message/rfc822'); }
|
||||
|
||||
function ausDataTransfer(dt){
|
||||
const dateien = Array.from(dt.files || []);
|
||||
const eml = dateien.find(istEml);
|
||||
if(eml){
|
||||
if(eml.size > 5 * 1024 * 1024){ fehler('Die Mail ist zu groß (mehr als 5 MB).'); return; }
|
||||
eml.text().then(t => auswerten({ eml: t })).catch(() => fehler('Die Mail-Datei ließ sich nicht lesen.'));
|
||||
return;
|
||||
}
|
||||
if(dateien.some(f => /\.msg$/i.test(f.name || ''))){
|
||||
fehler('Outlook-Dateien (.msg) lassen sich nicht direkt lesen. Bitte den Mailtext markieren und hineinziehen oder mit Strg+V einfügen.');
|
||||
return;
|
||||
}
|
||||
let text = (dt.getData('text/plain') || '').trim();
|
||||
if(!text){
|
||||
const html = dt.getData('text/html') || '';
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, '');
|
||||
text = (div.innerText || div.textContent || '').trim();
|
||||
}
|
||||
if(!text){
|
||||
if(dateien.length) fehler('Diese Datei ist keine Mail. Abgelegt werden können markierter Mailtext oder .eml-Dateien.');
|
||||
else versteckeFlaeche();
|
||||
return;
|
||||
}
|
||||
auswerten({ text });
|
||||
}
|
||||
|
||||
function fehler(meldung){
|
||||
laeuft = false;
|
||||
versteckeFlaeche();
|
||||
app.showToast(meldung, 'error');
|
||||
}
|
||||
|
||||
// ---------- Auswerten ----------
|
||||
|
||||
function anbieterWaehlen(){
|
||||
return fetch(baseUrl() + '/my-ai-settings').then(r => r.json()).then(d => {
|
||||
const bereit = ['chatgpt', 'claude'].filter(a => d && d[a] && d[a].configured);
|
||||
let zuletzt = '';
|
||||
try { zuletzt = localStorage.getItem(ANBIETER_KEY) || ''; } catch(e){ /* egal */ }
|
||||
return bereit.includes(zuletzt) ? zuletzt : (bereit[0] || '');
|
||||
}).catch(() => '');
|
||||
}
|
||||
|
||||
function projektPersonen(){
|
||||
const gesehen = new Set();
|
||||
const liste = [];
|
||||
projectAssignees(app.project, app.week).forEach(a => {
|
||||
if(gesehen.has(a.person.id)) return;
|
||||
gesehen.add(a.person.id);
|
||||
liste.push(a.person);
|
||||
});
|
||||
return liste;
|
||||
}
|
||||
|
||||
function kontext(){
|
||||
const todos = (app.project.plan && app.project.plan.todos) || [];
|
||||
return {
|
||||
heute: heuteIso(),
|
||||
projekt: [app.project.nr, app.project.name].filter(Boolean).join(' '),
|
||||
personen: projektPersonen().map(p => p.fullName || p.name).filter(Boolean),
|
||||
phasen: todos.filter(t => t.lph && t.start && t.end).map(t => ({ lph: t.lph, start: t.start, ende: t.end }))
|
||||
};
|
||||
}
|
||||
|
||||
function auswerten(nutzlast){
|
||||
laeuft = true;
|
||||
zeigeFlaeche('Mail wird ausgewertet …', 'Die KI liest die Mail. Das dauert einige Sekunden.');
|
||||
anbieterWaehlen().then(anbieter => fetch(baseUrl() + '/plan-eintrag-aus-mail', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify(Object.assign({ anbieter, kontext: kontext() }, nutzlast))
|
||||
}))
|
||||
.then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||
.then(({ ok, d }) => {
|
||||
laeuft = false;
|
||||
versteckeFlaeche();
|
||||
if(!ok || !d.ok){ app.showToast(d.error || 'Die Mail konnte nicht ausgewertet werden.', 'error'); return; }
|
||||
if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){
|
||||
try { localStorage.setItem(ANBIETER_KEY, d.erkennungsart); } catch(e){ /* egal */ }
|
||||
}
|
||||
verarbeiten(d);
|
||||
})
|
||||
.catch(() => fehler('Der Server ist nicht erreichbar.'));
|
||||
}
|
||||
|
||||
// ---------- Vorschläge ins Panel ----------
|
||||
|
||||
// personenZuordnen: Namen aus der KI-Antwort → Personen-IDs des Projekts
|
||||
// (voller Name, sonst Nachname, sonst Kürzel; Groß-/Kleinschreibung egal).
|
||||
function personenZuordnen(namen){
|
||||
const leute = projektPersonen();
|
||||
const ids = [];
|
||||
(namen || []).forEach(n => {
|
||||
const s = String(n || '').trim().toLowerCase();
|
||||
if(!s) return;
|
||||
const treffer = leute.find(p => String(p.fullName || '').toLowerCase() === s)
|
||||
|| leute.find(p => { const nn = String(p.fullName || '').toLowerCase().split(/\s+/).pop(); return nn && (s === nn || s.split(/\s+/).pop() === nn); })
|
||||
|| leute.find(p => String(p.name || '').toLowerCase() === s);
|
||||
if(treffer && !ids.includes(treffer.id)) ids.push(treffer.id);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function notizText(mail, eintrag){
|
||||
const kopf = [];
|
||||
const datum = mail.datum ? formatDateDE(parseDateISO(mail.datum)) : '';
|
||||
kopf.push('Aus Mail' + (mail.absender ? ' von ' + mail.absender : '') + (datum ? ' vom ' + datum : ''));
|
||||
if(mail.betreff) kopf.push('Betreff: ' + mail.betreff);
|
||||
if(eintrag.zitat) kopf.push('Bezug: „' + eintrag.zitat + '“');
|
||||
return kopf.join('\n') + '\n\n' + String(mail.text || '').slice(0, 8000);
|
||||
}
|
||||
|
||||
function hinweisText(d){
|
||||
if(d.erkennungsart === 'chatgpt' || d.erkennungsart === 'claude'){
|
||||
return 'Vorschlag der KI aus der Mail — bitte prüfen und speichern.';
|
||||
}
|
||||
return 'Die KI war nicht verfügbar (' + (d.erkennungshinweis || 'unbekannter Fehler') + '). Termine bitte selbst eintragen.';
|
||||
}
|
||||
|
||||
function verarbeiten(d){
|
||||
const eintraege = Array.isArray(d.eintraege) ? d.eintraege : [];
|
||||
if(eintraege.length === 0){
|
||||
app.showToast('In der Mail steht nichts für den Terminplan.');
|
||||
return;
|
||||
}
|
||||
if(eintraege.length === 1){ nacheinanderOeffnen(d, eintraege); return; }
|
||||
auswahlZeigen(d, eintraege);
|
||||
}
|
||||
|
||||
function nacheinanderOeffnen(d, liste){
|
||||
const rest = liste.slice();
|
||||
const gesamt = rest.length;
|
||||
const naechster = () => {
|
||||
const e = rest.shift();
|
||||
if(!e){ renderPlanGantt(); return; }
|
||||
const nr = gesamt - rest.length;
|
||||
openTodoModal(null, {
|
||||
title: e.titel,
|
||||
start: e.start,
|
||||
end: e.ende,
|
||||
assignees: personenZuordnen(e.zustaendig),
|
||||
aufwand: e.aufwandStunden,
|
||||
notiz: notizText(d.mail || {}, e),
|
||||
mailHinweis: (gesamt > 1 ? `Vorschlag ${nr} von ${gesamt}. ` : '') + hinweisText(d),
|
||||
nachSchliessen: naechster
|
||||
});
|
||||
};
|
||||
naechster();
|
||||
}
|
||||
|
||||
// ---------- Mehrere Vorschläge: Auswahl ----------
|
||||
|
||||
function auswahlZeigen(d, eintraege){
|
||||
let bg = document.getElementById('mailAuswahlModal');
|
||||
if(!bg){
|
||||
bg = document.createElement('div');
|
||||
bg.id = 'mailAuswahlModal';
|
||||
bg.className = 'modal-bg';
|
||||
bg.style.display = 'none';
|
||||
bg.addEventListener('click', e => { if(e.target === bg) bg.style.display = 'none'; });
|
||||
document.body.appendChild(bg);
|
||||
}
|
||||
const zeitraum = e => {
|
||||
if(!e.start) return 'ohne Termin';
|
||||
const a = formatDateDE(parseDateISO(e.start)), b = formatDateDE(parseDateISO(e.ende));
|
||||
return a === b ? a : a + ' – ' + b;
|
||||
};
|
||||
bg.innerHTML = `<div class="modal mail-auswahl" role="dialog" aria-label="Einträge aus der Mail">
|
||||
<h2>${eintraege.length} Einträge in der Mail</h2>
|
||||
<p class="hint">${esc(hinweisText(d))} Die ausgewählten öffnen sich nacheinander im Aufgaben-Panel.</p>
|
||||
<div class="mail-auswahl-liste">${eintraege.map((e, i) => `
|
||||
<label class="mail-auswahl-zeile"><input type="checkbox" checked data-i="${i}">
|
||||
<span class="mail-auswahl-titel">${esc(e.titel)}</span>
|
||||
<span class="mail-auswahl-datum">${esc(zeitraum(e))}</span></label>`).join('')}</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" data-aktion="abbrechen">Abbrechen</button>
|
||||
<button type="button" data-aktion="weiter">Prüfen und anlegen</button>
|
||||
</div></div>`;
|
||||
bg.querySelector('[data-aktion="abbrechen"]').onclick = () => { bg.style.display = 'none'; };
|
||||
bg.querySelector('[data-aktion="weiter"]').onclick = () => {
|
||||
const gewaehlt = Array.from(bg.querySelectorAll('input[type="checkbox"]:checked')).map(cb => eintraege[Number(cb.dataset.i)]);
|
||||
bg.style.display = 'none';
|
||||
if(gewaehlt.length) nacheinanderOeffnen(d, gewaehlt);
|
||||
};
|
||||
bg.style.display = 'flex';
|
||||
}
|
||||
})();
|
||||
@@ -55,7 +55,7 @@
|
||||
if(positionSelect){
|
||||
const currentPosition = currentMs && currentMs.position ? currentMs.position : '';
|
||||
const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null;
|
||||
let opts = '<option value="">Oben in der Projektzeile</option>';
|
||||
let opts = '<option value="">Oben, eigene Zeile</option>';
|
||||
if(plan.todos && plan.todos.length > 0){
|
||||
plan.todos.forEach(t => {
|
||||
const beforeVal = 'before:' + t.id;
|
||||
@@ -69,6 +69,14 @@
|
||||
}
|
||||
positionSelect.innerHTML = opts;
|
||||
}
|
||||
window.msLageSync();
|
||||
|
||||
// Seitenpanel: Überschrift und "Löschen" nur beim Bearbeiten — vorher
|
||||
// stand "Löschen" auch beim Anlegen eines neuen Meilensteins.
|
||||
const msTitel = document.getElementById('milestoneModalTitle');
|
||||
if(msTitel) msTitel.textContent = isNew ? 'Neuer Meilenstein' : 'Meilenstein bearbeiten';
|
||||
const msLoeschen = document.getElementById('milestoneEditDelete');
|
||||
if(msLoeschen) msLoeschen.hidden = isNew;
|
||||
|
||||
const dependsOnList = document.getElementById('milestoneEditDependsOn');
|
||||
if(dependsOnList){
|
||||
@@ -110,6 +118,35 @@
|
||||
document.getElementById('milestoneEditName').focus();
|
||||
};
|
||||
|
||||
// msLageSync/msLageSetzen: "Wo im Terminplan" als Umschalter "In der
|
||||
// Projektzeile | Bei einer Aufgabe" über dem bisherigen Auswahlfeld
|
||||
// (#milestoneEditPosition, Wert '' = Projektzeile, sonst before:/after:
|
||||
// einer Aufgabe). Gespeichert wird weiter allein der Wert des Felds.
|
||||
window.msLageSync = function(){
|
||||
const sel = document.getElementById('milestoneEditPosition');
|
||||
const seg = document.getElementById('milestoneEditLage');
|
||||
if(!sel || !seg) return;
|
||||
const beiAufgabe = !!sel.value;
|
||||
const hatAufgaben = sel.options.length > 1;
|
||||
seg.querySelectorAll('button').forEach(b => {
|
||||
b.setAttribute('aria-pressed', String((b.dataset.lage === 'aufgabe') === beiAufgabe));
|
||||
if(b.dataset.lage === 'aufgabe') b.disabled = !hatAufgaben;
|
||||
});
|
||||
sel.hidden = !beiAufgabe;
|
||||
// Die Option "Oben, eigene Zeile" steht schon im Umschalter.
|
||||
if(sel.options[0] && sel.options[0].value === '') sel.options[0].hidden = true;
|
||||
};
|
||||
window.msLageSetzen = function(art){
|
||||
const sel = document.getElementById('milestoneEditPosition');
|
||||
if(!sel) return;
|
||||
if(art === 'zeile') sel.value = '';
|
||||
else if(!sel.value){
|
||||
const nachOptionen = Array.from(sel.options).filter(o => o.value.indexOf('after:') === 0);
|
||||
if(nachOptionen.length) sel.value = nachOptionen[nachOptionen.length - 1].value;
|
||||
}
|
||||
window.msLageSync();
|
||||
};
|
||||
|
||||
// closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den
|
||||
// Bearbeitungsindex zurück.
|
||||
window.closeMilestoneModal = function(){
|
||||
@@ -186,6 +223,9 @@
|
||||
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
|
||||
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
|
||||
let todoModalSnapshot = '';
|
||||
// todoNachSchliessen: Rückruf nach dem Schließen des Panels (gespeichert
|
||||
// oder verworfen) — "Termin aus Mail" öffnet damit den nächsten Vorschlag.
|
||||
let todoNachSchliessen = null;
|
||||
|
||||
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
|
||||
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
|
||||
@@ -358,6 +398,69 @@
|
||||
list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; });
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leistungsphase als Balken (Eriks Wunsch): "+ Neu" → "Leistungsphase"
|
||||
// öffnet dasselbe Panel im Phasen-Modus. Ein Balken ist NUR über das Feld
|
||||
// t.lph eine Phase (balkenPhase(), load.js) — nicht mehr über seinen Titel.
|
||||
// ---------------------------------------------------------------------------
|
||||
const LPH_NAMEN = {
|
||||
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
|
||||
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
|
||||
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', 'W+M': 'Wartung und Monitoring'
|
||||
};
|
||||
let todoPhasenModus = false;
|
||||
let todoPhaseAutoTitel = '';
|
||||
const phasenTitel = l => l ? (l + (LPH_NAMEN[l] ? ' ' + LPH_NAMEN[l] : '')) : '';
|
||||
|
||||
// addPlanPhase: "+ Neu" → "Leistungsphase".
|
||||
// Optional lph: diese Phase vorwählen (Bereich "Leistungsphasen", Kachel
|
||||
// ohne Zeitraum).
|
||||
window.addPlanPhase = function(lph){ openTodoModal(null, { phase: true, lph: typeof lph === 'string' ? lph : '' }); };
|
||||
|
||||
// todoPhaseGewechselt: Auswahl der Phase geändert — den vorgeschlagenen
|
||||
// Titel mitziehen, solange niemand ihn von Hand geändert hat.
|
||||
window.todoPhaseGewechselt = function(){
|
||||
const sel = document.getElementById('todoEditLph');
|
||||
const titel = document.getElementById('todoEditTitle');
|
||||
if(!sel || !titel) return;
|
||||
if(!titel.value.trim() || titel.value === todoPhaseAutoTitel){
|
||||
todoPhaseAutoTitel = phasenTitel(sel.value);
|
||||
titel.value = todoPhaseAutoTitel;
|
||||
}
|
||||
};
|
||||
|
||||
// todoPhasenModusSetzen: Panel für eine Leistungsphase herrichten (Auswahl
|
||||
// füllen, "Wer"/"Speichern & neu" ausblenden) oder zurück zur Aufgabe.
|
||||
function todoPhasenModusSetzen(an, todo, isNew, vorgabeLph){
|
||||
todoPhasenModus = an;
|
||||
const abs = document.getElementById('todoEditPhaseAbs');
|
||||
const wer = document.getElementById('todoEditWerAbs');
|
||||
const neu = document.getElementById('todoEditSaveNew');
|
||||
if(abs) abs.hidden = !an;
|
||||
if(wer) wer.hidden = an;
|
||||
if(neu) neu.hidden = an;
|
||||
const titelEl = document.getElementById('todoModalTitle');
|
||||
if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten');
|
||||
if(!an) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
const vergeben = new Set((plan.todos || []).filter(t => t !== todo && t.lph).map(t => t.lph));
|
||||
const beauftragt = Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : [];
|
||||
const sel = document.getElementById('todoEditLph');
|
||||
if(!sel) return;
|
||||
sel.innerHTML = LPHS.map(l => {
|
||||
const zusatz = vergeben.has(l) ? ' (schon im Plan)' : (beauftragt.length && !beauftragt.includes(l) ? ' (nicht beauftragt)' : '');
|
||||
return `<option value="${esc(l)}"${vergeben.has(l) ? ' disabled' : ''}>${esc(phasenTitel(l) + zusatz)}</option>`;
|
||||
}).join('');
|
||||
const frei = l => !vergeben.has(l);
|
||||
sel.value = (todo && todo.lph) || (vorgabeLph && frei(vorgabeLph) ? vorgabeLph : '') || LPHS.find(l => frei(l) && (!beauftragt.length || beauftragt.includes(l))) || LPHS.find(frei) || '';
|
||||
if(isNew){
|
||||
todoPhaseAutoTitel = phasenTitel(sel.value);
|
||||
document.getElementById('todoEditTitle').value = todoPhaseAutoTitel;
|
||||
} else {
|
||||
todoPhaseAutoTitel = '';
|
||||
}
|
||||
}
|
||||
|
||||
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
|
||||
// oder null für eine neue), prefill (optional, Vorbelegung für "Speichern &
|
||||
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
|
||||
@@ -380,7 +483,18 @@
|
||||
const titleEl = document.getElementById('todoModalTitle');
|
||||
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
|
||||
|
||||
document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || '');
|
||||
document.getElementById('todoEditTitle').value = isNew ? (pre.title || '') : (currentTodo.title || '');
|
||||
const notizEl = document.getElementById('todoEditNotiz');
|
||||
if(notizEl) notizEl.value = isNew ? (pre.notiz || '') : (currentTodo.notiz || '');
|
||||
// Hinweis über der Notiz, nur bei einem Vorschlag aus "Termin aus Mail".
|
||||
const mailHinweisEl = document.getElementById('todoEditMailHinweis');
|
||||
if(mailHinweisEl){
|
||||
mailHinweisEl.hidden = !(isNew && pre.mailHinweis);
|
||||
mailHinweisEl.textContent = (isNew && pre.mailHinweis) || '';
|
||||
}
|
||||
todoNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null;
|
||||
const saveNewBtn = document.getElementById('todoEditSaveNew');
|
||||
if(saveNewBtn) saveNewBtn.hidden = !!todoNachSchliessen;
|
||||
|
||||
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
|
||||
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
|
||||
@@ -404,6 +518,12 @@
|
||||
// Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()).
|
||||
const showInGanttEl = document.getElementById('todoEditShowInGantt');
|
||||
if(showInGanttEl) showInGanttEl.checked = isNew ? (pre.showInGantt !== undefined ? pre.showInGantt : true) : currentTodo.showInGantt !== false;
|
||||
const aufwandEl = document.getElementById('todoEditAufwand');
|
||||
if(aufwandEl){
|
||||
const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand);
|
||||
aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : '';
|
||||
}
|
||||
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
|
||||
|
||||
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
|
||||
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
|
||||
@@ -465,13 +585,25 @@
|
||||
uniquePeople.push(a.person);
|
||||
}
|
||||
});
|
||||
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
|
||||
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
|
||||
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
|
||||
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
|
||||
// gedrückten Chips liest.
|
||||
currentAssignees.forEach(id => {
|
||||
if(seenPeople.has(id)) return;
|
||||
seenPeople.add(id);
|
||||
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
|
||||
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
|
||||
});
|
||||
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
|
||||
// Personenspalten im Projektboard.
|
||||
assigneesDiv.innerHTML = uniquePeople.map(person => {
|
||||
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
|
||||
const short = person.name || initialsForName(person.fullName || '') || '?';
|
||||
const full = person.fullName || person.name || '';
|
||||
return `<button type="button" class="person-chip" data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
|
||||
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : '';
|
||||
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
|
||||
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
|
||||
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
|
||||
}
|
||||
@@ -512,8 +644,10 @@
|
||||
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
|
||||
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
|
||||
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
|
||||
const gantt = document.getElementById('todoEditShowInGantt');
|
||||
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
|
||||
currentTodoEditStatus(), read('todoEditProgress'), deps, people]);
|
||||
currentTodoEditStatus(), read('todoEditProgress'), deps, people,
|
||||
gantt ? gantt.checked : true, read('todoEditAufwand'), read('todoEditLph'), read('todoEditNotiz')]);
|
||||
}
|
||||
|
||||
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
|
||||
@@ -527,6 +661,9 @@
|
||||
document.getElementById('todoModal').style.display = 'none';
|
||||
app.currentTodoIndex = null;
|
||||
todoModalSnapshot = '';
|
||||
const danach = todoNachSchliessen;
|
||||
todoNachSchliessen = null;
|
||||
if(danach) setTimeout(danach, 0);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -573,12 +710,21 @@
|
||||
|
||||
const showInGanttEl = document.getElementById('todoEditShowInGantt');
|
||||
const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
|
||||
// Aufwand in Stunden (optional): zählt zur Auslastung der Zuständigen.
|
||||
const aufwandEl = document.getElementById('todoEditAufwand');
|
||||
const aufwand = aufwandEl ? Math.max(0, parseFloat(String(aufwandEl.value).replace(',', '.')) || 0) : 0;
|
||||
// Phasen-Modus: welche Leistungsphase dieser Balken ist.
|
||||
const lphSel = document.getElementById('todoEditLph');
|
||||
const lph = todoPhasenModus && lphSel ? lphSel.value : '';
|
||||
const notizEl = document.getElementById('todoEditNotiz');
|
||||
const notiz = notizEl ? notizEl.value.trim() : '';
|
||||
if(todoPhasenModus && !lph){ alert('Bitte eine Leistungsphase wählen.'); return; }
|
||||
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.todos)) plan.todos = [];
|
||||
|
||||
if(app.currentTodoIndex === null){
|
||||
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt });
|
||||
plan.todos.push(Object.assign({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt, aufwand, notiz }, lph ? { lph, assignees: [], aufwand: 0 } : {}));
|
||||
} else if(plan.todos[app.currentTodoIndex]){
|
||||
const t = plan.todos[app.currentTodoIndex];
|
||||
t.title = title;
|
||||
@@ -589,6 +735,9 @@
|
||||
t.dependsOn = dependsOn;
|
||||
t.assignees = assignees;
|
||||
t.showInGantt = showInGantt;
|
||||
t.aufwand = aufwand;
|
||||
t.notiz = notiz;
|
||||
if(todoPhasenModus) t.lph = lph;
|
||||
}
|
||||
app.savePlan();
|
||||
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
|
||||
@@ -633,7 +782,12 @@
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!plan.todos[idx]) return;
|
||||
plan.todos[idx].done = !plan.todos[idx].done;
|
||||
const t = plan.todos[idx];
|
||||
t.done = !t.done;
|
||||
// Fortschritt mitführen: Sonst blieb nach dem Zurücknehmen "100 %" stehen,
|
||||
// und der Dialog las daraus wieder "fertig" (todoStatusFrom()).
|
||||
if(t.done) t.progress = 100;
|
||||
else if(Number(t.progress) >= 100) t.progress = 0;
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// -----------------------------
|
||||
// Projektbereich — Leistungsphasen
|
||||
// -----------------------------
|
||||
// Bereich "Leistungsphasen" (Eriks Wahl "B"): die Phasenbalken des
|
||||
// Terminplans (Aufgaben mit t.lph, balkenPhase() in load.js) als eigener
|
||||
// Bereich statt als Zeilen zwischen den Aufgaben. Oben ein Ablaufbalken über
|
||||
// alle Phasen mit Heute-Linie, darunter eine Kachel je Phase: Fortschritt
|
||||
// (aus dem Wochenbericht, p.lph), Zeitraum (aus dem Balken), Aufwand
|
||||
// (Summe der Stunden ihrer Aufgaben) und die Aufgaben, die in ihrem
|
||||
// Zeitraum beginnen — dieselbe Zuordnung wie die Gliederung im Terminplan
|
||||
// (plan-build-gantt.js).
|
||||
//
|
||||
// Keine eigene Datenhaltung: Bearbeiten öffnet dasselbe Panel wie im
|
||||
// Terminplan (openTodoModal() im Phasen-Modus), Fortschritt bleibt im
|
||||
// Wochenbericht ("Ein Ort je Angabe").
|
||||
//
|
||||
// renderPlanPhasen() wird von renderPlanGantt() (plan-gantt.js) und beim
|
||||
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) aufgerufen.
|
||||
|
||||
// aufgabenPhase: zu welcher Leistungsphase eine (gewöhnliche) Aufgabe gehört —
|
||||
// der Phasenbalken, in dessen Zeitraum sie beginnt. Dieselbe Regel wie die
|
||||
// Gliederung im Terminplan. Parameter: t (Aufgabe), todos (alle Aufgaben des
|
||||
// Plans). Rückgabe: Phasenname ('LPH 5') oder ''.
|
||||
function aufgabenPhase(t, todos){
|
||||
if(!t || balkenPhase(t)) return '';
|
||||
const s0 = parseDateISO(t.start);
|
||||
if(!s0) return '';
|
||||
const k = (todos || [])
|
||||
.filter(b => balkenPhase(b) && b.start && b.end)
|
||||
.sort((a, b) => String(a.start).localeCompare(String(b.start)))
|
||||
.find(b => s0 >= parseDateISO(b.start) && s0 <= parseDateISO(b.end));
|
||||
return k ? balkenPhase(k) : '';
|
||||
}
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
const LPH_NAMEN = {
|
||||
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
|
||||
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
|
||||
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
|
||||
'W+M': 'Wartung und Monitoring'
|
||||
};
|
||||
|
||||
const kurzDatum = d => d ? d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }) : '';
|
||||
const langDatum = d => d ? formatDateDE(d) : '';
|
||||
|
||||
// phasenDaten: je Phase alles, was Ablaufbalken und Kachel brauchen.
|
||||
// Rückgabe: Array, sortiert läuft → geplant → fertig, darin nach Beginn.
|
||||
function phasenDaten(){
|
||||
const p = app.project;
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
|
||||
|
||||
// Phasen: beauftragte plus alle, die einen Balken haben.
|
||||
const namen = LPHS.filter(l => beauftragt.includes(l) || todos.some(t => balkenPhase(t) === l));
|
||||
const amtierend = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || null;
|
||||
const laufend = typeof arbeitsPhase === 'function' ? arbeitsPhase(p, amtierend) : amtierend;
|
||||
|
||||
return namen.map(l => {
|
||||
const idx = todos.findIndex(t => balkenPhase(t) === l);
|
||||
const balken = idx >= 0 ? todos[idx] : null;
|
||||
const start = balken ? parseDateISO(balken.start) : null;
|
||||
const ende = balken ? parseDateISO(balken.end) : null;
|
||||
const prozent = typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0;
|
||||
const aufgaben = todos.filter(t => aufgabenPhase(t, todos) === l);
|
||||
// Aufwand = Summe der Stunden ihrer Aufgaben (dieselbe Quelle wie die
|
||||
// Auslastung, js/load/load.js), nicht mehr aus dem Honorar.
|
||||
const stunden = Math.round(aufgaben.reduce((s, t) => s + (Number(t.aufwand) || 0), 0) * 10) / 10;
|
||||
const status = prozent >= 100 ? 'fertig' : (l === laufend ? 'laeuft' : 'geplant');
|
||||
return { l, idx, balken, start, ende, prozent, stunden, aufgaben, status,
|
||||
beauftragt: !beauftragt.length || beauftragt.includes(l) };
|
||||
}).sort((a, b) => {
|
||||
const rang = { laeuft: 0, geplant: 1, fertig: 2 };
|
||||
if(rang[a.status] !== rang[b.status]) return rang[a.status] - rang[b.status];
|
||||
return LPHS.indexOf(a.l) - LPHS.indexOf(b.l);
|
||||
});
|
||||
}
|
||||
|
||||
// ablaufHtml: der schmale Balken über alle Phasen mit Zeitraum. Phasen
|
||||
// ohne Zeitraum fehlen hier (sie stehen als Kachel mit Hinweis darunter).
|
||||
function ablaufHtml(daten){
|
||||
const mit = daten.filter(d => d.start && d.ende && d.ende >= d.start)
|
||||
.sort((a, b) => a.start - b.start);
|
||||
if(!mit.length) return '';
|
||||
const von = mit[0].start.getTime();
|
||||
const bis = Math.max(...mit.map(d => d.ende.getTime()));
|
||||
const spanne = Math.max(1, bis - von);
|
||||
const pos = t => Math.min(100, Math.max(0, (t - von) / spanne * 100));
|
||||
const heute = new Date(); heute.setHours(0, 0, 0, 0);
|
||||
const heutePos = heute.getTime() >= von && heute.getTime() <= bis ? pos(heute.getTime()) : null;
|
||||
const segmente = mit.map(d => {
|
||||
const links = pos(d.start.getTime());
|
||||
const breite = Math.max(1.5, pos(d.ende.getTime() + 86400000) - links);
|
||||
const titel = `${d.l}: ${langDatum(d.start)} – ${langDatum(d.ende)}, ${d.prozent} %`;
|
||||
return `<button type="button" class="phasen-ablauf-seg is-${d.status}" style="left:${links}%;width:${breite}%;--fort:${d.prozent}%"
|
||||
title="${esc(titel)}" onclick="phasenKachelZeigen('${esc(d.l)}')"><span>${esc(breite > 6 ? d.l : d.l.replace('LPH ', ''))}</span></button>`;
|
||||
}).join('');
|
||||
const heuteHtml = heutePos === null ? '' : `<span class="phasen-ablauf-heute" style="left:${heutePos}%" title="heute"></span>`;
|
||||
const monat = d => d.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' });
|
||||
return `<div class="plan-panel phasen-ablauf">
|
||||
<div class="phasen-kopf">Ablauf</div>
|
||||
<div class="phasen-ablauf-spur">${segmente}${heuteHtml}</div>
|
||||
<div class="phasen-ablauf-achse"><span>${esc(monat(mit[0].start))}</span>${heutePos === null ? '' : `<span class="phasen-ablauf-heute-text" style="left:${heutePos}%">heute, ${esc(kurzDatum(heute))}</span>`}<span>${esc(monat(new Date(bis)))}</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// kachelHtml: eine Kachel je Phase.
|
||||
function kachelHtml(d, locked){
|
||||
const statusText = { laeuft: 'läuft', geplant: 'geplant', fertig: 'fertig' }[d.status];
|
||||
const erledigt = d.aufgaben.filter(t => t.done).length;
|
||||
const aufgabenText = d.aufgaben.length ? `${erledigt} von ${d.aufgaben.length} erledigt` : 'keine';
|
||||
const zeitraum = d.start && d.ende
|
||||
? `${d.start.getFullYear() === d.ende.getFullYear() ? kurzDatum(d.start) : langDatum(d.start)} – ${langDatum(d.ende)}`
|
||||
: '<span class="phasen-leer">kein Zeitraum</span>';
|
||||
const bearbeiten = locked ? ''
|
||||
: (d.balken
|
||||
? `<button class="secondary small" onclick="openTodoModal(${d.idx})">Bearbeiten</button>`
|
||||
: `<button class="secondary small" onclick="addPlanPhase('${esc(d.l)}')">Zeitraum festlegen</button>`);
|
||||
const ansehen = d.aufgaben.length
|
||||
? `<button class="secondary small" onclick="phasenAufgabenZeigen('${esc(d.l)}')">Aufgaben ansehen</button>` : '';
|
||||
return `<div class="phasen-kachel is-${d.status}" data-phase="${esc(d.l)}">
|
||||
<div class="phasen-kachel-kopf">
|
||||
<div class="phasen-ring" style="--fort:${d.prozent}%"><span>${d.prozent} %</span></div>
|
||||
<div class="phasen-kachel-name">
|
||||
<b>${esc(d.l)}</b>
|
||||
<span>${esc(LPH_NAMEN[d.l] || '')}</span>
|
||||
</div>
|
||||
<span class="phasen-chip is-${d.status}">${statusText}</span>
|
||||
</div>
|
||||
${d.beauftragt ? '' : '<div class="phasen-hinweis">nicht beauftragt</div>'}
|
||||
<div class="phasen-werte">
|
||||
<span><small>Zeitraum</small><b>${zeitraum}</b></span>
|
||||
<span><small>Aufwand</small><b>${d.stunden ? d.stunden.toLocaleString('de-DE') + ' h' : '<span class="phasen-leer">keine Stunden</span>'}</b></span>
|
||||
<span><small>Aufgaben</small><b>${aufgabenText}</b></span>
|
||||
</div>
|
||||
${bearbeiten || ansehen ? `<div class="phasen-knoepfe">${bearbeiten}${ansehen}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderPlanPhasen: baut den Bereich neu auf. Keine Parameter.
|
||||
window.renderPlanPhasen = function(){
|
||||
const el = document.getElementById('planPhasenWrap');
|
||||
if(!el || !app.project) return;
|
||||
const locked = app.isLocked();
|
||||
const daten = phasenDaten();
|
||||
if(!daten.length){
|
||||
el.innerHTML = `<div class="plan-panel"><div class="plan-task-empty">${locked
|
||||
? 'Für dieses Projekt sind keine Leistungsphasen hinterlegt.'
|
||||
: 'Noch keine Leistungsphase. Unter „Einstellungen“ beauftragen oder über „+ Leistungsphase“ bzw. „Terminplan aus Honorar“ anlegen.'}</div></div>`;
|
||||
return;
|
||||
}
|
||||
const summeH = daten.reduce((s, d) => s + d.stunden, 0);
|
||||
const enden = daten.filter(d => d.ende).map(d => d.ende.getTime());
|
||||
const laufend = daten.find(d => d.status === 'laeuft');
|
||||
const teile = [daten.filter(d => d.beauftragt).length + ' beauftragt'];
|
||||
if(laufend) teile.push('läuft: ' + laufend.l);
|
||||
if(summeH) teile.push(summeH.toLocaleString('de-DE') + ' h in Aufgaben');
|
||||
if(enden.length) teile.push('Ende voraussichtlich ' + langDatum(new Date(Math.max(...enden))));
|
||||
el.innerHTML = `<div class="phasen-zusammenfassung">${esc(teile.join(' · '))}<span>Fortschritt aus dem Wochenbericht</span></div>
|
||||
${ablaufHtml(daten)}
|
||||
<div class="phasen-raster">${daten.map(d => kachelHtml(d, locked)).join('')}</div>`;
|
||||
};
|
||||
|
||||
// phasenKachelZeigen: Klick auf ein Segment im Ablauf — Kachel ins Bild
|
||||
// holen und kurz hervorheben.
|
||||
window.phasenKachelZeigen = function(l){
|
||||
const k = document.querySelector(`#planPhasenWrap .phasen-kachel[data-phase="${CSS.escape(l)}"]`);
|
||||
if(!k) return;
|
||||
k.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
k.classList.remove('ist-markiert'); void k.offsetWidth; k.classList.add('ist-markiert');
|
||||
};
|
||||
|
||||
// phasenAufgabenZeigen: in den Bereich Aufgaben, gefiltert auf diese Phase.
|
||||
window.phasenAufgabenZeigen = function(l){
|
||||
if(typeof setzeAufgabenFilter === 'function') setzeAufgabenFilter(l);
|
||||
showPlanSection('aufgaben');
|
||||
};
|
||||
})();
|
||||
@@ -177,7 +177,7 @@
|
||||
body.innerHTML = `
|
||||
<div class="todo-depends-list" id="protokollTodosPunkteListe" style="max-height:280px">
|
||||
${erkanntePunkte.map((p, i) => {
|
||||
const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : (p.status === 'offen');
|
||||
const checked = punkteAuswahl[i] !== undefined ? punkteAuswahl[i] : ((p.status || (p.erledigt ? 'erledigt' : 'offen')) === 'offen');
|
||||
punkteAuswahl[i] = checked;
|
||||
return `<label class="todo-depends-item">
|
||||
<input type="checkbox" value="${i}" ${checked ? 'checked' : ''}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Projektplan — Protokolle (Besprechungsprotokolle je Projekt)
|
||||
// -----------------------------
|
||||
// Bereich "Protokolle" des Projektbereichs (siehe js/plan/plan-sections.js)
|
||||
// plus eine Kompakt-Karte im Dashboard (#planProtokolleSummary, springt per
|
||||
// plus eine Kachel im Dashboard (#planDashProtokolle, springt per
|
||||
// Klick über openProtokollePanel() in diesen Bereich — gleiches Muster wie
|
||||
// #planFilesSummary/openFilesPanel() in plan-files.js).
|
||||
//
|
||||
@@ -77,28 +77,103 @@
|
||||
});
|
||||
}
|
||||
|
||||
// renderProtokolleSummary: füllt die Kompakt-Vorschau der Dashboard-Karte
|
||||
// (#planProtokolleSummary) — letztes Protokoll + Anzahl offener Punkte,
|
||||
// gleiches Muster wie renderFilesSummary() in plan-files.js.
|
||||
// renderProtokolleSummary: füllt die Dashboard-Kachel "Protokolle"
|
||||
// (#planDashProtokolle, Eriks Wunsch — vorher nur eine Zeile in
|
||||
// "Unterlagen"). Oben das letzte Protokoll, darunter dessen OFFENE Punkte
|
||||
// (die Frage nach einer Besprechung ist "was ist noch offen?"), unten die
|
||||
// früheren Protokolle als Kurzliste. Die Punkte kommen aus einem zweiten
|
||||
// Abruf (GET /project-protokolle/:id) — die Liste liefert nur Zahlen.
|
||||
const DASH_PROTOKOLL_PUNKTE = 5;
|
||||
const DASH_PROTOKOLL_FRUEHERE = 3;
|
||||
let dashProtokollAbruf = 0;
|
||||
|
||||
function datumDe(iso){
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : String(iso || '');
|
||||
}
|
||||
|
||||
function renderProtokolleSummary(){
|
||||
const el = document.getElementById('planProtokolleSummary');
|
||||
const el = document.getElementById('planDashProtokolle');
|
||||
const panel = document.getElementById('planDashProtokollePanel');
|
||||
if(!el) return;
|
||||
if(!app.project || !app.project.nr){
|
||||
el.innerHTML = '<span class="hint">Keine Projektnummer.</span>';
|
||||
return;
|
||||
}
|
||||
// Ohne Projektnummer (Planung-Terminplan) gibt es keine Protokolle —
|
||||
// die Kachel verschwindet wie der Bereich selbst.
|
||||
if(panel) panel.hidden = !(app.project && app.project.nr);
|
||||
if(!app.project || !app.project.nr){ el.innerHTML = ''; return; }
|
||||
if(protokolleCache.length === 0){
|
||||
el.innerHTML = '<span class="hint">Noch keine Protokolle.</span>';
|
||||
el.innerHTML = `<div class="plan-dash-leer">Noch keine Protokolle.
|
||||
<button type="button" class="plan-panel-link" onclick="dashNeuesProtokoll()">+ Neues Protokoll</button></div>`;
|
||||
return;
|
||||
}
|
||||
const letztes = protokolleCache[0]; // Server liefert nach nummer DESC
|
||||
const chip = letztes.offenCount > 0
|
||||
? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>`
|
||||
: '<span class="plan-mini-chip">Alle erledigt</span>';
|
||||
el.innerHTML = `<div class="plan-mini-card-preview">Nr. ${esc(letztes.nummer)} · ${esc(letztes.betreff || 'ohne Betreff')}</div>
|
||||
<div class="plan-mini-chip-row">${chip}</div>`;
|
||||
const frueher = protokolleCache.slice(1, 1 + DASH_PROTOKOLL_FRUEHERE);
|
||||
const zahlen = [
|
||||
letztes.offenCount ? `<span class="plan-mini-chip warn">${letztes.offenCount} offen</span>` : '<span class="plan-mini-chip">nichts offen</span>',
|
||||
letztes.erledigtCount ? `<span class="plan-mini-chip">${letztes.erledigtCount} erledigt</span>` : '',
|
||||
letztes.infoCount ? `<span class="plan-mini-chip">${letztes.infoCount} Info</span>` : ''
|
||||
].join('');
|
||||
el.innerHTML = `
|
||||
<button type="button" class="plan-prot-kopf" onclick="dashOeffneProtokoll('${esc(letztes.id)}')">
|
||||
<span class="plan-prot-nr">Nr. ${esc(letztes.nummer)}</span>
|
||||
<span class="plan-prot-titel"><b>${esc(letztes.betreff || 'ohne Betreff')}</b>
|
||||
<span>${esc(datumDe(letztes.datum))}${letztes.ort ? ' · ' + esc(letztes.ort) : ''}${letztes.teilnehmerCount ? ' · ' + letztes.teilnehmerCount + ' Teilnehmer' : ''}</span></span>
|
||||
</button>
|
||||
<div class="plan-mini-chip-row">${zahlen}</div>
|
||||
<div class="plan-prot-punkte" id="planDashProtokollPunkte">${letztes.offenCount ? '<span class="hint">Lade offene Punkte…</span>' : ''}</div>
|
||||
${frueher.length ? `<div class="plan-termin-gruppe">Frühere Protokolle</div>
|
||||
${frueher.map(pr => `<button type="button" class="plan-prot-frueher" onclick="dashOeffneProtokoll('${esc(pr.id)}')">
|
||||
<span>Nr. ${esc(pr.nummer)} · ${esc(datumDe(pr.datum))}</span>
|
||||
<span class="plan-prot-frueher-titel">${esc(pr.betreff || 'ohne Betreff')}</span>
|
||||
<span class="plan-prot-frueher-zahl">${pr.offenCount ? pr.offenCount + ' offen' : ''}</span>
|
||||
</button>`).join('')}` : ''}`;
|
||||
if(letztes.offenCount) ladeOffenePunkte(letztes.id);
|
||||
}
|
||||
|
||||
function ladeOffenePunkte(id){
|
||||
const nr = ++dashProtokollAbruf;
|
||||
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(id))
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
.then(d => {
|
||||
if(nr !== dashProtokollAbruf) return; // inzwischen anderes Projekt
|
||||
const ziel = document.getElementById('planDashProtokollPunkte');
|
||||
if(!ziel || !d.protokoll) return;
|
||||
const punkte = d.protokoll.punkte || [];
|
||||
const nummern = window.berechneProtokollNummern(punkte);
|
||||
const heute = typeof heuteIso === 'function' ? heuteIso() : new Date().toISOString().slice(0, 10);
|
||||
const offen = punkte.map((p, i) => Object.assign({}, p, { anzeigeNr: nummern[i] }))
|
||||
.filter(p => p.status === 'offen')
|
||||
// Überfälliges zuerst, dann nach Termin, ohne Termin zuletzt
|
||||
.sort((a, b) => (a.termin || '9999').localeCompare(b.termin || '9999'));
|
||||
const zeigen = offen.slice(0, DASH_PROTOKOLL_PUNKTE);
|
||||
const rest = offen.length - zeigen.length;
|
||||
ziel.innerHTML = zeigen.map(p => {
|
||||
const ueber = p.termin && p.termin < heute;
|
||||
const titel = p.thema || statusPlainText(p.text).slice(0, 80) || 'ohne Thema';
|
||||
return `<div class="plan-termin ist-klickbar" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">
|
||||
<span class="plan-termin-art">${esc(p.anzeigeNr)}</span>
|
||||
<span class="plan-termin-text"><b>${esc(titel)}</b><span>${p.gruppe ? esc(p.gruppe) + ' · ' : ''}${p.zustaendig ? esc(p.zustaendig) : 'ohne Zuständigen'}</span></span>
|
||||
<span class="plan-termin-datum${ueber ? ' ist-rot' : ''}">${p.termin ? esc(datumDe(p.termin)) + (typeof relativerTag === 'function' ? '<em>' + esc(relativerTag(p.termin)) + '</em>' : '') : '—'}</span>
|
||||
</div>`;
|
||||
}).join('') + (rest > 0 ? `<button type="button" class="plan-panel-link plan-prot-mehr" onclick="dashOeffneProtokoll('${esc(d.protokoll.id)}')">… ${rest} weitere offene Punkte</button>` : '');
|
||||
})
|
||||
.catch(err => {
|
||||
if(nr !== dashProtokollAbruf) return;
|
||||
const ziel = document.getElementById('planDashProtokollPunkte');
|
||||
if(ziel) ziel.innerHTML = '<span class="hint">Offene Punkte konnten nicht geladen werden.</span>';
|
||||
console.error('Offene Protokollpunkte laden fehlgeschlagen:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// dashOeffneProtokoll: aus der Kachel direkt in DIESES Protokoll.
|
||||
window.dashOeffneProtokoll = function(id){
|
||||
window.openProtokollePanel();
|
||||
window.oeffneProtokoll(id);
|
||||
};
|
||||
window.dashNeuesProtokoll = function(){
|
||||
window.openProtokollePanel();
|
||||
window.neuesProtokoll();
|
||||
};
|
||||
|
||||
// openProtokollePanel: springt in den Bereich "Protokolle" — Gegenstück zu
|
||||
// openFilesPanel()/openResourceModal().
|
||||
window.openProtokollePanel = function(){
|
||||
@@ -175,7 +250,7 @@
|
||||
// currentProtokoll.punkte), richtung (-1 = nach oben, +1 = nach unten).
|
||||
// Keine Rückgabe; rendert nur den Punkte-Block neu.
|
||||
window.verschiebePunkt = function(idx, richtung){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
const punkte = currentProtokoll.punkte;
|
||||
const gruppe = String(punkte[idx].gruppe || '').trim();
|
||||
const gleicheGruppeIdx = punkte.map((p, i) => i).filter(i => String(punkte[i].gruppe || '').trim() === gruppe);
|
||||
@@ -197,7 +272,7 @@
|
||||
// Parameter: gruppe (Name), richtung (-1 = nach vorn, +1 = nach hinten).
|
||||
// Keine Rückgabe.
|
||||
window.verschiebeGruppe = function(gruppe, richtung){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
const punkte = currentProtokoll.punkte;
|
||||
const namen = [];
|
||||
punkte.forEach(p => {
|
||||
@@ -230,7 +305,7 @@
|
||||
function renderProtokolleListe(){
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(!container) return;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.protokolleGesperrt();
|
||||
const anlegeBtns = locked ? '' : `<button onclick="neuesProtokoll()">+ Neues Protokoll</button>
|
||||
<button class="secondary" onclick="oeffneProtokollImportDialog()">Protokoll importieren</button>`;
|
||||
if(protokolleCache.length === 0){
|
||||
@@ -262,7 +337,7 @@
|
||||
// "anwesend" zurückgesetzt), sonst Fallback auf projectAssignees(), sonst
|
||||
// leer. Das eigentliche Fortschreiben der Punkte übernimmt der Server.
|
||||
window.neuesProtokoll = async function(){
|
||||
if(app.isLocked()) return;
|
||||
if(app.protokolleGesperrt()) return;
|
||||
let teilnehmer = [];
|
||||
try {
|
||||
if(protokolleCache.length > 0){
|
||||
@@ -317,7 +392,7 @@
|
||||
// firmenweiten Stand, und kann sich seit dem letzten Öffnen geändert
|
||||
// haben.
|
||||
window.oeffneProtokollImportDialog = function(){
|
||||
if(app.isLocked()) return;
|
||||
if(app.protokolleGesperrt()) return;
|
||||
document.getElementById('protokollImportFile').value = '';
|
||||
document.getElementById('protokollImportBetreff').value = '';
|
||||
document.getElementById('protokollImportOrt').value = '';
|
||||
@@ -460,7 +535,7 @@
|
||||
const container = document.getElementById('protokolleContainer');
|
||||
if(!container || !currentProtokoll) return;
|
||||
const p = currentProtokoll;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.protokolleGesperrt();
|
||||
|
||||
const datumDisplay = formatDateDE(parseDateISO(p.datum)) || '';
|
||||
const quelleHinweis = p.quelleDateiname
|
||||
@@ -634,7 +709,7 @@
|
||||
// aktualisiereListenZeile() oben.
|
||||
function renderPunkteBlockHtml(){
|
||||
const p = currentProtokoll;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.protokolleGesperrt();
|
||||
const gruppen = gruppenReihenfolge();
|
||||
const nummern = window.berechneProtokollNummern(p.punkte);
|
||||
|
||||
@@ -717,7 +792,7 @@
|
||||
// in currentProtokoll.punkte, -1 = kein Punkt vorhanden), nummern (das
|
||||
// Ergebnis von berechneProtokollNummern()). Rückgabe: HTML-String.
|
||||
function renderSchreibbereichHtml(idx, nummern){
|
||||
const locked = app.isLocked();
|
||||
const locked = app.protokolleGesperrt();
|
||||
if(idx < 0){
|
||||
return `<div class="protokoll-schreiben ist-leer">
|
||||
<p>Noch kein Punkt angelegt.</p>
|
||||
@@ -874,17 +949,17 @@
|
||||
// ---------- Strukturelle Änderungen (Zeile hinzufügen/entfernen) ----------
|
||||
|
||||
window.fuegeTeilnehmerHinzu = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
currentProtokoll.teilnehmer.push({ name: '', firma: '', anwesend: true });
|
||||
renderProtokollDetail();
|
||||
};
|
||||
window.entferneTeilnehmer = function(idx){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
currentProtokoll.teilnehmer.splice(idx, 1);
|
||||
renderProtokollDetail();
|
||||
};
|
||||
window.fuegePunktHinzu = function(gruppe){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
// nummer wird nicht gesetzt — sie ergibt sich beim Rendern automatisch
|
||||
// aus Gruppe und Position (siehe berechneProtokollNummern() oben).
|
||||
// erstelltAm nur als Vorschau bis zum nächsten Speichern — maßgeblich ist
|
||||
@@ -894,7 +969,19 @@
|
||||
thema: '', text: '', zustaendig: '', termin: '', status: 'offen',
|
||||
erstelltAm: currentProtokoll.datum || '', entfaelltNaechstes: false
|
||||
};
|
||||
currentProtokoll.punkte.push(neuerPunkt);
|
||||
// Hinter dem letzten Punkt DERSELBEN Gruppe einfügen statt ans Ende: Die
|
||||
// Nummern folgen der Reihenfolge im Array (berechneProtokollNummern()),
|
||||
// die Anzeige aber gruppiert. Ein ans Ende gehängter Punkt "ohne Gruppe"
|
||||
// bekam sonst eine Nummer hinter allen Gruppen (1–5, dann 7, darunter 6.1).
|
||||
// Gibt es noch keinen Punkt ohne Gruppe, kommt er an den Anfang — dieser
|
||||
// Abschnitt steht immer zuerst.
|
||||
const liste = currentProtokoll.punkte;
|
||||
const g = gruppe || '';
|
||||
let letzter = -1;
|
||||
liste.forEach((x, i) => { if((x.gruppe || '') === g) letzter = i; });
|
||||
if(letzter >= 0) liste.splice(letzter + 1, 0, neuerPunkt);
|
||||
else if(!g) liste.unshift(neuerPunkt);
|
||||
else liste.push(neuerPunkt);
|
||||
// Den neuen Punkt gleich im Schreibbereich öffnen und ins Thema-Feld
|
||||
// springen — sonst stünde er zwar links in der Liste, rechts bliebe
|
||||
// aber der vorige Punkt stehen und man müsste ihn erst anklicken.
|
||||
@@ -914,7 +1001,7 @@
|
||||
// (nicht mehr einem Feld am Punkt, siehe currentProtokollUmbenenneGruppe()
|
||||
// oben) fokussiert und markiert, damit er sich sofort überschreiben lässt.
|
||||
window.fuegeNeueGruppeHinzu = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
const bestehende = new Set(gruppenReihenfolge());
|
||||
let name = 'Neue Gruppe';
|
||||
let n = 2;
|
||||
@@ -927,7 +1014,7 @@
|
||||
if(input){ input.focus(); input.select(); }
|
||||
};
|
||||
window.entfernePunkt = function(idx){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
// War der gelöschte Punkt der geöffnete, rückt der an seine Stelle
|
||||
// getretene nach (bzw. der letzte, wenn es der letzte war) — sonst
|
||||
// stünde der Schreibbereich leer da, obwohl noch Punkte vorhanden sind.
|
||||
@@ -947,7 +1034,7 @@
|
||||
// Punkte komplett (siehe server.js). Ausdrückliches Speichern, kein
|
||||
// Auto-Save, wie die Board-Kommentare.
|
||||
window.speichereProtokoll = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
// Errechnete Nummern vor dem Senden in die Punkte schreiben, damit die
|
||||
// gespeicherten Werte mit der Anzeige übereinstimmen (maßgeblich ist beim
|
||||
// Anzeigen aber immer die Neuberechnung, nie dieser gespeicherte Wert —
|
||||
@@ -995,7 +1082,7 @@
|
||||
};
|
||||
|
||||
window.loescheProtokoll = function(){
|
||||
if(!currentProtokoll || app.isLocked()) return;
|
||||
if(!currentProtokoll || app.protokolleGesperrt()) return;
|
||||
if(!confirm('Protokoll Nr. ' + currentProtokoll.nummer + ' wirklich löschen?')) return;
|
||||
fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(currentProtokoll.id), { method: 'DELETE' })
|
||||
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Kritischer Pfad, Auto-Schedule & Mail-Link
|
||||
// Projektplan — Auto-Schedule & Mail-Link
|
||||
// -----------------------------
|
||||
// Dieses Modul berechnet über computeCriticalPath die Aufgabe(n) auf dem
|
||||
// kritischen Pfad (längste Vorgänger-Kette) und hebt sie im GANTT hervor,
|
||||
// verschiebt bei autoScheduleTodos TODOs und Meilensteine automatisch hinter
|
||||
// ihre Vorgänger und erstellt bei todoMail einen mailto-Link an alle
|
||||
// zugeordneten Personen. Die Hervorhebung übergibt renderPlanGantt als
|
||||
// Option criticalPath an buildGanttHtml (plan-build-gantt.js); den Schalter
|
||||
// criticalPathActive hält der Zustand in window.PlanApp (plan-state.js).
|
||||
// Dieses Modul verschiebt bei autoScheduleTodos TODOs und Meilensteine
|
||||
// automatisch hinter ihre Vorgänger (aufgerufen beim Ziehen eines Balkens,
|
||||
// plan-drag.js, und über "Auto-Schedule" im Bereich Aufgaben) und erstellt
|
||||
// bei todoMail einen mailto-Link an alle zugeordneten Personen. Die frühere
|
||||
// Hervorhebung des kritischen Pfads ist auf Eriks Wunsch entfallen.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
@@ -15,113 +13,7 @@
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// ---------- Kritischer Pfad & Auto-Schedule ----------
|
||||
|
||||
// computeCriticalPath: liefert Array der TODO-IDs, die auf einem kritischen Pfad liegen
|
||||
// (längste Kette von Vorgänger-Abhängigkeiten, End-to-Start)
|
||||
window.computeCriticalPath = function(project){
|
||||
if(!project || !project.plan) return [];
|
||||
const todos = (project.plan.todos || []).filter(t => t.start && t.end);
|
||||
const milestones = (project.plan.milestones || []).filter(m => m.date);
|
||||
if(todos.length === 0 && milestones.length === 0) return [];
|
||||
|
||||
// Einheitliche Knoten: TODOs und Meilensteine (Dauer 1)
|
||||
const nodes = todos.map(t => ({ type: 'todo', id: t.id, obj: t, start: parseDateISO(t.start), end: parseDateISO(t.end), duration: Math.max(1, daysBetween(parseDateISO(t.start), parseDateISO(t.end)) + 1) }));
|
||||
milestones.forEach(m => {
|
||||
const d = parseDateISO(m.date);
|
||||
nodes.push({ type: 'milestone', id: 'm_' + m.id, obj: m, start: d, end: d, duration: 1 });
|
||||
});
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
||||
|
||||
// depsOf: liefert die Vorgänger-Liste eines Knotens (leeres Array, falls
|
||||
// keine vorhanden).
|
||||
function depsOf(n){
|
||||
return Array.isArray(n.obj.dependsOn) ? n.obj.dependsOn : [];
|
||||
}
|
||||
|
||||
// Frühester Start (ES) und frühestes Ende (EF) berechnen.
|
||||
// visiting-Set schützt vor Endlos-Rekursion bei zyklischen Abhängigkeiten.
|
||||
const es = {}, ef = {};
|
||||
const esVisiting = new Set();
|
||||
// computeES: berechnet rekursiv den frühesten Start (ES) und frühestes
|
||||
// Ende (EF) eines Knotens aus seinen Vorgängern. Liefert ES oder null
|
||||
// bei Zyklen (visiting-Set).
|
||||
function computeES(n){
|
||||
if(es[n.id] !== undefined) return es[n.id];
|
||||
if(esVisiting.has(n.id)){ es[n.id] = null; ef[n.id] = null; return null; } // Zyklus
|
||||
if(!n.start || !n.end){ es[n.id] = null; ef[n.id] = null; return null; }
|
||||
esVisiting.add(n.id);
|
||||
const deps = depsOf(n);
|
||||
let maxEF = null;
|
||||
deps.forEach(depId => {
|
||||
const dep = nodeMap.get(depId);
|
||||
if(!dep) return;
|
||||
const depStart = computeES(dep);
|
||||
if(!depStart) return;
|
||||
const depEnd = addDays(depStart, dep.duration - 1);
|
||||
if(maxEF === null || depEnd > maxEF) maxEF = depEnd;
|
||||
});
|
||||
esVisiting.delete(n.id);
|
||||
es[n.id] = maxEF === null ? n.start : addDays(maxEF, 1);
|
||||
ef[n.id] = addDays(es[n.id], n.duration - 1);
|
||||
return es[n.id];
|
||||
}
|
||||
nodes.forEach(n => computeES(n));
|
||||
|
||||
const validNodes = nodes.filter(n => es[n.id]);
|
||||
if(validNodes.length === 0) return [];
|
||||
const maxEF = validNodes.reduce((max, n) => { const d = ef[n.id]; return !max || d > max ? d : max; }, null);
|
||||
|
||||
// Spätestes Ende (LF) und spätester Start (LS) rückwärts berechnen.
|
||||
// Wichtig: LF eines Knotens ist der Tag VOR dem spätesten Start (LS)
|
||||
// seines frühesten Nachfolgers – nicht vor dessen LF.
|
||||
const lf = {}, ls = {};
|
||||
const lfVisiting = new Set();
|
||||
// computeLF: berechnet rekursiv den spätesten Start (LS) und das späteste
|
||||
// Ende (LF) eines Knotens rückwärts aus seinen Nachfolgern. Liefert LF
|
||||
// oder null bei Zyklen (visiting-Set).
|
||||
function computeLF(n){
|
||||
if(lf[n.id] !== undefined) return lf[n.id];
|
||||
if(lfVisiting.has(n.id)){ lf[n.id] = null; ls[n.id] = null; return null; } // Zyklus
|
||||
if(!n.start || !n.end){ lf[n.id] = null; ls[n.id] = null; return null; }
|
||||
lfVisiting.add(n.id);
|
||||
const successors = validNodes.filter(succ => depsOf(succ).includes(n.id));
|
||||
let minLF = null;
|
||||
successors.forEach(succ => {
|
||||
if(!computeLF(succ)) return;
|
||||
const succLS = ls[succ.id];
|
||||
if(!succLS) return;
|
||||
const candidate = addDays(succLS, -1);
|
||||
if(minLF === null || candidate < minLF) minLF = candidate;
|
||||
});
|
||||
lfVisiting.delete(n.id);
|
||||
lf[n.id] = minLF === null ? maxEF : minLF;
|
||||
ls[n.id] = addDays(lf[n.id], -(n.duration - 1));
|
||||
return lf[n.id];
|
||||
}
|
||||
validNodes.forEach(n => computeLF(n));
|
||||
|
||||
return validNodes.filter(n => n.type === 'todo' && es[n.id] && ls[n.id] && daysBetween(es[n.id], ls[n.id]) === 0).map(n => n.id);
|
||||
};
|
||||
|
||||
// toggleCriticalPath: schaltet die Hervorhebung des kritischen Pfads im
|
||||
// GANTT ein/aus (Wert in app.criticalPathActive), aktualisiert den
|
||||
// Toolbar-Button und zeigt über einen Toast an, wie viele Aufgaben auf
|
||||
// dem kritischen Pfad liegen.
|
||||
window.toggleCriticalPath = function(){
|
||||
app.criticalPathActive = !app.criticalPathActive;
|
||||
const btn = document.getElementById('criticalPathBtn');
|
||||
if(btn) btn.classList.toggle('active', app.criticalPathActive);
|
||||
renderPlanGantt();
|
||||
if(app.criticalPathActive){
|
||||
const path = computeCriticalPath(app.project);
|
||||
if(path.length === 0){
|
||||
app.showToast('Kein kritischer Pfad ermittelt (zu wenig Aufgaben oder Abhängigkeiten).');
|
||||
} else {
|
||||
app.showToast(path.length + ' Aufgabe(n) auf dem kritischen Pfad hervorgehoben.');
|
||||
}
|
||||
}
|
||||
};
|
||||
// ---------- Auto-Schedule ----------
|
||||
|
||||
// autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von
|
||||
// anderen Elementen abhängen, automatisch auf den frühesten möglichen
|
||||
|
||||
+115
-43
@@ -36,7 +36,7 @@
|
||||
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
|
||||
// Navigationsleiste. Muss zu den data-section-Attributen in
|
||||
// App.html passen.
|
||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
|
||||
|
||||
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
|
||||
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
if(!app.project) return;
|
||||
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
|
||||
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
|
||||
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
|
||||
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
|
||||
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
|
||||
@@ -106,53 +107,88 @@
|
||||
if(nav) nav.style.display = '';
|
||||
}
|
||||
|
||||
// upcomingEntries: sammelt die nächsten Termine des Projekts — offene
|
||||
// Aufgaben mit Endtermin und Meilensteine mit Datum — in einer gemeinsamen,
|
||||
// chronologisch sortierten Liste. Überfällige Einträge stehen zuerst, damit
|
||||
// sie im Dashboard nicht untergehen. Rückgabe: Array von
|
||||
// { kind, title, date, overdue }.
|
||||
// upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute
|
||||
// (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als
|
||||
// gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit
|
||||
// ein Klick denselben Dialog öffnet wie im Terminplan.
|
||||
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
|
||||
function upcomingEntries(){
|
||||
const plan = app.ensurePlanLocal();
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const heute = heuteIso();
|
||||
const ich = currentUser && currentUser.personId;
|
||||
const namen = {};
|
||||
((app.week && app.week.people) || []).forEach(x => { namen[x.id] = x.fullName || x.name; });
|
||||
const rows = [];
|
||||
|
||||
(plan.todos || []).forEach(t => {
|
||||
if(t.done) return;
|
||||
const d = parseDateISO(t.end);
|
||||
if(!d) return;
|
||||
rows.push({ kind: 'Aufgabe', title: t.title || 'Aufgabe', date: d, overdue: d < today });
|
||||
(plan.todos || []).forEach((t, idx) => {
|
||||
if(t.done || !t.end) return;
|
||||
const wer = (t.assignees || []).map(id => namen[id] || id);
|
||||
rows.push({ art: 'aufgabe', idx, titel: t.title || 'Aufgabe', iso: t.end, wer,
|
||||
meins: !!ich && (t.assignees || []).includes(ich) });
|
||||
});
|
||||
(plan.milestones || []).forEach(m => {
|
||||
const d = parseDateISO(m.date);
|
||||
if(!d) return;
|
||||
rows.push({ kind: 'Meilenstein', title: m.name || 'Meilenstein', date: d, overdue: d < today });
|
||||
(plan.milestones || []).forEach((m, idx) => {
|
||||
if(!m.date || m.date < heute) return;
|
||||
rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false });
|
||||
});
|
||||
|
||||
rows.sort((a, b) => a.date - b.date);
|
||||
rows.sort((a, b) => a.iso.localeCompare(b.iso));
|
||||
return rows;
|
||||
}
|
||||
|
||||
// renderPlanDashboard: füllt die beiden eigenen Karten des Dashboards
|
||||
// ("Nächste Termine" und "Team"). Die übrigen Inhalte des Bereichs
|
||||
// stammen aus bereits vorhandenen Render-Funktionen: die KPI-Kacheln aus
|
||||
// renderPlanKpis() (plan-gantt.js), Kommentar und Leistungsphasen aus
|
||||
// renderPlanInfo() (plan-info.js), die Kompakt-Karten aus
|
||||
// renderResourceLoad()/renderProjectFiles()/renderPlanHistory().
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
// PLAN_DASH_ANTEIL: wie viele Einträge je Gruppe offen dastehen — jede
|
||||
// Gruppe bekommt ihren eigenen Anteil, sonst füllten 25 überfällige
|
||||
// Aufgaben die Liste und "Diese Woche" stand ohne Einträge da. Der Rest
|
||||
// steckt hinter "… weitere in Aufgaben".
|
||||
const PLAN_DASH_ANTEIL = { ueber: 4, woche: 4, spaeter: 3 };
|
||||
|
||||
// renderPlanDashboard: füllt "Termine und Aufgaben" (gegliedert nach
|
||||
// Überfällig / Diese Woche / Später) und "Team" (eine Zeile je PERSON mit
|
||||
// ihren Rollen als Chips — vorher stand jemand mit drei Rollen dreimal da).
|
||||
// Kennzahlenband, Kommentar, Leistungsphasen und Unterlagen füllen
|
||||
// renderPlanKpis()/renderPlanInfo()/renderResourceLoad()/
|
||||
// renderProjectFiles(). Keine Parameter.
|
||||
window.renderPlanDashboard = function(){
|
||||
if(!app.project) return;
|
||||
const locked = app.isLocked();
|
||||
|
||||
const upcomingEl = document.getElementById('planDashUpcoming');
|
||||
if(upcomingEl){
|
||||
const rows = upcomingEntries().slice(0, 8);
|
||||
upcomingEl.innerHTML = rows.length === 0
|
||||
? '<div class="plan-task-empty">Keine offenen Termine hinterlegt.</div>'
|
||||
: '<div class="plan-upcoming-list">' + rows.map(r => `<div class="plan-upcoming-item${r.overdue ? ' overdue' : ''}">
|
||||
<span class="plan-upcoming-date">${esc(formatDateDE(r.date))}</span>
|
||||
<span>${esc(r.title)}</span>
|
||||
<span class="plan-upcoming-kind">${esc(r.kind)}</span>
|
||||
</div>`).join('') + '</div>';
|
||||
const heute = heuteIso();
|
||||
const wocheEnde = isoPlusTage(7);
|
||||
const alle = upcomingEntries();
|
||||
if(alle.length === 0){
|
||||
upcomingEl.innerHTML = '<div class="plan-task-empty">Keine offenen Termine. Aufgaben und Meilensteine entstehen im Terminplan.</div>';
|
||||
} else {
|
||||
const gruppen = [
|
||||
{ key: 'ueber', titel: 'Überfällig', klasse: 'ist-rot', treffer: r => r.art === 'aufgabe' && r.iso < heute },
|
||||
{ key: 'woche', titel: 'Diese Woche', klasse: '', treffer: r => r.iso >= heute && r.iso <= wocheEnde },
|
||||
{ key: 'spaeter', titel: 'Später', klasse: '', treffer: r => r.iso > wocheEnde },
|
||||
];
|
||||
let gezeigt = 0;
|
||||
let html = '';
|
||||
gruppen.forEach(g => {
|
||||
const alleDerGruppe = alle.filter(g.treffer);
|
||||
const sichtbar = alleDerGruppe.slice(0, PLAN_DASH_ANTEIL[g.key]);
|
||||
gezeigt += sichtbar.length;
|
||||
if(!alleDerGruppe.length) return;
|
||||
html += `<div class="plan-termin-gruppe ${g.klasse}">${esc(g.titel)}<span>${alleDerGruppe.length}</span></div>`;
|
||||
html += sichtbar.map(r => {
|
||||
const klick = locked ? '' : ` onclick="${r.art === 'meilenstein' ? 'openMilestoneModal' : 'openTodoModal'}(${r.idx})"`;
|
||||
const zeichen = r.art === 'meilenstein' ? '◆' : (r.meins ? 'A' : 'a');
|
||||
const klasse = r.art === 'meilenstein' ? 'ist-ms' : (r.meins ? 'ist-meins' : '');
|
||||
const wer = r.art === 'meilenstein' ? 'Meilenstein' : (r.wer.length ? r.wer.join(', ') : 'niemand zugewiesen');
|
||||
const rot = r.art === 'aufgabe' && r.iso < heute;
|
||||
return `<div class="plan-termin${locked ? '' : ' ist-klickbar'}"${klick}>
|
||||
<span class="plan-termin-art ${klasse}">${zeichen}</span>
|
||||
<span class="plan-termin-text"><b>${esc(r.titel)}</b><span>${esc(wer)}</span></span>
|
||||
<span class="plan-termin-datum${rot ? ' ist-rot' : ''}">${esc(formatDateDE(parseDateISO(r.iso)))}<em>${esc(relativerTag(r.iso))}</em></span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
});
|
||||
const verborgen = alle.length - gezeigt;
|
||||
if(verborgen > 0){
|
||||
html += `<button type="button" class="plan-panel-link plan-termin-mehr" onclick="showPlanSection('aufgaben')">${verborgen} weitere in „Aufgaben“</button>`;
|
||||
}
|
||||
upcomingEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
const teamEl = document.getElementById('planDashTeam');
|
||||
@@ -162,14 +198,50 @@
|
||||
teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>';
|
||||
} else {
|
||||
const plan = app.ensurePlanLocal();
|
||||
teamEl.innerHTML = '<div class="plan-dash-team">' + assignees.map(a => {
|
||||
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(a.person.id) !== -1).length;
|
||||
const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : '');
|
||||
return `<div class="plan-dash-team-row">
|
||||
<span class="board-avatar ${app.planRoleClass(a.role)} state-${a.state}" title="${esc(title)}">${esc(a.person.name)}</span>
|
||||
<span>${esc(a.person.fullName || a.person.name)}</span>
|
||||
<span class="plan-upcoming-kind">${esc(a.role || '')}</span>
|
||||
<span class="plan-task-meta" style="margin-left:auto">${offen} offen</span>
|
||||
const jePerson = new Map();
|
||||
assignees.forEach(a => {
|
||||
if(!jePerson.has(a.person.id)) jePerson.set(a.person.id, { person: a.person, rollen: [] });
|
||||
jePerson.get(a.person.id).rollen.push({ rolle: a.role, state: a.state });
|
||||
});
|
||||
const zeile = e => {
|
||||
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(e.person.id) !== -1).length;
|
||||
const haupt = e.rollen.find(r => r.state === 1) || e.rollen[0];
|
||||
return `<div class="plan-team-zeile">
|
||||
<span class="board-avatar ${app.planRoleClass(haupt.rolle)} state-${haupt.state}">${esc(e.person.name)}</span>
|
||||
<span class="plan-team-text"><b>${esc(e.person.fullName || e.person.name)}</b>
|
||||
<span class="plan-team-rollen">${e.rollen.map(r => `<span class="plan-rolle${r.state === 2 ? ' ist-neben' : ''}" title="${r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung'}">${esc(r.rolle || '')}</span>`).join('')}</span></span>
|
||||
<span class="plan-team-offen">${offen} offen</span>
|
||||
</div>`;
|
||||
};
|
||||
// Nach Gewerk gruppiert (Eriks Wahl "A"): erst die Gewerke des
|
||||
// Projekts in fester Reihenfolge — auch unbesetzte, mit Hinweis —,
|
||||
// dann Gewerke von Beteiligten, die nicht zum Projekt gehören, zuletzt
|
||||
// Personen ohne bekanntes Gewerk. Das Gewerk einer Person kommt aus
|
||||
// ihrem Konto (personGewerk(), users.department).
|
||||
const reihenfolge = Object.keys(DEPARTMENTS);
|
||||
const projGewerke = reihenfolge.filter(g => (app.project.gewerke || []).indexOf(g) !== -1);
|
||||
const gruppen = new Map(projGewerke.map(g => [g, []]));
|
||||
[...jePerson.values()].forEach(e => {
|
||||
const g = personGewerk(e.person) || '';
|
||||
if(!gruppen.has(g)) gruppen.set(g, []);
|
||||
gruppen.get(g).push(e);
|
||||
});
|
||||
const schluessel = [...gruppen.keys()].sort((x, y) => {
|
||||
const rang = g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g));
|
||||
return rang(x) - rang(y);
|
||||
});
|
||||
teamEl.innerHTML = '<div class="plan-team">' + schluessel.map(g => {
|
||||
const leute = gruppen.get(g);
|
||||
const fremd = g !== '' && projGewerke.indexOf(g) === -1;
|
||||
const titel = g === '' ? 'Ohne Gewerk' : ((DEPARTMENTS[g] && DEPARTMENTS[g].label !== g) ? DEPARTMENTS[g].label : '');
|
||||
return `<div class="plan-team-gruppe">
|
||||
<div class="plan-team-gewerk">
|
||||
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
|
||||
<span class="plan-gewerk-titel">${esc(titel)}${fremd ? (titel ? ' · ' : '') + 'kein Gewerk des Projekts' : ''}</span>
|
||||
<span class="plan-gewerk-linie"></span>
|
||||
<span class="plan-gewerk-anzahl">${leute.length === 1 ? '1 Person' : leute.length + ' Personen'}</span>
|
||||
</div>
|
||||
${leute.length ? leute.map(zeile).join('') : '<div class="plan-team-luecke">Noch niemand aus diesem Gewerk zugeordnet.</div>'}
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
+311
-89
@@ -51,10 +51,285 @@
|
||||
// Wechsel in den Bereich (showPlanSection(), plan-sections.js) und nach
|
||||
// jeder Änderung an Honorar oder beauftragten Leistungsphasen aufgerufen.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
// HOAI-Bezeichnungen der Leistungsphasen für die Zeilen im Abschnitt
|
||||
// "Leistungsphasen" (nur Anzeige).
|
||||
const LPH_BEZEICHNUNG = {
|
||||
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
|
||||
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
|
||||
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung'
|
||||
};
|
||||
|
||||
// Gewählter Abschnitt der Einstellungen (Allgemein/Auftrag/Phasen/Team).
|
||||
let einstTeil = 'allgemein';
|
||||
window.zeigeEinstTeil = function(teil){
|
||||
einstTeil = teil;
|
||||
document.querySelectorAll('#planSection-einstellungen .plan-einst-reiter').forEach(b => {
|
||||
const an = b.dataset.teil === teil;
|
||||
b.classList.toggle('is-active', an);
|
||||
b.setAttribute('aria-current', an ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('#planSection-einstellungen .plan-einst-teil').forEach(t => {
|
||||
t.hidden = t.dataset.teil !== teil;
|
||||
});
|
||||
};
|
||||
|
||||
// einstTeamPersonen: alle Personen, die im Abschnitt "Beteiligte" vorkommen
|
||||
// können — jede Person der Sitzung mit mindestens einer Fachrolle
|
||||
// (peopleByRole()), je mit den Rollen, die sie hat. Dazu, wer dem Projekt
|
||||
// zugeordnet ist, obwohl sein Konto die Rolle inzwischen nicht mehr trägt —
|
||||
// sonst wäre so jemand unsichtbar und ließe sich nicht mehr entfernen.
|
||||
// Rückgabe: Array {person, rollen:Set}.
|
||||
function einstTeamPersonen(){
|
||||
const grouped = peopleByRole(app.week);
|
||||
const map = new Map();
|
||||
ROLES.forEach(r => (grouped[r] || []).forEach(entry => {
|
||||
if(!map.has(entry.person.id)) map.set(entry.person.id, { person: entry.person, rollen: new Set() });
|
||||
map.get(entry.person.id).rollen.add(r);
|
||||
}));
|
||||
const p = app.project;
|
||||
const leute = (app.week && app.week.people) || [];
|
||||
Object.keys((p && p.assign) || {}).forEach(key => {
|
||||
if(!(p.assign[key] > 0)) return;
|
||||
const [id, r] = key.split('|');
|
||||
if(map.has(id)) { map.get(id).rollen.add(r); return; }
|
||||
const person = leute.find(x => x.id === id);
|
||||
if(person) map.set(id, { person, rollen: new Set([r]) });
|
||||
});
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
const einstName = person => person.fullName || person.name || '';
|
||||
const einstNachname = person => einstName(person).split(' ').slice(-1)[0];
|
||||
const einstBeteiligt = (p, id) => ROLES.some(r => (p.assign[assignKey(id, r)] || 0) > 0);
|
||||
function einstGewerkRang(p){
|
||||
const reihenfolge = Object.keys(DEPARTMENTS);
|
||||
const projGewerke = reihenfolge.filter(g => (p.gewerke || []).indexOf(g) !== -1);
|
||||
return { projGewerke, rang: g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g)) };
|
||||
}
|
||||
|
||||
// Zustand des Abschnitts "Beteiligte": zuletzt hinzugefügte Person (kurz
|
||||
// hervorgehoben) und die zuletzt entfernte samt ihrer Zuordnung, damit
|
||||
// "Rückgängig" sie wiederherstellen kann.
|
||||
let einstNeu = '';
|
||||
let einstEntfernt = null;
|
||||
let einstToastTimer = null;
|
||||
|
||||
// renderEinstTeam: Besetzungsleiste, Sperrhinweis, Suchfeld und die
|
||||
// Personenliste nach Gewerk (Eriks Wahl "B"). Keine Rückgabe.
|
||||
function renderEinstTeam(p, locked){
|
||||
if(!p.assign) p.assign = {};
|
||||
const personen = einstTeamPersonen();
|
||||
const { projGewerke, rang } = einstGewerkRang(p);
|
||||
|
||||
const sperre = document.getElementById('planEinstSperre');
|
||||
if(sperre) sperre.hidden = !locked;
|
||||
const sucheWrap = document.getElementById('planEinstSucheWrap');
|
||||
if(sucheWrap) sucheWrap.hidden = locked;
|
||||
|
||||
// Besetzungsleiste: je Rolle, wer die Hauptverantwortung trägt.
|
||||
const besetzung = document.getElementById('planEinstBesetzung');
|
||||
if(besetzung){
|
||||
besetzung.innerHTML = ROLES.map(r => {
|
||||
const haupt = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 1);
|
||||
const neben = personen.filter(e => p.assign[assignKey(e.person.id, r)] === 2);
|
||||
const fehlt = haupt.length === 0;
|
||||
const text = fehlt
|
||||
? (neben.length ? 'nur Unterstützung, Haupt fehlt' : 'niemand zugeordnet')
|
||||
: haupt.map(e => einstNachname(e.person)).join(', ') + (neben.length ? ' + ' + neben.length + ' Unterst.' : '');
|
||||
return `<div class="plan-einst-besetzt${fehlt ? ' ist-luecke' : ''}"><b>${esc(r)}</b><span>${esc(text)}</span></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const el = document.getElementById('planAssignMatrix');
|
||||
if(!el) return;
|
||||
const drin = personen.filter(e => einstBeteiligt(p, e.person.id));
|
||||
if(!drin.length){
|
||||
el.innerHTML = personen.length
|
||||
? `<div class="plan-einst-leer"><b>Noch niemand zugeordnet</b><span>Über das Suchfeld Personen hinzufügen. Vorgeschlagen werden zuerst die Mitarbeiter aus den Gewerken des Projekts.</span></div>`
|
||||
: '<div class="plan-einst-leer"><b>Niemand verfügbar</b><span>In dieser Sitzung ist niemand mit einer Fachrolle hinterlegt (Admin → Benutzerverwaltung).</span></div>';
|
||||
} else {
|
||||
const gruppen = new Map();
|
||||
drin.forEach(e => {
|
||||
const g = personGewerk(e.person) || '';
|
||||
if(!gruppen.has(g)) gruppen.set(g, []);
|
||||
gruppen.get(g).push(e);
|
||||
});
|
||||
el.innerHTML = [...gruppen.keys()].sort((x, y) => rang(x) - rang(y)).map(g => {
|
||||
const liste = gruppen.get(g).sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
|
||||
const titel = g === '' ? 'Ohne Gewerk' : ((typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[g]) || '')
|
||||
+ (projGewerke.indexOf(g) === -1 ? ', kein Gewerk des Projekts' : '');
|
||||
return `<div class="plan-einst-gruppe">
|
||||
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
|
||||
<span class="plan-gewerk-titel">${esc(titel)}</span>
|
||||
<span class="plan-gewerk-linie"></span>
|
||||
<span class="plan-gewerk-anzahl">${liste.length === 1 ? '1 Person' : liste.length + ' Personen'}</span>
|
||||
</div>` + liste.map(e => {
|
||||
const id = e.person.id;
|
||||
const name = einstName(e.person);
|
||||
const kapseln = ROLES.map(r => {
|
||||
const hat = e.rollen.has(r);
|
||||
const st = p.assign[assignKey(id, r)] || 0;
|
||||
const art = st === 1 ? 'ist-haupt' : (st === 2 ? 'ist-neben' : '');
|
||||
const titel = !hat ? `${name} hat die Rolle ${r} nicht` : `${r}: ${st === 1 ? 'Hauptverantwortung' : st === 2 ? 'Unterstützung' : 'nicht beteiligt'}`;
|
||||
return `<button type="button" class="plan-einst-kapsel ${art}${hat ? '' : ' ist-fremd'}" aria-pressed="${st > 0}"
|
||||
${!hat || locked ? 'disabled' : ''} title="${esc(titel)}"
|
||||
onclick="planCycleAssign('${esc(id)}','${esc(r)}')">${esc(r)}</button>`;
|
||||
}).join('');
|
||||
return `<div class="plan-einst-zeile${einstNeu === id ? ' ist-neu' : ''}">
|
||||
<span class="plan-einst-person"><span class="plan-einst-ava">${esc(e.person.name)}</span><b>${esc(name)}</b></span>
|
||||
<span class="plan-einst-kapseln" role="group" aria-label="Rollen von ${esc(name)}">${kapseln}</span>
|
||||
${locked ? '' : `<button type="button" class="plan-einst-weg" title="Aus dem Projekt entfernen" aria-label="${esc(name)} aus dem Projekt entfernen" onclick="einstEntfernen('${esc(id)}')">×</button>`}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const toast = document.getElementById('planEinstToast');
|
||||
if(toast){
|
||||
toast.hidden = !einstEntfernt || locked;
|
||||
toast.innerHTML = einstEntfernt
|
||||
? `<span>${esc(einstEntfernt.name)} aus dem Projekt entfernt</span><button type="button" onclick="einstRueckgaengig()">Rückgängig</button>` : '';
|
||||
}
|
||||
}
|
||||
|
||||
// einstNachAenderung: speichern und alles nachziehen, was die Zuordnung
|
||||
// zeigt (dieselben Wege wie planCycleAssign()).
|
||||
function einstNachAenderung(){
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
if(typeof renderBoard === 'function') renderBoard();
|
||||
if(typeof renderWochenbericht === 'function') renderWochenbericht();
|
||||
}
|
||||
|
||||
// einstEntfernen: nimmt eine Person mit allen Rollen aus dem Projekt und
|
||||
// merkt sich die Zuordnung für "Rückgängig" (8 Sekunden).
|
||||
window.einstEntfernen = function(personId){
|
||||
const p = app.project;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
const eintrag = {};
|
||||
ROLES.forEach(r => {
|
||||
const key = assignKey(personId, r);
|
||||
if(p.assign[key]) { eintrag[key] = p.assign[key]; delete p.assign[key]; }
|
||||
});
|
||||
const e = einstTeamPersonen().find(x => x.person.id === personId);
|
||||
const person = e ? e.person : ((app.week.people || []).find(x => x.id === personId) || { name: personId });
|
||||
einstEntfernt = { name: einstName(person), eintrag };
|
||||
einstNeu = '';
|
||||
clearTimeout(einstToastTimer);
|
||||
einstToastTimer = setTimeout(() => { einstEntfernt = null; const t = document.getElementById('planEinstToast'); if(t) t.hidden = true; }, 8000);
|
||||
einstNachAenderung();
|
||||
};
|
||||
|
||||
window.einstRueckgaengig = function(){
|
||||
const p = app.project;
|
||||
if(!p || !einstEntfernt || app.stammGesperrt()) return;
|
||||
Object.assign(p.assign, einstEntfernt.eintrag);
|
||||
const key = Object.keys(einstEntfernt.eintrag)[0];
|
||||
einstNeu = key ? key.split('|')[0] : '';
|
||||
einstEntfernt = null;
|
||||
clearTimeout(einstToastTimer);
|
||||
einstNachAenderung();
|
||||
};
|
||||
|
||||
// Suchfeld "Person hinzufügen": Vorschläge aus allen Personen mit
|
||||
// Fachrolle, die noch nicht beteiligt sind — erst die Gewerke des
|
||||
// Projekts, dann die übrigen. Gerendert wird nur die Vorschlagsliste,
|
||||
// damit das Eingabefeld beim Tippen seinen Fokus behält.
|
||||
function einstVorschlaege(){
|
||||
const p = app.project;
|
||||
const feld = document.getElementById('planEinstSuche');
|
||||
const s = (feld ? feld.value : '').trim().toLowerCase();
|
||||
const { projGewerke } = einstGewerkRang(p);
|
||||
const frei = einstTeamPersonen()
|
||||
.filter(e => !einstBeteiligt(p, e.person.id))
|
||||
.filter(e => !s || einstName(e.person).toLowerCase().indexOf(s) !== -1 || String(e.person.name || '').toLowerCase().indexOf(s) !== -1)
|
||||
.sort((a, b) => einstName(a.person).localeCompare(einstName(b.person), 'de'));
|
||||
const eigen = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) !== -1);
|
||||
const andere = frei.filter(e => projGewerke.indexOf(personGewerk(e.person)) === -1);
|
||||
return [['Aus den Gewerken des Projekts', eigen], ['Andere Gewerke', andere]].filter(([, l]) => l.length);
|
||||
}
|
||||
|
||||
window.einstSucheZeigen = function(){
|
||||
const box = document.getElementById('planEinstVorschlaege');
|
||||
if(!box || !app.project || app.stammGesperrt()) return;
|
||||
const gruppen = einstVorschlaege();
|
||||
if(!gruppen.length){
|
||||
const feld = document.getElementById('planEinstSuche');
|
||||
box.innerHTML = feld && feld.value.trim() ? '<div class="plan-einst-vorschlag-leer">Niemand gefunden.</div>' : '';
|
||||
box.hidden = !box.innerHTML;
|
||||
return;
|
||||
}
|
||||
box.innerHTML = gruppen.map(([titel, liste]) => `<div class="plan-einst-vorschlag-titel">${esc(titel)}</div>`
|
||||
+ liste.map(e => `<button type="button" class="plan-einst-vorschlag" onmousedown="event.preventDefault()"
|
||||
onclick="einstHinzufuegen('${esc(e.person.id)}')">
|
||||
<span class="plan-einst-ava">${esc(e.person.name)}</span>
|
||||
<span><b>${esc(einstName(e.person))}</b><span>${esc([personGewerk(e.person), ROLES.filter(r => e.rollen.has(r)).join(', ')].filter(Boolean).join(', '))}</span></span>
|
||||
</button>`).join('')).join('');
|
||||
box.hidden = false;
|
||||
};
|
||||
|
||||
window.einstSucheSchliessen = function(){
|
||||
setTimeout(() => { const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; }, 120);
|
||||
};
|
||||
|
||||
// Enter übernimmt den ersten Vorschlag, Esc schließt die Liste.
|
||||
window.einstSucheTaste = function(event){
|
||||
if(event.key === 'Escape'){ const box = document.getElementById('planEinstVorschlaege'); if(box) box.hidden = true; return; }
|
||||
if(event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
const erste = einstVorschlaege()[0];
|
||||
if(erste && erste[1][0]) window.einstHinzufuegen(erste[1][0].person.id);
|
||||
};
|
||||
|
||||
// einstHinzufuegen: nimmt eine Person mit ihrer ersten Fachrolle als
|
||||
// Hauptverantwortung auf; weitere Rollen setzt man über die Kapseln.
|
||||
window.einstHinzufuegen = function(personId){
|
||||
const p = app.project;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
const e = einstTeamPersonen().find(x => x.person.id === personId);
|
||||
if(!e) return;
|
||||
const rolle = ROLES.find(r => e.rollen.has(r));
|
||||
if(!rolle) return;
|
||||
if(!p.assign) p.assign = {};
|
||||
p.assign[assignKey(personId, rolle)] = 1;
|
||||
einstNeu = personId;
|
||||
einstEntfernt = null;
|
||||
const feld = document.getElementById('planEinstSuche');
|
||||
if(feld) feld.value = '';
|
||||
const box = document.getElementById('planEinstVorschlaege');
|
||||
if(box) box.hidden = true;
|
||||
einstNachAenderung();
|
||||
};
|
||||
|
||||
// renderEinstUebersicht: die Kurzfassung unter jedem Abschnitt der
|
||||
// Unternavigation und welcher Abschnitt sichtbar ist.
|
||||
function renderEinstUebersicht(){
|
||||
const p = app.project;
|
||||
if(!p) return;
|
||||
const sandbox = app.isSandboxPlan();
|
||||
const setze = (id, text) => { const el = document.getElementById(id); if(el) el.textContent = text; };
|
||||
const gewerke = Array.isArray(p.gewerke) ? p.gewerke : [];
|
||||
const amtierend = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? p.aktuelleLP[0] : '';
|
||||
setze('einstSumAllgemein', sandbox ? [p.name || '', amtierend].filter(Boolean).join(' · ')
|
||||
: [p.nr || 'ohne Nummer', gewerke.join(', ') || 'kein Gewerk', amtierend].filter(Boolean).join(' · '));
|
||||
const honorar = Number(p.honorar) || 0;
|
||||
const honorarText = honorar > 0 ? honorar.toLocaleString('de-DE') + ' €' : 'kein Honorar';
|
||||
const status = (typeof AUFTRAGSSTATUS !== 'undefined' && typeof auftragsstatusOf === 'function')
|
||||
? (AUFTRAGSSTATUS.find(s => s.key === auftragsstatusOf(p)) || {}).label : '';
|
||||
setze('einstSumAuftrag', sandbox ? honorarText : [status, honorarText].filter(Boolean).join(' · '));
|
||||
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP.length : 0;
|
||||
const laeuft = Array.isArray(p.aktuelleLP) && p.aktuelleLP[0] ? ' · läuft: ' + p.aktuelleLP[0] : '';
|
||||
setze('einstSumPhasen', `${beauftragt} von ${LPHS.length} beauftragt${laeuft}`);
|
||||
const ids = new Set(Object.keys(p.assign || {}).filter(k => p.assign[k] > 0).map(k => k.split('|')[0]));
|
||||
const unbesetzt = gewerke.filter(g => !einstTeamPersonen().some(e => ids.has(e.person.id) && personGewerk(e.person) === g));
|
||||
setze('einstSumTeam', (ids.size === 1 ? '1 Person' : ids.size + ' Personen') + (unbesetzt.length ? ' · ' + unbesetzt.join(', ') + ' unbesetzt' : ''));
|
||||
window.zeigeEinstTeil(einstTeil);
|
||||
}
|
||||
|
||||
window.renderPlanSettings = function(){
|
||||
const p = app.project;
|
||||
if(!p) return;
|
||||
const locked = app.isLocked();
|
||||
const locked = app.stammGesperrt();
|
||||
const sandbox = app.isSandboxPlan();
|
||||
|
||||
const lockHint = document.getElementById('planSettingsLockHint');
|
||||
@@ -62,16 +337,20 @@
|
||||
|
||||
// Felder, die nur bei einem echten Projekt Bedeutung haben (Auftrags-
|
||||
// Workflow, Dateien-Zuordnung über die Nummer, Gewerke-Filter des
|
||||
// Wochenberichts, Auslastungs-Ersatzgröße) — für die Planung-Sandbox
|
||||
// Wochenberichts) — für die Planung-Sandbox
|
||||
// ausgeblendet, siehe CLAUDE.md "Planung — projektunabhängige
|
||||
// Terminplan-Sandbox". Honorar + beauftragte Leistungsphasen (Grundlage
|
||||
// des Terminplan-Rechners) und die Beteiligten-Matrix bleiben sichtbar.
|
||||
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke', 'settingsGroesse']
|
||||
['settingsProjNr', 'settingsAuftragsstatus', 'settingsGewerke']
|
||||
.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
const row = el && el.closest('.form-row');
|
||||
if(row) row.style.display = sandbox ? 'none' : '';
|
||||
});
|
||||
['settingsProjNrHinweis'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if(el) el.hidden = sandbox;
|
||||
});
|
||||
|
||||
// Projektnummer und -name: das fokussierte Feld nicht überschreiben
|
||||
// (dieselbe Vorsichtsmaßnahme wie beim Honorar weiter unten).
|
||||
@@ -86,25 +365,6 @@
|
||||
nameEl.disabled = locked;
|
||||
}
|
||||
|
||||
// Projektgröße: Sie ist der Ersatz für ein fehlendes Honorar, deshalb
|
||||
// steht unter dem Regler, ob sie gerade überhaupt zählt. Eine Zahl, die
|
||||
// wirkungslos ist, ohne dass man es sieht, wäre schlimmer als keine.
|
||||
const groesseEl = document.getElementById('settingsGroesse');
|
||||
if(groesseEl){
|
||||
const g = Number(p.groesse) || 0;
|
||||
if(document.activeElement !== groesseEl) groesseEl.value = g > 0 ? g : '';
|
||||
groesseEl.disabled = locked;
|
||||
const hinweisEl = document.getElementById('settingsGroesseHinweis');
|
||||
if(hinweisEl){
|
||||
const honorar = Number(p.honorar) || 0;
|
||||
hinweisEl.textContent = honorar > 0
|
||||
? 'Ein Honorar ist hinterlegt — die Auslastung rechnet damit, die Größe bleibt unberücksichtigt.'
|
||||
: (g > 0
|
||||
? 'Kein Honorar hinterlegt: Die Auslastung rechnet mit dieser Größe. 0 = keine Angabe, 1 = großes Projekt.'
|
||||
: 'Ohne Honorar und ohne Größe fließt dieses Projekt in keine Auslastung ein. 0 = keine Angabe, 1 = großes Projekt.');
|
||||
}
|
||||
}
|
||||
|
||||
// Schlagwörter: dieselbe Chip-Liste wie im früheren Projekte-Tab.
|
||||
const tagsEl = document.getElementById('settingsProjTags');
|
||||
if(tagsEl){
|
||||
@@ -124,11 +384,12 @@
|
||||
const gewerkeEl = document.getElementById('settingsGewerke');
|
||||
if(gewerkeEl && typeof GEWERK_LABELS === 'object'){
|
||||
const gesetzt = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
|
||||
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => `<label class="beauftragte-lp-item" title="${esc(GEWERK_LABELS[g])}">
|
||||
<input type="checkbox" value="${esc(g)}" ${gesetzt.has(g) ? 'checked' : ''} ${locked ? 'disabled' : ''}
|
||||
onchange="togglePlanGewerk('${esc(g)}', this.checked)">
|
||||
${esc(g)}
|
||||
</label>`).join('');
|
||||
// Als Umschalt-Knöpfe (Einstellungen "C"): angetippt = gesetzt.
|
||||
gewerkeEl.innerHTML = Object.keys(GEWERK_LABELS).map(g => {
|
||||
const an = gesetzt.has(g);
|
||||
return `<button type="button" class="plan-einst-chip${an ? ' an' : ''}" aria-pressed="${an}" title="${esc(GEWERK_LABELS[g])}"
|
||||
${locked ? 'disabled' : ''} onclick="togglePlanGewerk('${esc(g)}', ${!an})">${esc(g)}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Auftragsstatus: Das Auswahlfeld kommt fertig aus js/auftrag.js, damit
|
||||
@@ -139,8 +400,6 @@
|
||||
statusEl.innerHTML = auftragsstatusSelectHtml(p, { disabled: locked });
|
||||
}
|
||||
|
||||
const aufwandBtn = document.getElementById('settingsAufwandBtn');
|
||||
if(aufwandBtn) aufwandBtn.disabled = locked || typeof openLpModal !== 'function';
|
||||
|
||||
// Honorar: das fokussierte Feld nicht überschreiben, sonst springt beim
|
||||
// Tippen der Cursor (gleiche Vorsichtsmaßnahme wie im Honorar-Dialog).
|
||||
@@ -152,36 +411,32 @@
|
||||
honorarEl.disabled = locked;
|
||||
}
|
||||
|
||||
// Beauftragte Leistungsphasen: dieselbe Kreuzchen-Liste wie im
|
||||
// Honorar-Dialog, gespeichert über toggleBeauftragteLp() (plan-honorar.js).
|
||||
// Beauftragte Leistungsphasen: je Phase eine Zeile mit Umschalter
|
||||
// "Beauftragt | Nicht beauftragt", die laufende Phase trägt "läuft".
|
||||
// Gespeichert über toggleBeauftragteLp() (plan-honorar.js).
|
||||
const lpEl = document.getElementById('settingsBeauftragteLp');
|
||||
if(lpEl){
|
||||
const gesetzt = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []);
|
||||
lpEl.innerHTML = LPHS.map(l => `<label class="beauftragte-lp-item">
|
||||
<input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''} ${locked ? 'disabled' : ''}
|
||||
onchange="toggleBeauftragteLp('${esc(l)}', this.checked)">
|
||||
${esc(l)}
|
||||
</label>`).join('');
|
||||
const laeuft = Array.isArray(p.aktuelleLP) ? p.aktuelleLP[0] : '';
|
||||
lpEl.innerHTML = LPHS.map(l => {
|
||||
const an = gesetzt.has(l);
|
||||
return `<div class="plan-einst-phase${an ? ' ist-an' : ''}">
|
||||
<span class="plan-einst-phase-name"><b>${esc(l)}</b><span>${esc(LPH_BEZEICHNUNG[l] || '')}</span>${l === laeuft ? '<em>läuft</em>' : ''}</span>
|
||||
<span class="plan-einst-seg" role="group" aria-label="${esc(l)}">
|
||||
<button type="button" class="${an ? 'an' : ''}" ${locked ? 'disabled' : ''} onclick="toggleBeauftragteLp('${esc(l)}', true)">Beauftragt</button>
|
||||
<button type="button" class="${an ? '' : 'an'}" ${locked ? 'disabled' : ''} onclick="toggleBeauftragteLp('${esc(l)}', false)">Nicht beauftragt</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Beteiligte — Stammdatum, hier editierbar (siehe Kopfkommentar). Nicht
|
||||
// zugeordnete Personen erscheinen blass, damit erkennbar bleibt, wer zur
|
||||
// Auswahl stünde; ein Klick schaltet den Zustand durch (planCycleAssign()).
|
||||
const assignMatrix = document.getElementById('planAssignMatrix');
|
||||
if(assignMatrix){
|
||||
const grouped = peopleByRole(app.week);
|
||||
if(!p.assign) p.assign = {};
|
||||
assignMatrix.innerHTML = ROLES.map(r => {
|
||||
const entries = grouped[r] || [];
|
||||
const chips = entries.length ? entries.map(entry => {
|
||||
const state = p.assign[assignKey(entry.person.id, r)] || 0;
|
||||
const click = locked ? '' : `onclick="planCycleAssign('${entry.person.id}','${r}')"`;
|
||||
const title = (entry.person.fullName || entry.person.name) + ' · ' + r;
|
||||
return `<span class="board-avatar ${app.planRoleClass(r)} state-${state}${locked ? ' locked-cell' : ''}" ${click} title="${esc(title)}">${esc(entry.person.name)}</span>`;
|
||||
}).join('') : `<span class="hint" style="margin:0">Niemand hinterlegt</span>`;
|
||||
return `<div class="board-role-row"><span class="board-role-label ${app.planRoleClass(r)}">${esc(r)}</span><div class="board-role-chips">${chips}</div></div>`;
|
||||
}).join('');
|
||||
}
|
||||
if(assignMatrix) renderEinstTeam(p, locked);
|
||||
|
||||
renderEinstUebersicht();
|
||||
};
|
||||
|
||||
// planCycleAssign: wechselt die Zuordnung einer Person zu DIESEM Projekt
|
||||
@@ -191,7 +446,7 @@
|
||||
// Rückgabe.
|
||||
window.planCycleAssign = function(personId, role){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
if(!p.assign) p.assign = {};
|
||||
const key = assignKey(personId, role);
|
||||
const cur = p.assign[key] || 0;
|
||||
@@ -215,7 +470,7 @@
|
||||
// Dateien nicht mehr; deshalb die Rückfrage. Parameter: val. Keine Rückgabe.
|
||||
window.setPlanProjNr = function(val){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
const neuVal = (val || '').trim();
|
||||
if(neuVal === (p.nr || '')) return;
|
||||
if(p.nr && !confirm('Die Projektnummer "' + p.nr + '" wird zu "' + neuVal + '".\n\n'
|
||||
@@ -231,7 +486,7 @@
|
||||
// setPlanProjName: ändert die Projektbezeichnung. Parameter: val.
|
||||
window.setPlanProjName = function(val){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
p.name = (val || '').trim();
|
||||
app.savePlan();
|
||||
// Die Bezeichnung steht an drei weiteren Stellen: in der Kopfzeile des
|
||||
@@ -249,28 +504,12 @@
|
||||
renderPlanSettings();
|
||||
};
|
||||
|
||||
// setPlanGroesse: übernimmt die Projektgröße (0–1). Gespeichert wird erst
|
||||
// beim Loslassen des Reglers, nicht bei jedem Zwischenwert — sonst liefe
|
||||
// ein Zug über die Skala als zwanzig Speichervorgänge auf den Server.
|
||||
// Parameter: val. Keine Rückgabe.
|
||||
window.setPlanGroesse = function(val){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
const n = Math.max(0, Math.min(1, parseFloat(val) || 0));
|
||||
if(n === (Number(p.groesse) || 0)) return;
|
||||
p.groesse = n;
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
// Die Auslastung rechnet mit diesem Wert.
|
||||
if(typeof renderLoad === 'function' && document.getElementById('loadGrid')) renderLoad();
|
||||
};
|
||||
|
||||
// planTagKeydown: übernimmt ein Schlagwort mit Enter. Parameter: event.
|
||||
window.planTagKeydown = function(event){
|
||||
if(event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked()) return;
|
||||
if(!p || app.stammGesperrt()) return;
|
||||
const tag = (event.target.value || '').trim();
|
||||
if(!tag) return;
|
||||
if(!Array.isArray(p.tags)) p.tags = [];
|
||||
@@ -283,7 +522,7 @@
|
||||
// planRemoveTag: entfernt ein Schlagwort. Parameter: tag.
|
||||
window.planRemoveTag = function(tag){
|
||||
const p = app.project;
|
||||
if(!p || app.isLocked() || !Array.isArray(p.tags)) return;
|
||||
if(!p || app.stammGesperrt() || !Array.isArray(p.tags)) return;
|
||||
p.tags = p.tags.filter(t => t !== tag);
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
@@ -296,7 +535,7 @@
|
||||
// GEWERK_LABELS (js/projekt-wizard.js), damit sie nicht bei jeder Änderung
|
||||
// durcheinandergerät. Parameter: gewerk, checked (Boolean).
|
||||
window.togglePlanGewerk = function(gewerk, checked){
|
||||
if(app.isLocked()) return;
|
||||
if(app.stammGesperrt()) return;
|
||||
const p = app.project;
|
||||
const set = new Set(Array.isArray(p.gewerke) ? p.gewerke : []);
|
||||
if(checked) set.add(gewerk); else set.delete(gewerk);
|
||||
@@ -304,21 +543,4 @@
|
||||
app.savePlan();
|
||||
renderPlanSettings();
|
||||
};
|
||||
|
||||
// planOpenAufwand: öffnet die Aufwands-Gewichtungsmatrix. Der Dialog selbst
|
||||
// liegt in js/load/load.js (openLpModal) und wird hier nur aufgerufen —
|
||||
// er gehörte immer zum Projekt, stand aber bisher nur im Projekte-Tab.
|
||||
window.planOpenAufwand = function(){
|
||||
const p = app.project;
|
||||
if(!p || typeof openLpModal !== 'function') return;
|
||||
// openLpModal() sucht das Projekt in currentWeek(). Zeigt der
|
||||
// Projektbereich eine ANDERE Sitzung, findet es dort nichts und täte
|
||||
// stillschweigend nichts — deshalb hier der ausdrückliche Hinweis.
|
||||
const w = typeof currentWeek === 'function' ? currentWeek() : null;
|
||||
if(!w || !Array.isArray(w.projects) || !w.projects.some(x => x.id === p.id)){
|
||||
app.showToast('Der Aufwand lässt sich nur in der laufenden Sitzung bearbeiten.');
|
||||
return;
|
||||
}
|
||||
openLpModal(p.id);
|
||||
};
|
||||
})();
|
||||
@@ -36,7 +36,6 @@ window.PlanApp = {
|
||||
week: null, // planWeek
|
||||
project: null, // planProject
|
||||
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
|
||||
criticalPathActive: false, // Hervorhebung des kritischen Pfads im GANTT aktiv
|
||||
// Drag-Zustände
|
||||
todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen
|
||||
ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben
|
||||
@@ -122,6 +121,9 @@ window.PlanApp = {
|
||||
app.projNr = '';
|
||||
app.projName = app.project.name || '';
|
||||
renderPlanPage();
|
||||
// showTab() hat die Adresse schon vor diesem Aufruf geschrieben, als
|
||||
// der Name noch fehlte (/Projektplan/?…) — jetzt mit Namen nachziehen.
|
||||
if(typeof updateRouteUrl === 'function') updateRouteUrl('plan');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -177,10 +179,21 @@ window.PlanApp = {
|
||||
return app.project.plan;
|
||||
}
|
||||
|
||||
// isLocked: liefert true, wenn die aktuelle Woche gesperrt ist (dann sind
|
||||
// Bearbeitungen im Plan gesperrt). Rückgabe: Boolescher Wert.
|
||||
// isLocked: liefert true, wenn der TERMINPLAN nicht bearbeitet werden darf
|
||||
// — gesperrte Sitzung oder fehlendes Recht „Terminplan bearbeiten“ (bei
|
||||
// einem Planung-Plan: „Planung“; Rollen & Rechte). Rückgabe: Boolean.
|
||||
function isLocked(){
|
||||
return !!app.week.locked;
|
||||
return !!app.week.locked || !hatRecht(isSandboxPlan() ? 'wz.planung' : 'proj.plan');
|
||||
}
|
||||
// stammGesperrt: dasselbe für Projekteinstellungen, Gruppe, amtierende
|
||||
// Leistungsphase und Honorar (Recht „Stammdaten und Beteiligte“).
|
||||
function stammGesperrt(){
|
||||
return !!app.week.locked || !hatRecht(isSandboxPlan() ? 'wz.planung' : 'proj.stamm');
|
||||
}
|
||||
// protokolleGesperrt: Protokolle (Recht „Protokolle bearbeiten“), in einer
|
||||
// gesperrten Sitzung wie bisher gesperrt.
|
||||
function protokolleGesperrt(){
|
||||
return !!app.week.locked || !hatRecht('proj.protokolle');
|
||||
}
|
||||
|
||||
// isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur
|
||||
@@ -238,8 +251,6 @@ window.PlanApp = {
|
||||
// Bereichs), lädt bei vorhandener Projektnummer die Protokollliste.
|
||||
if(typeof initPlanProtokoll === 'function') initPlanProtokoll();
|
||||
|
||||
// Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js.
|
||||
renderPlanHistory();
|
||||
|
||||
// Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen,
|
||||
// Projektangaben) — siehe js/plan/plan-settings.js.
|
||||
@@ -247,10 +258,12 @@ window.PlanApp = {
|
||||
|
||||
// Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten
|
||||
// Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche:
|
||||
// Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html.
|
||||
// Terminplan (…Btn), Aufgaben (…BtnList) und Leistungsphasen (…BtnPhasen),
|
||||
// siehe App.html.
|
||||
const locked = isLocked();
|
||||
['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', 'autoScheduleBtn',
|
||||
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList']
|
||||
['addMilestoneBtn', 'addPhaseBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn',
|
||||
'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList',
|
||||
'honorarBtnPhasen', 'addPhaseBtnPhasen']
|
||||
.forEach(id => {
|
||||
const btn = document.getElementById(id);
|
||||
if(btn) btn.style.display = locked ? 'none' : '';
|
||||
@@ -263,10 +276,8 @@ window.PlanApp = {
|
||||
document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]')
|
||||
.forEach(btn => { btn.style.display = sandbox ? 'none' : ''; });
|
||||
|
||||
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für
|
||||
// Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent.
|
||||
const sandboxPdfBtn = document.getElementById('sandboxPdfBtn');
|
||||
if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none';
|
||||
// Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gilt für
|
||||
// Planung-Pläne UND echte Projekte, der Knopf ist immer sichtbar.
|
||||
|
||||
// GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit)
|
||||
fitPlanGanttRange();
|
||||
@@ -394,6 +405,8 @@ window.PlanApp = {
|
||||
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
|
||||
app.ensurePlanLocal = ensurePlanLocal;
|
||||
app.isLocked = isLocked;
|
||||
app.stammGesperrt = stammGesperrt;
|
||||
app.protokolleGesperrt = protokolleGesperrt;
|
||||
app.isSandboxPlan = isSandboxPlan;
|
||||
app.removeDependencyRefs = removeDependencyRefs;
|
||||
app.renderProjectAssignees = renderProjectAssignees;
|
||||
|
||||
@@ -75,6 +75,18 @@
|
||||
return e < today;
|
||||
}
|
||||
|
||||
// Phasenfilter der Aufgabenliste: '' = alle, 'LPH 5', oder '-' = ohne
|
||||
// Phase. Gilt nur für das Projekt, für das er gesetzt wurde.
|
||||
let filter = { projekt: null, phase: '' };
|
||||
const filterPhase = () => filter.projekt === app.project ? filter.phase : '';
|
||||
|
||||
// setzeAufgabenFilter: von außen (Bereich Leistungsphasen) oder über die
|
||||
// Filterknöpfe. Parameter: phase ('' | 'LPH x' | '-').
|
||||
window.setzeAufgabenFilter = function(phase){
|
||||
filter = { projekt: app.project, phase: phase || '' };
|
||||
window.renderPlanTasks();
|
||||
};
|
||||
|
||||
// renderPlanTasks: baut die Aufgaben- und die Meilensteinliste des Bereichs
|
||||
// "Aufgaben" neu auf und schreibt die Zähler in die Kartenüberschriften.
|
||||
// Keine Parameter, keine Rückgabe.
|
||||
@@ -85,7 +97,25 @@
|
||||
|
||||
// ---- Aufgaben ----
|
||||
const todoEl = document.getElementById('planTodoList');
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
const alleTodos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
// Phasenbalken stehen im eigenen Bereich "Leistungsphasen"
|
||||
// (plan-phasen.js), nicht in der Aufgabenliste. idx bleibt der Index in
|
||||
// plan.todos, den die Dialoge erwarten.
|
||||
const eintraege = alleTodos.map((t, idx) => ({ t, idx, phase: aufgabenPhase(t, alleTodos) }))
|
||||
.filter(e => !balkenPhase(e.t));
|
||||
const todos = eintraege.map(e => e.t);
|
||||
const fp = filterPhase();
|
||||
const sichtbar = eintraege.filter(e => !fp || (fp === '-' ? !e.phase : e.phase === fp));
|
||||
|
||||
const filterEl = document.getElementById('planTodoFilter');
|
||||
if(filterEl){
|
||||
const phasen = LPHS.filter(l => eintraege.some(e => e.phase === l));
|
||||
const knopf = (wert, text) => `<button type="button" class="plan-task-filter-btn${fp === wert ? ' is-active' : ''}" onclick="setzeAufgabenFilter('${esc(wert)}')">${esc(text)}</button>`;
|
||||
filterEl.innerHTML = phasen.length
|
||||
? '<span>Phase:</span>' + knopf('', 'Alle') + phasen.map(l => knopf(l, l)).join('')
|
||||
+ (eintraege.some(e => !e.phase) ? knopf('-', 'ohne Phase') : '')
|
||||
: '';
|
||||
}
|
||||
if(todoEl){
|
||||
// In gesperrten Sitzungen sind die Anlege-Schaltflächen ausgeblendet
|
||||
// (renderPlanPage(), plan-state.js) — dann darf der Leertext auch nicht
|
||||
@@ -95,7 +125,9 @@
|
||||
: 'Noch keine Aufgaben. Über „+ Aufgabe“, „+ Vorlage“ oder „+ Terminplan“ anlegen.';
|
||||
todoEl.innerHTML = todos.length === 0
|
||||
? '<div class="plan-task-empty">' + leerTextTodos + '</div>'
|
||||
: todos.map((t, idx) => {
|
||||
: sichtbar.length === 0
|
||||
? '<div class="plan-task-empty">Keine Aufgaben in dieser Auswahl.</div>'
|
||||
: sichtbar.map(({ t, idx, phase }) => {
|
||||
const status = todoStatus(t);
|
||||
const range = dateRangeText(t);
|
||||
const overdue = isOverdue(t);
|
||||
@@ -109,6 +141,8 @@
|
||||
${check}
|
||||
<span class="plan-task-dot ${status}"></span>
|
||||
<span class="plan-task-title">${esc(t.title || 'Aufgabe')}</span>
|
||||
${t.notiz ? `<span class="plan-task-notiz" title="${esc(String(t.notiz).slice(0, 300))}">${icon('fileText')}</span>` : ''}
|
||||
${phase ? `<span class="plan-task-phase" title="Liegt im Zeitraum dieser Leistungsphase">${esc(phase)}</span>` : ''}
|
||||
${assigneeAvatars(t.assignees)}
|
||||
${progress}
|
||||
<span class="plan-task-meta${overdue ? ' overdue' : ''}" title="${range ? '' : 'Ohne Termin — erscheint nicht im Terminplan'}">${range ? esc(range) : '<span class="plan-task-no-schedule">ohne Termin</span>'}</span>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über
|
||||
// alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen:
|
||||
// Der Button "+ Vorlage" in der GANTT-Toolbar öffnet #lphTemplateModal, dort
|
||||
// wählt man eine Leistungsphase und ein Startdatum, hakt die gewünschten
|
||||
// wählt man Gewerk, Leistungsphase und Startdatum (Vorlagen gibt es je
|
||||
// Gewerk), hakt die gewünschten
|
||||
// Schritte an und fügt sie mit einem Klick als TODOs in den Projektplan ein.
|
||||
//
|
||||
// Der Honorar-Terminplan (js/plan/plan-honorar.js) ist ein eigener Dialog mit
|
||||
@@ -16,7 +17,7 @@
|
||||
// lph_templates): GET für alle angemeldeten Nutzer, POST/DELETE nur für
|
||||
// Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen
|
||||
// nicht. Die Pflege-Maske selbst steht seit dem Umbau NICHT mehr in diesem
|
||||
// Dialog, sondern im Bereich „Einstellungen" (js/settings/settings.js) —
|
||||
// Dialog, sondern im Admin-Bereich „Aufgaben-Vorlagen" (js/admin/admin.js) —
|
||||
// hier bleibt für Administratoren nur ein Verweis dorthin.
|
||||
//
|
||||
// WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne
|
||||
@@ -46,13 +47,28 @@
|
||||
if(app.isLocked()) return;
|
||||
|
||||
const lphSelect = document.getElementById('templateLphSelect');
|
||||
if(lphSelect && !lphSelect.options.length){
|
||||
// Bei JEDEM Öffnen neu vorbelegen: Der Projektbereich zeigt nacheinander
|
||||
// verschiedene Projekte im selben Dokument — sonst blieb die amtierende
|
||||
// Leistungsphase des zuerst geöffneten Projekts vorgewählt.
|
||||
if(lphSelect){
|
||||
// Vorbelegung: die amtierende Leistungsphase des Projekts, sonst LPH 1
|
||||
const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : '';
|
||||
lphSelect.innerHTML = TEMPLATE_LPHS.map(l =>
|
||||
`<option value="${esc(l)}" ${l === aktLp ? 'selected' : ''}>${esc(l)}</option>`
|
||||
).join('');
|
||||
}
|
||||
// Gewerk vorbelegen: das eigene, wenn das Projekt es hat, sonst das
|
||||
// erste Gewerk des Projekts, sonst das eigene (Vorlagen gibt es je
|
||||
// Gewerk, Eriks Wunsch).
|
||||
const gewSelect = document.getElementById('templateGewerkSelect');
|
||||
if(gewSelect){
|
||||
const projGew = Array.isArray(app.project.gewerke) ? app.project.gewerke : [];
|
||||
const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : '';
|
||||
const vorwahl = (eigen && projGew.includes(eigen)) ? eigen : (projGew[0] || eigen || Object.keys(DEPARTMENTS)[0]);
|
||||
gewSelect.innerHTML = Object.keys(DEPARTMENTS).map(d =>
|
||||
`<option value="${esc(d)}" ${d === vorwahl ? 'selected' : ''}>${esc(d)}${projGew.includes(d) ? '' : ' (nicht im Projekt)'}</option>`
|
||||
).join('');
|
||||
}
|
||||
// Startdatum vorbelegen: heute
|
||||
const startInput = document.getElementById('templateStart');
|
||||
const startDisplay = document.getElementById('templateStartDisplay');
|
||||
@@ -62,6 +78,7 @@
|
||||
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
|
||||
}
|
||||
|
||||
templatePhaseVorbelegen();
|
||||
document.getElementById('lphTemplateModal').style.display = 'flex';
|
||||
loadTemplates();
|
||||
};
|
||||
@@ -88,12 +105,88 @@
|
||||
});
|
||||
}
|
||||
|
||||
// templatePhaseVorbelegen: Hat das Projekt einen Phasenbalken der gewählten
|
||||
// Leistungsphase, übernehmen Startdatum und „Dauer der Phase“ seinen
|
||||
// Zeitraum (in Arbeitstagen). Sonst bleibt das Startdatum, die Dauer ist
|
||||
// von Hand anzugeben. Keine Parameter.
|
||||
window.templatePhaseVorbelegen = function(){
|
||||
const lph = (document.getElementById('templateLphSelect') || {}).value || '';
|
||||
const todos = (app.ensurePlanLocal().todos || []);
|
||||
const balken = todos.find(t => typeof balkenPhase === 'function' && balkenPhase(t) === lph && t.start && t.end);
|
||||
const tageEl = document.getElementById('templatePhasenTage');
|
||||
const hinweis = document.getElementById('templatePhasenHinweis');
|
||||
if(balken){
|
||||
const s = parseDateISO(balken.start), e = parseDateISO(balken.end);
|
||||
const startInput = document.getElementById('templateStart');
|
||||
const startDisplay = document.getElementById('templateStartDisplay');
|
||||
if(startInput) startInput.value = balken.start;
|
||||
if(startDisplay) startDisplay.value = formatDateDE(s);
|
||||
if(tageEl) tageEl.value = Math.max(1, workdaysBetween(s, e));
|
||||
if(hinweis) hinweis.textContent = 'aus dem Phasenbalken ' + lph + ' (' + formatDateDE(s) + ' – ' + formatDateDE(e) + ')';
|
||||
} else {
|
||||
if(tageEl) tageEl.value = '';
|
||||
if(hinweis) hinweis.textContent = 'Kein Phasenbalken für ' + lph + ' — Dauer bitte angeben.';
|
||||
}
|
||||
};
|
||||
|
||||
// templatePlan: Termine der Schritte nach ihrem Anteil (Eriks Wunsch, z. B.
|
||||
// Siemon-Tabelle): Jeder Schritt mit Anteil bekommt Anteil ÷ Summe aller
|
||||
// Anteile der Phase an deren Dauer, nacheinander ab dem Startdatum in der
|
||||
// gepflegten Reihenfolge (Arbeitstage, ohne Wochenenden/Feiertage). Die
|
||||
// Grenzen werden über die laufende Summe gerundet, damit sich keine
|
||||
// Rundungsfehler aufaddieren. Schritte ohne Anteil: Start/Dauer der
|
||||
// Vorlage, ohne Dauer ohne Termin. Parameter: entries (alle Schritte der
|
||||
// Phase), startDate, phasenTage. Rückgabe: Map id → {s, e} oder null.
|
||||
function templatePlan(entries, startDate, phasenTage){
|
||||
const out = new Map();
|
||||
const summe = entries.reduce((a, t) => a + (Number(t.anteil) || 0), 0);
|
||||
let kum = 0;
|
||||
entries.forEach(t => {
|
||||
const ant = Number(t.anteil) || 0;
|
||||
if(ant > 0 && summe > 0){
|
||||
if(!startDate || !(phasenTage > 0)){ out.set(t.id, null); return; }
|
||||
const von = Math.round(phasenTage * kum / summe);
|
||||
const bis = Math.round(phasenTage * (kum + ant) / summe);
|
||||
kum += ant;
|
||||
const s = addWorkdays(startDate, von);
|
||||
const e = addWorkdays(s, Math.max(1, bis - von) - 1);
|
||||
out.set(t.id, { s, e, anteil: ant / summe });
|
||||
} else if(t.enthaltenIn){
|
||||
out.set(t.id, null); // zweiter Durchgang unten
|
||||
} else if(t.durationDays > 0 && startDate){
|
||||
const s = addDays(startDate, t.offsetDays);
|
||||
out.set(t.id, { s, e: addDays(s, t.durationDays - 1) });
|
||||
} else {
|
||||
out.set(t.id, null);
|
||||
}
|
||||
});
|
||||
// „in a) enthalten“ (Siemon): zeitgleich mit den genannten Schritten —
|
||||
// vom frühesten Beginn bis zum spätesten Ende. Der Buchstabe eines
|
||||
// Schritts steht am Ende seines Titels („… (a)“).
|
||||
const buchstabe = t => (String(t.title).match(/\(([a-z])\)\s*$/i) || [])[1];
|
||||
entries.filter(t => t.enthaltenIn).forEach(t => {
|
||||
const wirte = t.enthaltenIn.split(',')
|
||||
.map(b => entries.find(x => !x.enthaltenIn && (buchstabe(x) || '').toLowerCase() === b))
|
||||
.map(x => x && out.get(x.id)).filter(Boolean);
|
||||
if(!wirte.length) return;
|
||||
out.set(t.id, {
|
||||
s: new Date(Math.min(...wirte.map(w => w.s.getTime()))),
|
||||
e: new Date(Math.max(...wirte.map(w => w.e.getTime()))),
|
||||
enthaltenIn: t.enthaltenIn
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten
|
||||
// Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array.
|
||||
function templatesForSelectedLph(){
|
||||
const lphSelect = document.getElementById('templateLphSelect');
|
||||
const gewSelect = document.getElementById('templateGewerkSelect');
|
||||
const lph = lphSelect ? lphSelect.value : '';
|
||||
return templateCache.filter(t => t.lph === lph);
|
||||
const g = gewSelect ? gewSelect.value : '';
|
||||
// Vorlagen ohne Gewerk (Altbestand) gelten für alle Gewerke
|
||||
return templateCache.filter(t => t.lph === lph && (!t.gewerk || t.gewerk === g));
|
||||
}
|
||||
|
||||
// renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je
|
||||
@@ -107,18 +200,23 @@
|
||||
const entries = templatesForSelectedLph();
|
||||
const startIso = (document.getElementById('templateStart') || {}).value || '';
|
||||
const startDate = parseDateISO(startIso);
|
||||
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
|
||||
const plan = templatePlan(entries, startDate, phasenTage);
|
||||
|
||||
if(entries.length === 0){
|
||||
listEl.innerHTML = '<span class="hint">Für diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>';
|
||||
listEl.innerHTML = '<span class="hint">Für dieses Gewerk und diese Leistungsphase ist noch keine Vorlage hinterlegt.</span>';
|
||||
} else {
|
||||
listEl.innerHTML = entries.map(t => {
|
||||
// Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist)
|
||||
let dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
|
||||
if(startDate){
|
||||
const s = addDays(startDate, t.offsetDays);
|
||||
const e = addDays(s, Math.max(0, t.durationDays - 1));
|
||||
dateHint = formatDateDE(s) + '–' + formatDateDE(e);
|
||||
}
|
||||
const termin = plan.get(t.id);
|
||||
let dateHint;
|
||||
if(termin) dateHint = formatDateDE(termin.s) + '–' + formatDateDE(termin.e)
|
||||
+ (termin.anteil ? ' · ' + String(t.anteil).replace('.', ',') + ' %' : '')
|
||||
+ (termin.enthaltenIn ? ' · in ' + termin.enthaltenIn.split(',').map(x => x + ')').join(' + ') : '');
|
||||
else if(t.enthaltenIn) dateHint = 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ') + ' — Dauer der Phase fehlt';
|
||||
else if(Number(t.anteil) > 0) dateHint = String(t.anteil).replace('.', ',') + ' % — Dauer der Phase fehlt';
|
||||
else if(t.durationDays > 0) dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`;
|
||||
else dateHint = 'ohne Termin';
|
||||
return `<label class="todo-depends-item">
|
||||
<input type="checkbox" value="${esc(t.id)}" checked>
|
||||
<span style="flex:1">${esc(t.title)}</span>
|
||||
@@ -131,13 +229,13 @@
|
||||
};
|
||||
|
||||
// renderTemplateHint: blendet für Administratoren den Verweis auf den
|
||||
// Bereich „Einstellungen" ein. Das Anlegen und Löschen von Schritten stand
|
||||
// bis zum Umbau hier im Dialog; es liegt jetzt in js/settings/settings.js.
|
||||
// Admin-Bereich „Aufgaben-Vorlagen" ein. Das Anlegen und Löschen von Schritten stand
|
||||
// bis zum Umbau hier im Dialog; es liegt jetzt in js/admin/admin.js.
|
||||
// Dieser Dialog FÜGT Vorlagen nur noch ein. Keine Parameter, keine Rückgabe.
|
||||
function renderTemplateHint(){
|
||||
const el = document.getElementById('templateAdminHint');
|
||||
if(!el) return;
|
||||
el.style.display = (currentUser && currentUser.role === 'admin') ? '' : 'none';
|
||||
el.style.display = hatRecht('a.vorlagen') ? '' : 'none';
|
||||
}
|
||||
|
||||
// applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im
|
||||
@@ -149,7 +247,6 @@
|
||||
if(app.isLocked()) return;
|
||||
const startIso = (document.getElementById('templateStart') || {}).value || '';
|
||||
const startDate = parseDateISO(startIso);
|
||||
if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; }
|
||||
|
||||
const listEl = document.getElementById('templateTaskList');
|
||||
const checkedIds = listEl
|
||||
@@ -157,16 +254,26 @@
|
||||
: [];
|
||||
if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; }
|
||||
|
||||
const entries = templatesForSelectedLph().filter(t => checkedIds.includes(t.id));
|
||||
const alle = templatesForSelectedLph();
|
||||
const entries = alle.filter(t => checkedIds.includes(t.id));
|
||||
// Ein Startdatum braucht es nur für Schritte mit Anteil oder Dauer —
|
||||
// ohne beides „ohne Termin“ (Aufgabe nur in der Liste, nicht im Gantt).
|
||||
if(!startDate && entries.some(t => t.durationDays > 0 || Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte ein Startdatum wählen.'); return; }
|
||||
const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0;
|
||||
if(!phasenTage && entries.some(t => Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte die Dauer der Phase in Arbeitstagen angeben — die Schritte bekommen ihren Anteil davon.'); return; }
|
||||
// Anteile beziehen sich auf ALLE Schritte der Phase: Abgewählte lassen
|
||||
// ihre Lücke, die übrigen behalten ihren Platz im Ablauf.
|
||||
const termine = templatePlan(alle, startDate, phasenTage);
|
||||
const plan = app.ensurePlanLocal();
|
||||
entries.forEach(t => {
|
||||
const s = addDays(startDate, t.offsetDays);
|
||||
const e = addDays(s, Math.max(0, t.durationDays - 1));
|
||||
const termin = termine.get(t.id);
|
||||
const s = termin ? termin.s : null;
|
||||
const e = termin ? termin.e : null;
|
||||
plan.todos.push({
|
||||
id: uid(),
|
||||
title: t.title,
|
||||
start: formatDateISO(s),
|
||||
end: formatDateISO(e),
|
||||
start: s ? formatDateISO(s) : '',
|
||||
end: e ? formatDateISO(e) : '',
|
||||
done: false,
|
||||
progress: 0,
|
||||
dependsOn: [],
|
||||
|
||||
Reference in new issue
Block a user