// 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 ``;
}
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();
})();