Files
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

736 lines
31 KiB
JavaScript
Raw Permalink 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.
// -----------------------------
// 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') + ' €';
}