// -----------------------------
// 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 `
${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}
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.
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").
${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 `
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
// 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') + ' €';
}