Files
projektuebersicht/public/js/plan/plan-templates.js
T
erikandClaude Opus 5.5 b7e0bd0d92 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>
2026-10-29 14:16:52 +01:00

290 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// Projektplan — Aufgaben-Vorlagen je Leistungsphase
// -----------------------------
// 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 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
// eigener Schaltfläche "+ Terminplan" — beide teilten sich früher einen Dialog
// mit Modus-Umschalter, was aber bedeutete, dass auf dem Knopf nur eines der
// beiden Verfahren stand.
//
// Datenquelle sind die Routen /lph-templates (server.js, Tabelle
// 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 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
// Verbindung zur Vorlage. Eine später geänderte Vorlage verändert bereits
// eingefügte Aufgaben also NICHT — ein laufender Projektplan soll sich nicht
// rückwirkend unter der Hand ändern.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// templateCache: zuletzt geladene Vorlagen (flaches Array), damit das
// Umschalten der Leistungsphase im Modal ohne erneuten Serveraufruf geht.
let templateCache = [];
// TEMPLATE_LPHS: Leistungsphasen, für die Vorlagen angelegt werden können —
// dieselbe Liste wie LPHS (js/globals.js) und wie VALID_LPHS in server.js.
const TEMPLATE_LPHS = LPHS;
// openTemplateModal: öffnet das Vorlagen-Modal, lädt die Vorlagen (einmal
// pro Seitenaufruf) und baut Leistungsphasen-Auswahl sowie Startdatum auf.
// Bei gesperrter Sitzung wird gar nicht erst geöffnet (wie bei den anderen
// Bearbeitungs-Dialogen, siehe plan-modals.js).
window.openTemplateModal = function(){
if(app.isLocked()) return;
const lphSelect = document.getElementById('templateLphSelect');
// 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');
if(startInput && !startInput.value){
const today = formatDateISO(new Date());
startInput.value = today;
if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today));
}
templatePhaseVorbelegen();
document.getElementById('lphTemplateModal').style.display = 'flex';
loadTemplates();
};
window.closeTemplateModal = function(){
document.getElementById('lphTemplateModal').style.display = 'none';
};
// loadTemplates: holt die Vorlagen vom Server und rendert danach die Liste.
// Fehler werden im Listenbereich gemeldet (kein alert, damit das Modal
// benutzbar bleibt).
function loadTemplates(){
const listEl = document.getElementById('templateTaskList');
if(listEl) listEl.innerHTML = '<span class="hint">Lade Vorlagen…</span>';
fetch(baseUrl() + '/lph-templates')
.then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); })
.then(data => {
templateCache = Array.isArray(data.templates) ? data.templates : [];
renderTemplateList();
})
.catch(err => {
console.error('Vorlagen laden fehlgeschlagen:', err);
if(listEl) listEl.innerHTML = '<span class="hint">Vorlagen konnten nicht geladen werden.</span>';
});
}
// 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 : '';
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
// Schritt, standardmäßig alle angehakt) samt Vorschau der Termine, die sich
// aus dem gewählten Startdatum ergeben. Für Admins wird darunter der
// Pflege-Bereich gerendert. Wird bei jedem Wechsel von Leistungsphase oder
// Startdatum erneut aufgerufen. Keine Parameter, keine Rückgabe.
window.renderTemplateList = function(){
const listEl = document.getElementById('templateTaskList');
if(!listEl) return;
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 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)
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>
<span class="hint" style="margin:0">${esc(dateHint)}</span>
</label>`;
}).join('');
}
renderTemplateHint();
};
// renderTemplateHint: blendet für Administratoren den Verweis auf den
// 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 = hatRecht('a.vorlagen') ? '' : 'none';
}
// applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im
// aktuellen Projektplan. Termine ergeben sich aus dem gewählten Startdatum
// plus offsetDays bzw. durationDays. Die neuen Aufgaben werden ans Ende der
// Aufgabenliste gehängt, ohne Zuständige und ohne Abhängigkeiten — das wird
// anschließend im TODO-Dialog ergänzt.
window.applyTemplate = function(){
if(app.isLocked()) return;
const startIso = (document.getElementById('templateStart') || {}).value || '';
const startDate = parseDateISO(startIso);
const listEl = document.getElementById('templateTaskList');
const checkedIds = listEl
? Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value)
: [];
if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; }
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 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: s ? formatDateISO(s) : '',
end: e ? formatDateISO(e) : '',
done: false,
progress: 0,
dependsOn: [],
assignees: [],
});
});
app.savePlan();
closeTemplateModal();
fitPlanGanttRange();
app.showToast(entries.length + ' Aufgabe(n) aus der Vorlage eingefügt.');
};
})();