All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
@@ -9,12 +9,74 @@
|
||||
// (renderGanttDependencies), die Drag-/Resize- und Klick-Attribute der
|
||||
// Balken nutzen plan-drag.js und plan-modals.js.
|
||||
//
|
||||
// Zwei Zusatzanzeigen werden hier ebenfalls erzeugt:
|
||||
//
|
||||
// 1. GEISTER-BALKEN (Option ghostTodos/ghostMilestones): hinter jedem Balken
|
||||
// bzw. jeder Meilenstein-Raute der Termin aus der VORHERIGEN Sitzung als
|
||||
// gestrichelter Umriss, sobald er sich unterscheidet. Klassische
|
||||
// Projektwerkzeuge brauchen dafür eine eigens eingefrorene "Baseline" —
|
||||
// hier existiert sie kostenlos, weil jede Wochenkopie (cloneWeek() in
|
||||
// js/weeks.js) bereits ein vollständiger Schnappschuss des damaligen Plans
|
||||
// ist. Zusammengeführt wird über todo.id bzw. milestone.id: die bleiben
|
||||
// beim Klonen unverändert (cloneWeek() vergibt nur project.id neu), sind
|
||||
// also über Sitzungen hinweg stabil. Die Vorwochen-Daten liefert
|
||||
// renderPlanGantt() (plan-gantt.js) über app.previousProjectVersion()
|
||||
// aus plan-history.js.
|
||||
//
|
||||
// 2. URLAUBS-KOLLISION: überschneidet sich eine Aufgabe zeitlich mit einer
|
||||
// Abwesenheit einer ihr zugeordneten Person, wird der Balken markiert und
|
||||
// die Zeile bekommt ein Warnsymbol. Die Urlaubsbalken selbst zeichnet
|
||||
// gantt.js schon länger, aber rein dekorativ — die Kollision musste bisher
|
||||
// mit dem Auge gefunden werden.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// rangesOverlap: prüft, ob sich zwei Datumsbereiche (jeweils einschließlich
|
||||
// Start und Ende) überschneiden. Parameter: vier Date-Objekte.
|
||||
// Rückgabe: Boolean (false, sobald ein Datum fehlt).
|
||||
function rangesOverlap(aStart, aEnd, bStart, bEnd){
|
||||
if(!aStart || !aEnd || !bStart || !bEnd) return false;
|
||||
return aStart <= bEnd && bStart <= aEnd;
|
||||
}
|
||||
|
||||
// absenceConflictsForTodo: sucht alle Abwesenheiten der einer Aufgabe
|
||||
// zugeordneten Personen, die sich mit deren Zeitraum überschneiden. Die
|
||||
// Abwesenheiten kommen aus der globalen Personenliste db.globalPeople
|
||||
// (gefüllt aus users/user_absences, siehe loadGlobalPeople() in server.js) —
|
||||
// dieselbe Quelle, aus der auch die Urlaubszeilen im GANTT gezeichnet
|
||||
// werden. Parameter: todo (Aufgabenobjekt). Rückgabe: Array von
|
||||
// { name, type, start, end } (leer, wenn keine Kollision).
|
||||
function absenceConflictsForTodo(todo){
|
||||
const s = parseDateISO(todo.start), e = parseDateISO(todo.end);
|
||||
if(!s || !e) return [];
|
||||
const assignees = Array.isArray(todo.assignees) ? todo.assignees : [];
|
||||
if(assignees.length === 0) return [];
|
||||
const conflicts = [];
|
||||
assignees.forEach(personId => {
|
||||
const person = (db.globalPeople || []).find(p => p.id === personId);
|
||||
if(!person) return;
|
||||
(person.absences || []).forEach(a => {
|
||||
const as = parseDateISO(a.start), ae = parseDateISO(a.end);
|
||||
if(!rangesOverlap(s, e, as, ae)) return;
|
||||
conflicts.push({
|
||||
name: person.fullName || person.name,
|
||||
type: a.type || 'Abwesenheit',
|
||||
start: as,
|
||||
end: ae,
|
||||
});
|
||||
});
|
||||
});
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
// Export, damit plan-gantt.js dieselbe Prüfung für die Warnliste über dem
|
||||
// Diagramm nutzen kann (eine Quelle für beide Anzeigen).
|
||||
app.absenceConflictsForTodo = absenceConflictsForTodo;
|
||||
|
||||
// Referenz auf die Original-Funktion aus gantt.js, die für das Basis-GANTT
|
||||
// weiterhin verwendet wird.
|
||||
const originalBuildGanttHtml = window.buildGanttHtml;
|
||||
@@ -36,6 +98,31 @@
|
||||
const totalDays = Math.max(1, daysBetween(start, end) + 1);
|
||||
const editable = options.editable;
|
||||
|
||||
// ghostBarHtml: zeichnet einen gestrichelten Umriss für den Termin aus der
|
||||
// Vorwoche. Liefert '' (nichts), wenn keine Vorwochen-Daten vorliegen, der
|
||||
// Termin unverändert ist oder er komplett außerhalb des sichtbaren
|
||||
// Zeitraums liegt. Parameter: prevStartIso/prevEndIso (ISO-Datum aus der
|
||||
// Vorwoche), curStartIso/curEndIso (aktuelle Termine), label (für den
|
||||
// Tooltip). Rückgabe: HTML-String.
|
||||
function ghostBarHtml(prevStartIso, prevEndIso, curStartIso, curEndIso, label){
|
||||
if(!prevStartIso || !prevEndIso) return '';
|
||||
if(prevStartIso === curStartIso && prevEndIso === curEndIso) return '';
|
||||
const gs = parseDateISO(prevStartIso), ge = parseDateISO(prevEndIso);
|
||||
if(!gs || !ge) return '';
|
||||
const barStart = gs < start ? start : gs;
|
||||
const barEnd = ge > end ? end : ge;
|
||||
if(barEnd < start || barStart > end) return '';
|
||||
const offset = daysBetween(start, barStart);
|
||||
const span = daysBetween(barStart, barEnd) + 1;
|
||||
// Verschiebung in Tagen, positiv = nach hinten gerutscht
|
||||
const shift = curStartIso ? daysBetween(gs, parseDateISO(curStartIso)) : 0;
|
||||
const shiftText = shift > 0 ? ` (${shift} Tag${shift === 1 ? '' : 'e'} später)`
|
||||
: shift < 0 ? ` (${-shift} Tag${-shift === 1 ? '' : 'e'} früher)` : '';
|
||||
const ref = options.ghostLabel ? esc(options.ghostLabel) : 'vorherige Sitzung';
|
||||
const title = `${ref}: ${esc(label)} ${formatDateDE(gs)}–${formatDateDE(ge)}${shiftText}`;
|
||||
return `<div class="gantt-bar ghost" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${title}"></div>`;
|
||||
}
|
||||
|
||||
projects.forEach(p => {
|
||||
const plan = p.plan || {};
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos : [];
|
||||
@@ -61,6 +148,18 @@
|
||||
row.dataset.todoId = todo.id;
|
||||
row.dataset.todoIndex = tIdx;
|
||||
|
||||
// Urlaubs-Kollision: Aufgabe überschneidet sich mit einer Abwesenheit
|
||||
// einer zugeordneten Person (siehe absenceConflictsForTodo oben).
|
||||
const conflicts = absenceConflictsForTodo(todo);
|
||||
if(conflicts.length > 0) row.className += ' absence-conflict';
|
||||
|
||||
// Geister-Balken: Termin derselben Aufgabe (gleiche todo.id) aus der
|
||||
// vorherigen Sitzung, sofern er sich unterscheidet.
|
||||
const ghost = options.ghostTodos ? options.ghostTodos.get(todo.id) : null;
|
||||
const ghostHtml = ghost
|
||||
? ghostBarHtml(ghost.start, ghost.end, todo.start, todo.end, todo.title || 'TODO')
|
||||
: '';
|
||||
|
||||
let barHtml = '';
|
||||
if(s && e){
|
||||
const barStart = s < start ? start : s;
|
||||
@@ -72,9 +171,14 @@
|
||||
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));
|
||||
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}"${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)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
// Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt.
|
||||
barHtml = ghostHtml + barHtml;
|
||||
|
||||
let depBadge = '';
|
||||
if(Array.isArray(todo.dependsOn) && todo.dependsOn.length > 0){
|
||||
@@ -83,7 +187,9 @@
|
||||
const t = todos.find(x => x.id === id);
|
||||
if(t) return { title: t.title || 'TODO', end: t.end };
|
||||
const m = milestones.find(x => 'm_' + x.id === id);
|
||||
if(m) return { title: '🚩 ' + (m.name || 'Meilenstein'), end: m.date };
|
||||
// Hinweis: title landet weiter unten via esc() in reinem Textkontext
|
||||
// (kein HTML-Rendering) -> kein Icon-Präfix möglich, nur Text.
|
||||
if(m) return { title: (m.name || 'Meilenstein'), end: m.date };
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
if(preds.length > 0){
|
||||
@@ -93,9 +199,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Warnsymbol bei Urlaubs-Kollision, mit den betroffenen Personen im Tooltip
|
||||
const conflictBadge = conflicts.length > 0
|
||||
? `<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')}">
|
||||
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
|
||||
${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}
|
||||
</div>
|
||||
<div class="gantt-timeline">${barHtml}</div>`;
|
||||
return row;
|
||||
@@ -122,8 +234,25 @@
|
||||
labelStyle = 'left:' + left;
|
||||
}
|
||||
}
|
||||
|
||||
// Geister-Raute: Termin desselben Meilensteins (gleiche milestone.id)
|
||||
// aus der vorherigen Sitzung, sofern er sich verschoben hat. Gerade
|
||||
// bei Meilensteinen ist das die Information, die im Wochenmeeting auf
|
||||
// den Tisch gehört — sie sind die nach außen zugesagten Termine.
|
||||
const ghostDate = options.ghostMilestones ? options.ghostMilestones.get(m.id) : null;
|
||||
if(ghostDate && ghostDate !== m.date){
|
||||
const gd = parseDateISO(ghostDate);
|
||||
if(gd && gd >= start && gd <= end){
|
||||
const gLeft = (daysBetween(start, gd) / totalDays * 100).toFixed(4) + '%';
|
||||
const shift = d ? daysBetween(gd, d) : 0;
|
||||
const shiftText = shift > 0 ? ` (${shift} Tag${shift === 1 ? '' : 'e'} später)`
|
||||
: shift < 0 ? ` (${-shift} Tag${-shift === 1 ? '' : 'e'} früher)` : '';
|
||||
const ref = options.ghostLabel ? esc(options.ghostLabel) : 'vorherige Sitzung';
|
||||
diamond = `<div class="gantt-milestone ghost" style="left:${gLeft}" title="${ref}: ${esc(m.name || 'Meilenstein')} ${formatDateDE(gd)}${shiftText}"></div>` + diamond;
|
||||
}
|
||||
}
|
||||
row.innerHTML = `<div class="gantt-label" style="padding-left:24px;font-weight:400;color:var(--text-muted)">
|
||||
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
|
||||
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
|
||||
<span class="gantt-milestone-title"${editable ? ' onclick="openMilestoneModal(' + mIdx + ')" style="cursor:pointer"' : ''}>${esc(m.name || 'Meilenstein')}</span>
|
||||
</div>
|
||||
<div class="gantt-timeline">${diamond}<div class="gantt-milestone-label" style="${labelStyle}">${esc(m.name || '')}</div></div>`;
|
||||
@@ -144,10 +273,19 @@
|
||||
insertedMilestoneIds.add(m.id);
|
||||
});
|
||||
|
||||
// Aufgabenzeile
|
||||
const todoRow = buildTodoRow(todo, tIdx);
|
||||
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
|
||||
insertAfter = todoRow;
|
||||
// Aufgabenzeile — nur wenn die Aufgabe terminiert ist (Start UND Ende
|
||||
// gesetzt). Eine Aufgabe ohne Termin bleibt ganz normal Teil von
|
||||
// plan.todos und erscheint im Bereich "Aufgaben" (renderPlanTasks(),
|
||||
// js/plan/plan-tasks.js) — hier im Terminplan aber bewusst nicht: sie
|
||||
// hat keinen Zeitraum, den ein Balken abbilden könnte, und soll den
|
||||
// Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht
|
||||
// jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede
|
||||
// Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben).
|
||||
if(todo.start && todo.end){
|
||||
const todoRow = buildTodoRow(todo, tIdx);
|
||||
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
|
||||
insertAfter = todoRow;
|
||||
}
|
||||
|
||||
// Meilensteine, die NACH dieser Aufgabe positioniert sind
|
||||
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
|
||||
|
||||
Reference in new issue
Block a user