- 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>
736 lines
31 KiB
JavaScript
736 lines
31 KiB
JavaScript
// -----------------------------
|
||
// Projektübersicht — Assistent "Neues Projekt"
|
||
// -----------------------------
|
||
// Führt in sechs Schritten durch das Anlegen eines Projekts und ersetzt den
|
||
// früheren Dialog #newProjModal, der alle Felder auf einmal zeigte — samt
|
||
// einer 27-Felder-Matrix für den Aufwand je Phase und Rolle, mitten im
|
||
// Formular.
|
||
//
|
||
// Die Schritte:
|
||
//
|
||
// 1. PROJEKT Nummer, Bezeichnung, GEWERKE, Gruppe, Schlagwörter
|
||
// 2. AUFTRAG Auftragsstatus, Honorar (freiwillig), beauftragte
|
||
// Leistungsphasen
|
||
// 3. STAND amtierende Leistungsphase
|
||
// 4. TEAM Beteiligte je Rolle — eingeschränkt auf die Gewerke des
|
||
// Projekts, andere ausdrücklich hinzuholbar
|
||
// 5. TERMINPLAN aus dem Honorar errechnet (nur dann); erzeugt je Phase
|
||
// eine Aufgabe im Projektplan
|
||
// 6. PRÜFEN Zusammenfassung, dann Anlegen
|
||
//
|
||
// Warum ein Assistent: Honorar, Team und Schlagwörter mussten zuvor NACH dem
|
||
// Anlegen an drei weiteren Stellen nachgetragen werden — und wurden es meist
|
||
// nicht.
|
||
//
|
||
// Pflichtangaben sperren "Weiter", solange etwas fehlt (das Honorar ist
|
||
// freiwillig, Eriks Vorgabe 10/2026),
|
||
// und unter dem Knopf steht, was fehlt. Ein Assistent, der unvollständige
|
||
// Projekte durchlässt, löst das Problem nicht, das er lösen soll.
|
||
//
|
||
// Nutzt LPHS/ROLES/GROUP_ORDER/APP_SETTINGS/HONORAR_ANTEILE (js/globals.js),
|
||
// currentWeek()/save() (js/store.js, js/weeks.js), auftragsstatusOptionsHtml()
|
||
// (js/auftrag.js), esc()/uid()/assignKey()/isWorkday() (js/utils.js).
|
||
|
||
// WIZARD_SCHRITTE: die Stationen in ihrer Reihenfolge. Das Team steht VOR
|
||
// dem Terminplan, weil die Dauer einer Phase davon abhängt, wie viele daran
|
||
// arbeiten — umgekehrt wäre sie geraten.
|
||
const WIZARD_SCHRITTE = [
|
||
{ key: 'projekt', titel: 'Projekt', unter: 'Wie heißt es, welche Gewerke bearbeiten es?' },
|
||
{ key: 'auftrag', titel: 'Auftrag', unter: 'Was ist beauftragt, und was ist es wert?' },
|
||
{ key: 'stand', titel: 'Stand', unter: 'Woran wird gerade gearbeitet?' },
|
||
{ key: 'team', titel: 'Team', unter: 'Wer bearbeitet das Projekt?' },
|
||
{ key: 'terminplan', titel: 'Terminplan', unter: 'Aus dem Honorar einen ersten Terminplan errechnen.' },
|
||
{ key: 'pruefen', titel: 'Prüfen', unter: 'Stimmt alles?' },
|
||
];
|
||
|
||
// GEWERK_LABELS: Langform für Titel und Auswahllisten. Die Schlüssel sind
|
||
// dieselben wie users.department und VALID_DEPARTMENTS in server.js.
|
||
const GEWERK_LABELS = { ELT: 'Elektrotechnik', HLS: 'Heizung · Lüftung · Sanitär', ARCH: 'Architektur' };
|
||
|
||
// wizard: Zustand des Assistenten. null = geschlossen.
|
||
let wizard = null;
|
||
|
||
// openNewProjWizard: öffnet den Assistenten mit leerem Zustand.
|
||
// Keine Parameter, keine Rückgabe; gesperrte Sitzungen werden abgewiesen,
|
||
// ebenso Konten ohne Rolle Projektleiter/Administrator (die Knöpfe sind für
|
||
// sie ohnehin ausgeblendet; /save-week weist das Anlegen mit 403 ab).
|
||
function openNewProjWizard(){
|
||
if(guardWeekLocked()) return;
|
||
if(!darfProjektlisteAendern()){
|
||
alert('Projekte anlegen dürfen nur Projektleiter und Administratoren.');
|
||
return;
|
||
}
|
||
wizard = {
|
||
schritt: 0,
|
||
fertig: null, // angelegtes Projekt, solange der Abschluss steht
|
||
nr: '', name: '', gewerke: [], gruppe: 'Aktive Projekte', tags: [],
|
||
status: AUFTRAGSSTATUS_VORGABE, honorar: '',
|
||
beauftragt: [], amtierend: '',
|
||
team: {}, fremde: [],
|
||
planErzeugen: true, planStart: heuteIso(), planPersonen: '',
|
||
};
|
||
ROLES.forEach(r => { wizard.team[r] = []; });
|
||
document.getElementById('wizardModal').style.display = 'flex';
|
||
renderWizard();
|
||
}
|
||
|
||
// closeNewProjWizard: schließt den Assistenten und verwirft die Eingaben.
|
||
function closeNewProjWizard(){
|
||
wizard = null;
|
||
const el = document.getElementById('wizardModal');
|
||
if(el) el.style.display = 'none';
|
||
}
|
||
|
||
// ---------- Werte und Ableitungen ----------
|
||
|
||
// wizardPersonen: alle Personen des Teilnehmerkreises der laufenden Sitzung,
|
||
// angereichert um Gewerk und Rollen aus der globalen Personenliste.
|
||
//
|
||
// Gewählt wird aus week.people und NICHT aus db.globalPeople: Die Zuordnung
|
||
// landet in project.assign und wird vom Board über currentWeek().people
|
||
// aufgelöst — wer dort fehlt, erschiene nirgends. Das Gewerk steht dagegen
|
||
// nur am Konto (users.department), deshalb der Abgleich über die Kennung.
|
||
function wizardPersonen(){
|
||
const w = currentWeek();
|
||
if(!w || !Array.isArray(w.people)) return [];
|
||
const global = Array.isArray(db.globalPeople) ? db.globalPeople : [];
|
||
return w.people.map(p => {
|
||
const g = global.find(x => x.id === p.id)
|
||
|| (p.fullName ? global.find(x => x.fullName === p.fullName) : null);
|
||
return {
|
||
id: p.id,
|
||
kuerzel: p.name || p.id,
|
||
name: p.fullName || p.name || p.id,
|
||
gewerk: (g && g.gewerk) || '',
|
||
rollen: Array.isArray(p.role) ? p.role : (g && g.role) || [],
|
||
};
|
||
});
|
||
}
|
||
|
||
// wizardSichtbare: wer in einer Rolle zur Auswahl steht — die Personen aus
|
||
// den Gewerken des Projekts, dazu die ausdrücklich hinzugeholten.
|
||
function wizardSichtbare(rolle){
|
||
return wizardPersonen().filter(p => {
|
||
if(p.rollen.indexOf(rolle) < 0) return false;
|
||
return wizard.gewerke.indexOf(p.gewerk) >= 0 || wizard.fremde.indexOf(p.id) >= 0;
|
||
});
|
||
}
|
||
|
||
// wizardUebrige: Personen, deren Gewerk nicht am Projekt beteiligt ist und
|
||
// die noch nicht hinzugeholt wurden — die Auswahl des Zusatz-Feldes.
|
||
function wizardUebrige(){
|
||
return wizardPersonen().filter(p =>
|
||
wizard.gewerke.indexOf(p.gewerk) < 0 && wizard.fremde.indexOf(p.id) < 0);
|
||
}
|
||
|
||
function wizardTeamAnzahl(){
|
||
const gesehen = new Set();
|
||
ROLES.forEach(r => wizard.team[r].forEach(id => gesehen.add(id)));
|
||
return gesehen.size;
|
||
}
|
||
|
||
function wizardHonorar(){
|
||
const n = parseFloat(String(wizard.honorar).replace(',', '.'));
|
||
return (!Number.isFinite(n) || n <= 0) ? 0 : n;
|
||
}
|
||
// wizardBasis: der Geldbetrag für den Terminplan — allein das Honorar.
|
||
// Es ist beim Anlegen freiwillig (Eriks Vorgabe); ohne Honorar entsteht
|
||
// kein Terminplan, er lässt sich später im Projektbereich erzeugen.
|
||
function wizardBasis(){
|
||
const h = wizardHonorar();
|
||
return h > 0 ? { betrag: h, quelle: 'Honorar' } : { betrag: 0, quelle: null };
|
||
}
|
||
|
||
// wizardPlanPersonen: wie viele gleichzeitig an einer Phase arbeiten.
|
||
// Vorgabe ist die Größe des Teams; überschreibbar, weil selten alle
|
||
// gleichzeitig an derselben Phase sitzen.
|
||
function wizardPlanPersonen(){
|
||
if(wizard.planPersonen) return Math.max(1, Number(wizard.planPersonen) || 1);
|
||
return Math.max(1, wizardTeamAnzahl());
|
||
}
|
||
|
||
// wizardPlanZeilen: der errechnete Terminplan — derselbe Rechenweg wie
|
||
// berechneHonorarPlan() in js/plan/plan-honorar.js: Phasenhonorar durch
|
||
// Stundensatz ergibt Stunden, Stunden durch Tagesleistung ergibt Dauer, die
|
||
// Phasen liegen hintereinander. Rückgabe: Array von Zeilen (leer, wenn nicht
|
||
// aus dem Honorar gerechnet werden kann).
|
||
function wizardPlanZeilen(){
|
||
const b = wizardBasis();
|
||
if(b.betrag <= 0) return [];
|
||
const phasen = wizard.beauftragt.length ? wizard.beauftragt : AUFWAND_LPS;
|
||
const summe = phasen.reduce((s, l) => s + (Number(HONORAR_ANTEILE[l]) || 0), 0);
|
||
if(summe <= 0) return [];
|
||
|
||
const satz = Number(APP_SETTINGS.stundensatz) || 100;
|
||
const proTag = Number(APP_SETTINGS.arbeitsstundenProTag) || 8;
|
||
const personen = wizardPlanPersonen();
|
||
|
||
let cursor = new Date(wizard.planStart);
|
||
if(isNaN(cursor.getTime())) cursor = new Date();
|
||
// Auf den nächsten Arbeitstag schieben, damit die erste Phase nicht am
|
||
// Wochenende oder Feiertag beginnt (isWorkday() kennt die gepflegten
|
||
// Feiertage, js/utils.js).
|
||
while(!isWorkday(cursor)) cursor.setDate(cursor.getDate() + 1);
|
||
|
||
return phasen.map(lph => {
|
||
const anteil = (Number(HONORAR_ANTEILE[lph]) || 0) / summe * 100;
|
||
const phasenHonorar = b.betrag * anteil / 100;
|
||
const stunden = phasenHonorar / satz;
|
||
const tage = Math.max(1, Math.ceil(stunden / (proTag * personen)));
|
||
const start = new Date(cursor);
|
||
const ende = addWorkdays(start, tage - 1);
|
||
cursor = addWorkdays(ende, 1);
|
||
return { lph, anteil, honorar: phasenHonorar, stunden, tage, start, ende };
|
||
});
|
||
}
|
||
|
||
// ---------- Vollständigkeit ----------
|
||
|
||
// wizardFehlt: was im aktuellen Schritt noch fehlt. Rückgabe: Array von
|
||
// Klartexten; leer = der Schritt ist vollständig.
|
||
//
|
||
// Der Assistent lässt niemanden weiter, solange hier etwas steht: Genau die
|
||
// Lücken, die sonst entstehen (keine Phase, niemand zugeordnet), fielen
|
||
// später im Wochenbericht und im Projektbereich auf.
|
||
function wizardFehlt(key){
|
||
const fehlt = [];
|
||
if(key === 'projekt'){
|
||
if(!wizard.nr.trim()) fehlt.push('Projektnummer');
|
||
if(!wizard.name.trim()) fehlt.push('Bezeichnung');
|
||
if(!wizard.gewerke.length) fehlt.push('mindestens ein Gewerk');
|
||
}
|
||
if(key === 'auftrag'){
|
||
if(!wizard.beauftragt.length) fehlt.push('mindestens eine beauftragte Leistungsphase');
|
||
}
|
||
if(key === 'stand'){
|
||
if(!wizard.amtierend) fehlt.push('amtierende Leistungsphase');
|
||
}
|
||
if(key === 'team'){
|
||
if(wizardTeamAnzahl() === 0) fehlt.push('mindestens eine beteiligte Person');
|
||
}
|
||
if(key === 'terminplan'){
|
||
if(wizardHonorar() > 0 && wizard.planErzeugen && !wizard.planStart) fehlt.push('Beginn');
|
||
}
|
||
return fehlt;
|
||
}
|
||
|
||
// ---------- Rendern ----------
|
||
|
||
function renderWizard(){
|
||
const el = document.getElementById('wizardBody');
|
||
if(!el || !wizard) return;
|
||
if(wizard.fertig){ el.innerHTML = wizardAbschlussHtml(); return; }
|
||
|
||
const st = WIZARD_SCHRITTE[wizard.schritt];
|
||
const fehlt = wizardFehlt(st.key);
|
||
const letzter = wizard.schritt === WIZARD_SCHRITTE.length - 1;
|
||
|
||
const leiste = WIZARD_SCHRITTE.map((s, i) => {
|
||
const erledigt = i < wizard.schritt;
|
||
const kl = i === wizard.schritt ? ' ist-aktiv' : (erledigt ? ' ist-fertig' : '');
|
||
return `<div class="wiz-stufe${kl}"><b>${erledigt ? '✓' : (i + 1)}</b><span>${esc(s.titel)}</span></div>`;
|
||
}).join('<div class="wiz-strich"></div>');
|
||
|
||
el.innerHTML = `
|
||
<div class="wiz-leiste">${leiste}</div>
|
||
<div class="wiz-inhalt">
|
||
<h3 class="wiz-titel">${esc(st.titel)}</h3>
|
||
<p class="wiz-unter">${esc(st.unter)}</p>
|
||
${wizardSchrittHtml(st.key)}
|
||
</div>
|
||
<div class="wiz-fuss">
|
||
<button type="button" class="secondary" onclick="wizardZurueck()"${wizard.schritt === 0 ? ' disabled' : ''}>Zurück</button>
|
||
<span class="wiz-zaehler">Schritt ${wizard.schritt + 1} von ${WIZARD_SCHRITTE.length}</span>
|
||
<div class="wiz-fuss-rechts">
|
||
${fehlt.length ? `<span class="wiz-fehlt">Es fehlt: ${esc(fehlt.join(', '))}</span>` : ''}
|
||
${letzter
|
||
? `<button type="button" onclick="wizardAnlegen()"${fehlt.length ? ' disabled' : ''}>Projekt anlegen</button>`
|
||
: `<button type="button" onclick="wizardWeiter()"${fehlt.length ? ' disabled' : ''}>Weiter</button>`}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function wizardSchrittHtml(key){
|
||
if(key === 'projekt') return wizardProjektHtml();
|
||
if(key === 'auftrag') return wizardAuftragHtml();
|
||
if(key === 'stand') return wizardStandHtml();
|
||
if(key === 'team') return wizardTeamHtml();
|
||
if(key === 'terminplan') return wizardTerminplanHtml();
|
||
return wizardPruefenHtml();
|
||
}
|
||
|
||
function wizardProjektHtml(){
|
||
const tagVorschlaege = ['ELT', 'HLS', 'Sanierung', 'Neubau', 'Bestand', 'Schule'];
|
||
return `
|
||
<div class="wiz-zeile">
|
||
<div class="form-row">
|
||
<label>Projektnummer <span class="wiz-pflicht">*</span></label>
|
||
<input type="text" id="wizNr" value="${esc(wizard.nr)}" placeholder="z. B. 26-4071"
|
||
oninput="wizardSetz('nr', this.value)">
|
||
<span class="hint">Dateien und Freigaben hängen an dieser Nummer.</span>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Gruppe</label>
|
||
<select onchange="wizardSetz('gruppe', this.value)">
|
||
${GROUP_ORDER.map(g => `<option${g === wizard.gruppe ? ' selected' : ''}>${esc(g)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Bezeichnung <span class="wiz-pflicht">*</span></label>
|
||
<input type="text" id="wizName" value="${esc(wizard.name)}" placeholder="z. B. Sporthalle Soest, Sanierung Heizung"
|
||
oninput="wizardSetz('name', this.value)">
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Gewerke <span class="wiz-pflicht">*</span></label>
|
||
<div class="wiz-chips">
|
||
${Object.keys(GEWERK_LABELS).map(g => `
|
||
<button type="button" class="wiz-chip wiz-chip-gewerk" aria-pressed="${wizard.gewerke.indexOf(g) >= 0}"
|
||
title="${esc(GEWERK_LABELS[g])}" onclick="wizardGewerkUm('${g}')">${g}</button>`).join('')}
|
||
</div>
|
||
<span class="hint">Welche Fachbereiche bearbeiten das Projekt? Mehrfachauswahl.
|
||
Die Wahl bestimmt, welche Mitarbeiter im Schritt „Team" zur Auswahl stehen.</span>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Schlagwörter</label>
|
||
<div class="wiz-chips">
|
||
${tagVorschlaege.map(t => `
|
||
<button type="button" class="wiz-chip" aria-pressed="${wizard.tags.indexOf(t) >= 0}"
|
||
onclick="wizardTagUm('${esc(t)}')">${esc(t)}</button>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function wizardAuftragHtml(){
|
||
return `
|
||
<div class="form-row">
|
||
<label>Auftragsstatus</label>
|
||
<select onchange="wizardSetz('status', this.value)">
|
||
${auftragsstatusOptionsHtml(wizard.status)}
|
||
</select>
|
||
<span class="hint">Anfrage und Angebot wandern in die Gruppe „In Anbahnung".</span>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Honorar (netto, €)</label>
|
||
<input type="number" id="wizHonorar" min="0" step="1000" value="${esc(wizard.honorar)}" placeholder="z. B. 480000"
|
||
oninput="wizardSetz('honorar', this.value)">
|
||
<span class="hint" id="wizHinweisHonorar">${wizardHinweisHonorar()}</span>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Beauftragte Leistungsphasen <span class="wiz-pflicht">*</span></label>
|
||
<div class="wiz-chips">
|
||
${AUFWAND_LPS.map(l => `
|
||
<button type="button" class="wiz-chip" aria-pressed="${wizard.beauftragt.indexOf(l) >= 0}"
|
||
onclick="wizardLphUm('${esc(l)}')">${esc(l.replace('LPH ', ''))}</button>`).join('')}
|
||
</div>
|
||
<span class="hint">Nur die beauftragten erscheinen im Wochenbericht — und nur sie stehen im Terminplan.</span>
|
||
</div>`;
|
||
}
|
||
|
||
// Der Hinweistext steht als eigene Funktion da, weil er beim Tippen
|
||
// mitläuft, ohne dass das Eingabefeld neu gebaut wird — sonst springt der
|
||
// Cursor ans Feldende.
|
||
function wizardHinweisHonorar(){
|
||
return wizardHonorar() > 0
|
||
? 'Grundlage für Leistungsstand und Terminplan.'
|
||
: 'Freiwillig. Ohne Honorar entsteht kein Terminplan; es lässt sich später in den Projekteinstellungen nachtragen.';
|
||
}
|
||
|
||
function wizardStandHtml(){
|
||
const phasen = wizard.beauftragt.length ? wizard.beauftragt : AUFWAND_LPS;
|
||
return `
|
||
<div class="form-row">
|
||
<label>Amtierende Leistungsphase <span class="wiz-pflicht">*</span></label>
|
||
<div class="wiz-chips">
|
||
${phasen.map(l => `
|
||
<button type="button" class="wiz-chip" aria-pressed="${wizard.amtierend === l}"
|
||
onclick="wizardSetz('amtierend','${esc(l)}')">${esc(l)}</button>`).join('')}
|
||
</div>
|
||
<span class="hint">Woran gerade gearbeitet wird.</span>
|
||
</div>`;
|
||
}
|
||
|
||
function wizardTeamHtml(){
|
||
const gewerkNamen = wizard.gewerke.join(' · ');
|
||
const zeilen = ROLES.map(r => {
|
||
const leute = wizardSichtbare(r);
|
||
const chips = leute.length
|
||
? leute.map(p => {
|
||
const fremd = wizard.gewerke.indexOf(p.gewerk) < 0;
|
||
return `<button type="button" class="wiz-person${fremd ? ' ist-fremd' : ''}"
|
||
data-rolle="${esc(r)}" aria-pressed="${wizard.team[r].indexOf(p.id) >= 0}"
|
||
title="${esc(p.name + ' · ' + (p.gewerk || 'ohne Gewerk') + (fremd ? ' (anderes Gewerk)' : ''))}"
|
||
onclick="wizardPersonUm('${esc(r)}','${esc(p.id)}')">${esc(p.kuerzel)}</button>`;
|
||
}).join('')
|
||
: `<span class="hint" style="margin:0">niemand aus ${esc(gewerkNamen)} in dieser Rolle</span>`;
|
||
return `<div class="wiz-rolle">
|
||
<span class="wiz-rolle-name">${esc(r)}</span>
|
||
<div class="wiz-chips">${chips}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const uebrige = wizardUebrige();
|
||
const auswahl = uebrige.length
|
||
? `<select id="wizFremd" onchange="wizardFremdenHolen(this.value); this.value='';">
|
||
<option value="">Mitarbeiter aus anderen Gewerken…</option>
|
||
${Object.keys(GEWERK_LABELS).filter(g => wizard.gewerke.indexOf(g) < 0).map(g => {
|
||
const teil = uebrige.filter(p => p.gewerk === g);
|
||
if(!teil.length) return '';
|
||
return `<optgroup label="${esc(GEWERK_LABELS[g])}">`
|
||
+ teil.map(p => `<option value="${esc(p.id)}">${esc(p.name)} — ${esc(p.rollen.join(', ') || 'ohne Rolle')}</option>`).join('')
|
||
+ '</optgroup>';
|
||
}).join('')}
|
||
${uebrige.some(p => !p.gewerk) ? `<optgroup label="Ohne Gewerk">`
|
||
+ uebrige.filter(p => !p.gewerk).map(p => `<option value="${esc(p.id)}">${esc(p.name)}</option>`).join('')
|
||
+ '</optgroup>' : ''}
|
||
</select>`
|
||
: `<span class="hint" style="margin:0">Es gibt niemanden hinzuzuholen.</span>`;
|
||
|
||
const dazu = wizard.fremde.map(id => {
|
||
const p = wizardPersonen().find(x => x.id === id);
|
||
if(!p) return '';
|
||
return `<span class="wiz-fremd-chip">${esc(p.kuerzel)}${p.gewerk ? ' · ' + esc(p.gewerk) : ''}
|
||
<button type="button" title="Wieder entfernen" onclick="wizardFremdenEntfernen('${esc(id)}')">×</button></span>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<p class="hint" style="margin:0 0 14px">Gezeigt werden die Mitarbeiter aus <b>${esc(gewerkNamen)}</b>.
|
||
Klick schaltet die Zuordnung um; sie bestimmt, wessen Auslastung das Projekt belastet
|
||
und wer es auf seinem Dashboard sieht.</p>
|
||
<div class="wiz-rollen">${zeilen}</div>
|
||
<div class="wiz-fremd">
|
||
<label>Aus anderen Gewerken hinzuholen</label>
|
||
${auswahl}
|
||
${dazu ? `<div class="wiz-fremd-liste">${dazu}</div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
function wizardTerminplanHtml(){
|
||
// Ohne Honorar kein Terminplan — er lässt sich später im Projektbereich
|
||
// erzeugen („Terminplan aus Honorar").
|
||
if(wizardHonorar() <= 0){
|
||
return `<div class="wiz-sperre">
|
||
<b>Kein Terminplan ohne Honorar.</b>
|
||
<p>Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit
|
||
nachträglich erzeugen („Terminplan aus Honorar").</p>
|
||
</div>`;
|
||
}
|
||
|
||
const zeilen = wizardPlanZeilen();
|
||
if(!zeilen.length){
|
||
return `<div class="wiz-sperre"><b>Kein Terminplan errechenbar.</b>
|
||
<p>Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt.
|
||
Ein Administrator pflegt sie im Admin-Bereich unter „Honorar".</p></div>`;
|
||
}
|
||
|
||
const summeTage = zeilen.reduce((s, z) => s + z.tage, 0);
|
||
const proTag = Number(APP_SETTINGS.arbeitsstundenProTag) || 8;
|
||
const satz = Number(APP_SETTINGS.stundensatz) || 100;
|
||
|
||
return `
|
||
<label class="wiz-schalter">
|
||
<input type="checkbox" ${wizard.planErzeugen ? 'checked' : ''} onchange="wizardSetz('planErzeugen', this.checked)">
|
||
<span>Terminplan beim Anlegen erzeugen</span>
|
||
</label>
|
||
<div class="wiz-zeile" style="margin-top:14px">
|
||
<div class="form-row">
|
||
<label>Beginn</label>
|
||
<input type="date" value="${esc(wizard.planStart)}"
|
||
${wizard.planErzeugen ? `onchange="wizardSetz('planStart', this.value)"` : 'disabled'}>
|
||
</div>
|
||
<div class="form-row">
|
||
<label>Gleichzeitig daran</label>
|
||
<input type="number" id="wizPersonen" min="1" max="20" step="1" value="${esc(wizard.planPersonen)}"
|
||
placeholder="${Math.max(1, wizardTeamAnzahl())}"
|
||
${wizard.planErzeugen ? `oninput="wizardSetz('planPersonen', this.value)"` : 'disabled'}>
|
||
<span class="hint">${wizard.planPersonen ? 'Überschreibt die Teamgröße.'
|
||
: 'Vorgabe: ' + Math.max(1, wizardTeamAnzahl()) + ' — die Größe des Teams aus dem vorigen Schritt.'}</span>
|
||
</div>
|
||
</div>
|
||
${wizard.planErzeugen ? `
|
||
<div class="wiz-plan" id="wizPlanTabelle">${wizardPlanTabelleHtml()}</div>
|
||
<p class="hint" style="margin-top:12px">Je Phase eine Aufgabe im Terminplan, hintereinander ab dem Beginn.
|
||
Gerechnet mit ${esc(faktorFormat(satz))} €/h, ${esc(String(proTag))} Stunden je Arbeitstag und
|
||
${wizardPlanPersonen()} gleichzeitig arbeitenden ${wizardPlanPersonen() === 1 ? 'Person' : 'Personen'};
|
||
Wochenenden und gepflegte Feiertage zählen nicht mit. Alles lässt sich danach im Gantt verschieben.</p>`
|
||
: `<p class="hint" style="margin-top:14px">Das Projekt entsteht ohne Terminplan.
|
||
Er lässt sich jederzeit im Projektbereich nachträglich erzeugen.</p>`}`;
|
||
}
|
||
|
||
function wizardPlanTabelleHtml(){
|
||
const zeilen = wizardPlanZeilen();
|
||
if(!zeilen.length) return '';
|
||
const summeTage = zeilen.reduce((s, z) => s + z.tage, 0);
|
||
const b = wizardBasis();
|
||
return `<div class="wiz-plan-zeile wiz-plan-kopf">
|
||
<span>Phase</span><span>Anteil</span><span>Honorar</span><span>Dauer</span><span>Zeitraum</span>
|
||
</div>`
|
||
+ zeilen.map(z => `<div class="wiz-plan-zeile">
|
||
<span><b>${esc(z.lph)}</b></span>
|
||
<span>${faktorFormat(z.anteil)} %</span>
|
||
<span>${eurFormat(z.honorar)}</span>
|
||
<span>${z.tage} AT</span>
|
||
<span>${formatWeekDate(isoVon(z.start))} – ${formatWeekDate(isoVon(z.ende))}</span>
|
||
</div>`).join('')
|
||
+ `<div class="wiz-plan-zeile wiz-plan-summe">
|
||
<span><b>Summe</b></span><span></span>
|
||
<span>${eurFormat(b.betrag)}</span><span>${summeTage} AT</span>
|
||
<span>Ende ${formatWeekDate(isoVon(zeilen[zeilen.length - 1].ende))}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function wizardPruefenHtml(){
|
||
const b = wizardBasis();
|
||
const zeile = (label, wert, fehlt) =>
|
||
`<div class="wiz-pruef-zeile"><span>${esc(label)}</span><b${fehlt ? ' class="fehlt"' : ''}>${wert}</b></div>`;
|
||
const team = ROLES.filter(r => wizard.team[r].length)
|
||
.map(r => r + ': ' + wizard.team[r].map(id => {
|
||
const p = wizardPersonen().find(x => x.id === id);
|
||
return p ? p.kuerzel : id;
|
||
}).join(', ')).join(' · ');
|
||
const plan = wizardPlanZeilen();
|
||
|
||
return `<div class="wiz-pruef">
|
||
${zeile('Projektnummer', esc(wizard.nr))}
|
||
${zeile('Bezeichnung', esc(wizard.name))}
|
||
${zeile('Gewerke', esc(wizard.gewerke.join(', ')))}
|
||
${zeile('Gruppe', esc(wizard.gruppe))}
|
||
${zeile('Schlagwörter', wizard.tags.length ? esc(wizard.tags.join(', ')) : 'keine', !wizard.tags.length)}
|
||
${zeile('Auftragsstatus', esc(auftragsstatusLabel(wizard.status)))}
|
||
${zeile('Honorar', wizardHonorar() > 0 ? eurFormat(wizardHonorar()) : 'nicht angegeben', !(wizardHonorar() > 0))}
|
||
${zeile('Beauftragte Phasen', esc(wizard.beauftragt.join(', ')))}
|
||
${zeile('Amtierende Phase', esc(wizard.amtierend))}
|
||
${zeile('Team', esc(team))}
|
||
${wizard.fremde.length ? zeile('davon aus anderen Gewerken', esc(wizard.fremde.map(id => {
|
||
const p = wizardPersonen().find(x => x.id === id);
|
||
return p ? p.kuerzel + (p.gewerk ? ' (' + p.gewerk + ')' : '') : id;
|
||
}).join(', '))) : ''}
|
||
${zeile('Terminplan', !(wizardHonorar() > 0)
|
||
? 'nicht möglich — kein Honorar angegeben'
|
||
: (!wizard.planErzeugen ? 'wird nicht erzeugt'
|
||
: (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')),
|
||
!(wizardHonorar() > 0) || !wizard.planErzeugen || !plan.length)}
|
||
</div>`;
|
||
}
|
||
|
||
function wizardAbschlussHtml(){
|
||
const p = wizard.fertig;
|
||
const plan = Array.isArray(p.plan && p.plan.todos) ? p.plan.todos : [];
|
||
return `<div class="wiz-fertig">
|
||
<div class="wiz-haken">✓</div>
|
||
<h3>${esc(p.nr)} · ${esc(p.name)} ist angelegt</h3>
|
||
<p>${esc(p.gewerke.join(' · '))}${p.aktuelleLP && p.aktuelleLP.length ? ', amtierend ' + esc(p.aktuelleLP[0]) : ''}.
|
||
${plan.length ? 'Der Terminplan steht mit ' + plan.length + ' Phasen. ' : ''}
|
||
Das Projekt steht jetzt in der Projektliste, im Wochenbericht und in der Auslastung.</p>
|
||
<div class="toolbar" style="justify-content:center">
|
||
<button type="button" onclick="wizardZumProjekt()">Projektbereich öffnen</button>
|
||
<button type="button" class="secondary" onclick="openNewProjWizard()">Noch eines anlegen</button>
|
||
<button type="button" class="secondary" onclick="closeNewProjWizard()">Schließen</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ---------- Eingaben ----------
|
||
|
||
function wizardSetz(feld, wert){
|
||
if(!wizard) return;
|
||
wizard[feld] = wert;
|
||
if(feld === 'status'){
|
||
// Dieselbe Kopplung wie im früheren Dialog: Eine Anfrage ist noch kein
|
||
// laufendes Projekt und gehört in die Anbahnung.
|
||
if(wert === 'anfrage' || wert === 'angebot') wizard.gruppe = 'In Anbahnung';
|
||
else if(wizard.gruppe === 'In Anbahnung') wizard.gruppe = 'Aktive Projekte';
|
||
}
|
||
// Freitextfelder nicht neu bauen — sonst springt der Cursor ans Feldende.
|
||
if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'planPersonen'){
|
||
wizardNurAbhaengiges();
|
||
return;
|
||
}
|
||
renderWizard();
|
||
}
|
||
|
||
// wizardNurAbhaengiges: erneuert beim Tippen alles, was vom Wert abhängt —
|
||
// aber kein Eingabefeld. Betroffen sind der Weiter-Knopf samt Fehlt-Hinweis,
|
||
// die beiden Hinweistexte des Auftrag-Schritts und die Terminplan-Tabelle.
|
||
function wizardNurAbhaengiges(){
|
||
const st = WIZARD_SCHRITTE[wizard.schritt];
|
||
const fehlt = wizardFehlt(st.key);
|
||
|
||
const rechts = document.querySelector('.wiz-fuss-rechts');
|
||
if(rechts){
|
||
const knopf = rechts.querySelector('button');
|
||
if(knopf) knopf.disabled = fehlt.length > 0;
|
||
let hinweis = rechts.querySelector('.wiz-fehlt');
|
||
if(fehlt.length){
|
||
if(!hinweis){
|
||
hinweis = document.createElement('span');
|
||
hinweis.className = 'wiz-fehlt';
|
||
rechts.insertBefore(hinweis, rechts.firstChild);
|
||
}
|
||
hinweis.textContent = 'Es fehlt: ' + fehlt.join(', ');
|
||
} else if(hinweis){
|
||
hinweis.remove();
|
||
}
|
||
}
|
||
|
||
const hh = document.getElementById('wizHinweisHonorar');
|
||
if(hh) hh.textContent = wizardHinweisHonorar();
|
||
|
||
const tab = document.getElementById('wizPlanTabelle');
|
||
if(tab) tab.innerHTML = wizardPlanTabelleHtml();
|
||
}
|
||
|
||
function wizardGewerkUm(g){
|
||
const i = wizard.gewerke.indexOf(g);
|
||
if(i >= 0){
|
||
wizard.gewerke.splice(i, 1);
|
||
// Beteiligte dieses Gewerks fallen aus dem Team — sonst stünde im Projekt
|
||
// jemand, der nach der eigenen Regel gar nicht auswählbar wäre.
|
||
const erlaubt = new Set(wizardPersonen()
|
||
.filter(p => wizard.gewerke.indexOf(p.gewerk) >= 0 || wizard.fremde.indexOf(p.id) >= 0)
|
||
.map(p => p.id));
|
||
ROLES.forEach(r => { wizard.team[r] = wizard.team[r].filter(id => erlaubt.has(id)); });
|
||
} else {
|
||
wizard.gewerke.push(g);
|
||
wizard.gewerke.sort((a, b) => Object.keys(GEWERK_LABELS).indexOf(a) - Object.keys(GEWERK_LABELS).indexOf(b));
|
||
// Wer jetzt zum Projektgewerk gehört, muss nicht mehr als "fremd" gelten.
|
||
wizard.fremde = wizard.fremde.filter(id => {
|
||
const p = wizardPersonen().find(x => x.id === id);
|
||
return !p || wizard.gewerke.indexOf(p.gewerk) < 0;
|
||
});
|
||
}
|
||
renderWizard();
|
||
}
|
||
|
||
function wizardTagUm(t){
|
||
const i = wizard.tags.indexOf(t);
|
||
if(i >= 0) wizard.tags.splice(i, 1); else wizard.tags.push(t);
|
||
renderWizard();
|
||
}
|
||
|
||
function wizardLphUm(l){
|
||
const i = wizard.beauftragt.indexOf(l);
|
||
if(i >= 0) wizard.beauftragt.splice(i, 1); else wizard.beauftragt.push(l);
|
||
wizard.beauftragt.sort((a, b) => AUFWAND_LPS.indexOf(a) - AUFWAND_LPS.indexOf(b));
|
||
// Eine amtierende Phase, die nicht mehr beauftragt ist, ergibt keinen Sinn.
|
||
if(wizard.amtierend && wizard.beauftragt.length && wizard.beauftragt.indexOf(wizard.amtierend) < 0){
|
||
wizard.amtierend = '';
|
||
}
|
||
renderWizard();
|
||
}
|
||
|
||
function wizardPersonUm(rolle, id){
|
||
const arr = wizard.team[rolle];
|
||
const i = arr.indexOf(id);
|
||
if(i >= 0) arr.splice(i, 1); else arr.push(id);
|
||
renderWizard();
|
||
}
|
||
|
||
function wizardFremdenHolen(id){
|
||
if(!id || wizard.fremde.indexOf(id) >= 0) return;
|
||
wizard.fremde.push(id);
|
||
renderWizard();
|
||
}
|
||
function wizardFremdenEntfernen(id){
|
||
wizard.fremde = wizard.fremde.filter(x => x !== id);
|
||
ROLES.forEach(r => { wizard.team[r] = wizard.team[r].filter(x => x !== id); });
|
||
renderWizard();
|
||
}
|
||
|
||
function wizardWeiter(){
|
||
if(wizardFehlt(WIZARD_SCHRITTE[wizard.schritt].key).length) return;
|
||
if(wizard.schritt < WIZARD_SCHRITTE.length - 1){ wizard.schritt++; renderWizard(); }
|
||
}
|
||
function wizardZurueck(){
|
||
if(wizard.schritt > 0){ wizard.schritt--; renderWizard(); }
|
||
}
|
||
|
||
// ---------- Anlegen ----------
|
||
|
||
// wizardAnlegen: baut aus den Eingaben ein Projekt, hängt es an die laufende
|
||
// Sitzung und speichert. Der Terminplan entsteht als gewöhnliche Aufgaben im
|
||
// project.plan — genau so, wie ihn der Dialog „Terminplan aus Honorar" im
|
||
// Projektbereich erzeugt.
|
||
function wizardAnlegen(){
|
||
if(!wizard || guardWeekLocked()) return;
|
||
const offen = WIZARD_SCHRITTE.map(s => wizardFehlt(s.key)).reduce((a, b) => a.concat(b), []);
|
||
if(offen.length){ alert('Es fehlt noch: ' + offen.join(', ')); return; }
|
||
|
||
const w = currentWeek();
|
||
if(w.projects.some(p => (p.nr || '') === wizard.nr.trim())){
|
||
if(!confirm('Die Projektnummer "' + wizard.nr.trim() + '" ist in dieser Sitzung bereits vergeben.\n\n'
|
||
+ 'Beide Projekte würden sich dieselben Dateien und Freigaben teilen.\n\nTrotzdem anlegen?')) return;
|
||
}
|
||
|
||
const nextOrder = w.projects
|
||
.filter(p => p.group === wizard.gruppe)
|
||
.reduce((max, p) => Math.max(max, p.order !== undefined ? p.order : 0), -1) + 1;
|
||
|
||
const pr = {
|
||
id: uid(), projectUid: uid(),
|
||
nr: wizard.nr.trim(), name: wizard.name.trim(),
|
||
group: wizard.gruppe, gewerke: wizard.gewerke.slice(),
|
||
tags: wizard.tags.slice(),
|
||
status: '', statusState: undefined, order: nextOrder,
|
||
lph: {}, assign: {}, plan: null,
|
||
auftragsstatus: auftragsstatusOf({ auftragsstatus: wizard.status }),
|
||
honorar: wizardHonorar(),
|
||
beauftragteLP: wizard.beauftragt.slice(),
|
||
aktuelleLP: [wizard.amtierend],
|
||
};
|
||
|
||
// Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in
|
||
// planCycleAssign(), js/plan/plan-settings.js).
|
||
ROLES.forEach(r => {
|
||
wizard.team[r].forEach(id => { pr.assign[assignKey(id, r)] = 1; });
|
||
});
|
||
|
||
// Terminplan: je beauftragter Phase eine Aufgabe.
|
||
const zeilen = wizard.planErzeugen ? wizardPlanZeilen() : [];
|
||
if(zeilen.length){
|
||
pr.plan = {
|
||
todos: zeilen.map((z, i) => ({
|
||
id: uid(),
|
||
title: z.lph,
|
||
lph: z.lph,
|
||
start: isoVon(z.start),
|
||
end: isoVon(z.ende),
|
||
done: false,
|
||
progress: 0,
|
||
assignees: [],
|
||
dependsOn: [],
|
||
})),
|
||
milestones: [],
|
||
};
|
||
}
|
||
|
||
w.projects.push(pr);
|
||
save();
|
||
renderBoard();
|
||
wizard.fertig = pr;
|
||
renderWizard();
|
||
}
|
||
|
||
// wizardZumProjekt: öffnet den Projektbereich des eben angelegten Projekts.
|
||
function wizardZumProjekt(){
|
||
const p = wizard && wizard.fertig;
|
||
closeNewProjWizard();
|
||
if(p && typeof openProjectPlan === 'function') openProjectPlan(p.id);
|
||
}
|
||
|
||
// ---------- kleine Helfer ----------
|
||
|
||
// isoVon: Date → 'YYYY-MM-TT' in Ortszeit (nicht über toISOString(), das
|
||
// rechnet nach UTC um und läge zwischen Mitternacht und 2 Uhr einen Tag
|
||
// zurück — siehe heuteIso() in js/utils.js).
|
||
function isoVon(d){
|
||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0')
|
||
+ '-' + String(d.getDate()).padStart(2, '0');
|
||
}
|
||
|
||
// eurFormat: Betrag in deutscher Schreibweise ohne Nachkommastellen — im
|
||
// Assistenten geht es um Größenordnungen, nicht um Cent.
|
||
function eurFormat(v){
|
||
return Math.round(Number(v) || 0).toLocaleString('de-DE') + ' €';
|
||
}
|