// Leistungswert-Ampel (Earned Value) // Quelle: widget.query("project.containers") // Rechenbasis sind ausschließlich Arbeitspakete ohne Unterelemente — // Elternknoten würden ihre Kinder ein zweites Mal zählen. const CURRENCY = "EUR"; // an die Mandantenwährung anpassen const isDark: boolean = widget.data.theme === "dark"; const C = { text: isDark ? "#f1f5f9" : "#1e293b", sub: "#94a3b8", card: isDark ? "#1e293b" : "#f8fafc", line: isDark ? "#334155" : "#e2e8f0", green: "#22c55e", amber: "#f59e0b", red: "#ef4444", }; const CUR: string = new Intl.NumberFormat(navigator.language, { style: "currency", currency: CURRENCY, }).formatToParts(0).filter((x) => x.type === "currency").map((x) => x.value)[0] || CURRENCY; function note(text: string): string { return `
${text}
`; } function queryError(code: string): string { return note( code === "forbidden" ? "Keine Berechtigung für den Strukturplan." : code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar." : "Daten konnten nicht geladen werden.", ); } function short(v: number): string { const a: number = Math.abs(v); if (a >= 1000000) return (v / 1000000).toLocaleString(navigator.language, { maximumFractionDigits: 1 }) + " Mio. " + CUR; if (a >= 10000) return Math.round(v / 1000).toLocaleString(navigator.language) + " Tsd. " + CUR; return Math.round(v).toLocaleString(navigator.language) + " " + CUR; } widget.el.innerHTML = note("Berechne Leistungswerte …"); (async () => { const res = await widget.query("project.containers", { limit: 500 }); if (!res.ok) { widget.el.innerHTML = queryError(res.error.code); widget.done(); return; } const work = res.data.items.filter((c) => c.type === "container"); const leaves = work.filter((c) => c.childrenCount === 0); const basis = leaves.length > 0 ? leaves : work; if (basis.length === 0) { widget.el.innerHTML = note("Kein Strukturplan hinterlegt."); widget.done(); return; } const now: number = Date.now(); let bac = 0, ev = 0, pv = 0, ac = 0; basis.forEach((c) => { const b: number = c.budget != null ? c.budget : (c.costPlan != null ? c.costPlan : 0); bac += b; ev += b * (c.progress / 100); ac += c.costActual != null ? c.costActual : 0; const s: number = Date.parse(c.startDate); const e: number = Date.parse(c.endDate); let frac = 1; if (!Number.isNaN(s) && !Number.isNaN(e) && e > s) { frac = Math.min(Math.max((now - s) / (e - s), 0), 1); } else if (!Number.isNaN(s) && now < s) { frac = 0; } pv += b * frac; }); const spi: number | null = pv > 0 ? ev / pv : null; const cpi: number | null = ac > 0 ? ev / ac : null; const eac: number = cpi !== null && cpi > 0 ? bac / cpi : bac; function tone(v: number | null): string { if (v === null) return C.sub; if (v >= 0.95) return C.green; if (v >= 0.85) return C.amber; return C.red; } function fmt(v: number | null): string { return v === null ? "—" : v.toLocaleString(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function gauge(label: string, v: number | null, hint: string): string { const color: string = tone(v); const pct: number = v === null ? 0 : (Math.min(Math.max(v, 0), 1.5) / 1.5) * 100; return `
${label}
${fmt(v)}
${hint}
`; } const spiText: string = spi === null ? "Keine Termindaten" : spi >= 1 ? Math.round((spi - 1) * 100) + " % vor Plan" : Math.round((1 - spi) * 100) + " % hinter Plan"; const cpiText: string = cpi === null ? "Noch keine Ist-Kosten" : cpi >= 1 ? Math.round((cpi - 1) * 100) + " % günstiger als geplant" : Math.round((1 / cpi - 1) * 100) + " % teurer als geplant"; const abw: number = eac - bac; function cell(label: string, value: string, color: string): string { return `
${value}
${label}
`; } widget.el.innerHTML = `
${gauge("Termin (SPI)", spi, spiText)} ${gauge("Kosten (CPI)", cpi, cpiText)}
${cell("Gesamtbudget", short(bac), C.text)} ${cell("Leistungswert", short(ev), C.text)} ${cell("Ist-Kosten", short(ac), C.text)} ${cell("Prognose Ende", short(eac), abw > 0 ? C.red : C.green)}
Basis: ${basis.length} Arbeitspakete. Der Sollwert wird zeitanteilig aus Start- und Endterminen gebildet.
`; widget.done(); })();