// ----------------------------- // 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, // 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, 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 `
${erledigt ? '✓' : (i + 1)}${esc(s.titel)}
`; }).join('
'); el.innerHTML = `
${leiste}

${esc(st.titel)}

${esc(st.unter)}

${wizardSchrittHtml(st.key)}
Schritt ${wizard.schritt + 1} von ${WIZARD_SCHRITTE.length}
${fehlt.length ? `Es fehlt: ${esc(fehlt.join(', '))}` : ''} ${letzter ? `` : ``}
`; } 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 `
Dateien und Freigaben hängen an dieser Nummer.
${Object.keys(GEWERK_LABELS).map(g => ` `).join('')}
Welche Fachbereiche bearbeiten das Projekt? Mehrfachauswahl. Die Wahl bestimmt, welche Mitarbeiter im Schritt „Team" zur Auswahl stehen.
${tagVorschlaege.map(t => ` `).join('')}
`; } function wizardAuftragHtml(){ const perHonorar = wizard.grundlage === 'honorar'; return `
Anfrage und Angebot wandern in die Gruppe „In Anbahnung" und zählen nicht zur Auslastung.
${wizardHinweisHonorar()}
${wizardHinweisGroesse()}
${AUFWAND_LPS.map(l => ` `).join('')}
Nur die beauftragten erscheinen im Wochenbericht — und nur sie stehen im Terminplan.
`; } // 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 `
${phasen.map(l => ` `).join('')}
Woran gerade gearbeitet wird. Ohne diese Angabe erschiene das Projekt in der Auslastung als nicht bewertbar.
`; } 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 ``; }).join('') : `niemand aus ${esc(gewerkNamen)} in dieser Rolle`; return `
${esc(r)}
${chips}
`; }).join(''); const uebrige = wizardUebrige(); const auswahl = uebrige.length ? `` : `Es gibt niemanden hinzuzuholen.`; const dazu = wizard.fremde.map(id => { const p = wizardPersonen().find(x => x.id === id); if(!p) return ''; return `${esc(p.kuerzel)}${p.gewerk ? ' · ' + esc(p.gewerk) : ''} `; }).join(''); return `

Gezeigt werden die Mitarbeiter aus ${esc(gewerkNamen)}. Klick schaltet die Zuordnung um; sie bestimmt, wessen Auslastung das Projekt belastet und wer es auf seinem Dashboard sieht.

${zeilen}
${auswahl} ${dazu ? `
${dazu}
` : ''}
`; } 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 `
Kein Terminplan aus der Projektgröße.

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.

Sobald das Honorar feststeht, lässt sich der Terminplan im Projektbereich jederzeit nachträglich erzeugen („Terminplan aus Honorar").

`; } const zeilen = wizardPlanZeilen(); if(!zeilen.length){ return `
Kein Terminplan errechenbar.

Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt. Ein Administrator pflegt sie im Admin-Bereich unter „Honorar".

`; } 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 `
${wizard.planPersonen ? 'Überschreibt die Teamgröße.' : 'Vorgabe: ' + Math.max(1, wizardTeamAnzahl()) + ' — die Größe des Teams aus dem vorigen Schritt.'}
${wizard.planErzeugen ? `
${wizardPlanTabelleHtml()}

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.

` : `

Das Projekt entsteht ohne Terminplan. Er lässt sich jederzeit im Projektbereich nachträglich erzeugen.

`}`; } function wizardPlanTabelleHtml(){ const zeilen = wizardPlanZeilen(); if(!zeilen.length) return ''; const summeTage = zeilen.reduce((s, z) => s + z.tage, 0); const b = wizardBasis(); return `
PhaseAnteilHonorarDauerZeitraum
` + zeilen.map(z => `
${esc(z.lph)} ${faktorFormat(z.anteil)} % ${eurFormat(z.honorar)} ${z.tage} AT ${formatWeekDate(isoVon(z.start))} – ${formatWeekDate(isoVon(z.ende))}
`).join('') + `
Summe ${eurFormat(b.betrag)}${summeTage} AT Ende ${formatWeekDate(isoVon(zeilen[zeilen.length - 1].ende))}
`; } function wizardPruefenHtml(){ const b = wizardBasis(); const zeile = (label, wert, fehlt) => `
${esc(label)}${wert}
`; 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 `
${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)}

Die Gewichtung des Aufwands je Phase und Rolle übernimmt die firmenweite Vorlage; sie lässt sich später im Projektbereich anpassen.

`; } function wizardAbschlussHtml(){ const p = wizard.fertig; const plan = Array.isArray(p.plan && p.plan.todos) ? p.plan.todos : []; return `
✓

${esc(p.nr)} · ${esc(p.name)} ist angelegt

${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.

`; } // ---------- 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 // 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, 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') + ' €'; }