alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+781
View File
@@ -0,0 +1,781 @@
// -----------------------------
// 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 ODER Projektgröße, 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. Genau daher rühren die Projekte, die in der Auslastung als "nicht
// bewertbar" erscheinen.
//
// JEDER SCHRITT IST PFLICHT: "Weiter" bleibt gesperrt, solange etwas fehlt,
// 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.
function openNewProjWizard(){
if(guardWeekLocked()) return;
wizard = {
schritt: 0,
fertig: null, // angelegtes Projekt, solange der Abschluss steht
nr: '', name: '', gewerke: [], gruppe: 'Aktive Projekte', tags: [],
status: AUFTRAGSSTATUS_VORGABE, grundlage: 'honorar', honorar: '', groesse: '',
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;
}
function wizardGroesse(){
const n = parseFloat(String(wizard.groesse).replace(',', '.'));
if(!Number.isFinite(n) || n <= 0) return 0;
return Math.min(1, n);
}
// wizardBasis: der Geldbetrag, mit dem gerechnet wird. Bei der Projektgröße
// über das firmenweite Referenzhonorar — der Betrag wird bewusst NICHT
// angezeigt: Die Größe ist eine Schätzung, und eine daneben stehende
// Eurosumme gäbe ihr eine Genauigkeit, die sie nicht hat.
function wizardBasis(){
if(wizard.grundlage === 'honorar'){
const h = wizardHonorar();
return h > 0 ? { betrag: h, quelle: 'Honorar' } : { betrag: 0, quelle: null };
}
const g = wizardGroesse();
const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000;
return g > 0 ? { betrag: g * referenz, quelle: 'Projektgröße' } : { 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(wizard.grundlage !== 'honorar' || 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 (kein Honorar, keine Phase, niemand
// zugeordnet), sind der Grund, warum Projekte später in der Auslastung nicht
// bewertbar sind.
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.grundlage === 'honorar' && wizardHonorar() <= 0) fehlt.push('Honorar');
if(wizard.grundlage === 'groesse' && wizardGroesse() <= 0) fehlt.push('Projektgröße');
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(wizard.grundlage === 'honorar' && 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(){
const perHonorar = wizard.grundlage === 'honorar';
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" und zählen nicht zur Auslastung.</span>
</div>
<div class="form-row">
<label>Grundlage der Rechnung</label>
<div class="wiz-chips">
<button type="button" class="wiz-chip" aria-pressed="${perHonorar}" onclick="wizardSetz('grundlage','honorar')">Vereinbartes Honorar</button>
<button type="button" class="wiz-chip" aria-pressed="${!perHonorar}" onclick="wizardSetz('grundlage','groesse')">Geschätzte Projektgröße</button>
</div>
</div>
<div class="wiz-zeile">
<div class="form-row">
<label>Honorar (netto, €) ${perHonorar ? '<span class="wiz-pflicht">*</span>' : ''}</label>
<input type="number" id="wizHonorar" min="0" step="1000" value="${esc(wizard.honorar)}" placeholder="z. B. 480000"
${perHonorar ? `oninput="wizardSetz('honorar', this.value)"` : 'disabled'}>
<span class="hint" id="wizHinweisHonorar">${wizardHinweisHonorar()}</span>
</div>
<div class="form-row">
<label>Projektgröße (0,00 – 1,00) ${!perHonorar ? '<span class="wiz-pflicht">*</span>' : ''}</label>
<input type="number" id="wizGroesse" min="0" max="1" step="0.05" value="${esc(wizard.groesse)}" placeholder="z. B. 0,40"
${!perHonorar ? `oninput="wizardSetz('groesse', this.value)"` : 'disabled'}>
<span class="hint" id="wizHinweisGroesse">${wizardHinweisGroesse()}</span>
</div>
</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>`;
}
// Die beiden Hinweistexte stehen als eigene Funktionen da, weil sie beim
// Tippen mitlaufen müssen, ohne dass das Eingabefeld neu gebaut wird — sonst
// springt der Cursor ans Feldende.
function wizardHinweisHonorar(){
return wizard.grundlage === 'honorar'
? 'Grundlage für Leistungsstand, Auslastung und Terminplan.'
: 'Gesperrt — es wird mit der Projektgröße gerechnet.';
}
function wizardHinweisGroesse(){
return wizard.grundlage === 'groesse'
? 'Grober Umfang des Projekts. 0 = keine Angabe, 1 = großes Projekt.'
: 'Gesperrt — es wird mit dem Honorar gerechnet.';
}
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. Ohne diese Angabe erschiene das Projekt
in der Auslastung als nicht bewertbar.</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(){
// Aus der Projektgröße lässt sich kein Terminplan rechnen: Sie ist eine
// Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus
// entstünden, sähen im Gantt so verbindlich aus wie errechnete.
if(wizard.grundlage !== 'honorar'){
return `<div class="wiz-sperre">
<b>Kein Terminplan aus der Projektgröße.</b>
<p>Die Größe ist eine Schätzung des Umfangs, kein vereinbarter Betrag. Termine, die daraus
entstünden, sähen im Terminplan so verbindlich aus wie errechnete — ohne es zu sein.</p>
<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('Grundlage', wizard.grundlage === 'honorar'
? eurFormat(wizardHonorar()) + ' Honorar'
: 'Projektgröße ' + faktorFormat(wizardGroesse()))}
${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', wizard.grundlage !== 'honorar'
? 'nicht möglich — Grundlage ist die Projektgröße'
: (!wizard.planErzeugen ? 'wird nicht erzeugt'
: (plan.length ? plan.length + ' Phasen bis ' + formatWeekDate(isoVon(plan[plan.length - 1].ende)) : 'nicht errechenbar')),
wizard.grundlage !== 'honorar' || !wizard.planErzeugen || !plan.length)}
</div>
<p class="hint" style="margin-top:14px">Die Gewichtung des Aufwands je Phase und Rolle übernimmt die
firmenweite Vorlage; sie lässt sich später im Projektbereich anpassen.</p>`;
}
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';
}
if(feld === 'grundlage'){
// Das nun gesperrte Feld leeren: Ein Wert, der dasteht und nicht mehr
// zählt, führt in die Irre.
if(wert === 'honorar') wizard.groesse = ''; else wizard.honorar = '';
}
// Freitextfelder nicht neu bauen — sonst springt der Cursor ans Feldende.
if(feld === 'nr' || feld === 'name' || feld === 'honorar' || feld === 'groesse' || 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 hg = document.getElementById('wizHinweisGroesse');
if(hg) hg.textContent = wizardHinweisGroesse();
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: wizard.grundlage === 'honorar' ? wizardHonorar() : 0,
groesse: wizard.grundlage === 'groesse' ? wizardGroesse() : 0,
beauftragteLP: wizard.beauftragt.slice(),
aktuelleLP: [wizard.amtierend],
};
// Zuordnungen: Zustand 1 = Hauptverantwortung (dieselbe Bedeutung wie in
// cycleAssign(), js/board/board.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,
start: isoVon(z.start),
end: isoVon(z.ende),
done: false,
progress: 0,
assignees: [],
deps: [],
order: i,
})),
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') + ' €';
}