WORKSPACE.PM
ProjektEmpfohlen 6 × 7ControllingPMOGeschäftsführung

Leistungswert-Ampel (Earned Value)

Rechnet aus Fortschritt, Plan- und Ist-Kosten des Strukturplans die beiden Klassiker SPI und CPI und zeigt sie als Ampeln mit Klartext-Deutung.

Zum Code
LEISTUNGSWERTSPICPI0,921,04HOCHRECHNUNG1,2 Mio. €

Nachbau im Stil dieser Website. Die echte Kachel übernimmt Schriften und Farbschema Ihres Mandanten.

Wofür das gut ist

„65 Prozent fertig, 80 Prozent Budget verbraucht" sind zwei Zahlen, aber noch keine Aussage. Die Earned-Value-Rechnung macht daraus zwei belastbare Kennzahlen: den Terminindex SPI und den Kostenindex CPI, beide bezogen auf den Leistungswert der tatsächlich erbrachten Arbeit. Rechenbasis sind ausschließlich Arbeitspakete ohne Unterelemente, damit kein Elternknoten seine Kinder ein zweites Mal zählt. Aus dem CPI leitet die Kachel zusätzlich eine Hochrechnung der Endkosten ab.

Was Sie in der Kachel sehen

  • Links der Terminindex SPI, rechts der Kostenindex CPI, jeweils mit Nadel und Farbe statt nackter Zahl.
  • Die Markierung auf jedem Zifferblatt ist der Wert 1,0 - die Grenze zwischen „im Plan" und „daneben".
  • Unten die Hochrechnung der Endkosten, aus dem bisherigen Kostenindex fortgeschrieben.

Der vollständige Code

Eigenständig und ohne externe Bibliothek. In den Editor einfügen, speichern, fertig.

TypeScript144 Zeilen
Rohdatei
// 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 `<div style="display:flex;align-items:center;justify-content:center;height:100%;min-height:60px;padding:12px;font-size:12px;color:${C.sub};text-align:center">${text}</div>`;
}

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 `<div style="flex:1;background:${C.card};border-radius:8px;padding:10px 12px;min-width:0">
      <div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em">${label}</div>
      <div style="font-size:26px;font-weight:700;line-height:1.15;color:${color}">${fmt(v)}</div>
      <div style="height:4px;background:${C.line};border-radius:2px;overflow:hidden;margin:6px 0 5px">
        <div style="height:100%;width:${pct.toFixed(0)}%;background:${color}"></div>
      </div>
      <div style="font-size:10px;color:${C.sub};line-height:1.3">${hint}</div>
    </div>`;
  }

  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 `<div style="flex:1;min-width:0">
      <div style="font-size:12px;font-weight:600;color:${color};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${value}</div>
      <div style="font-size:10px;color:${C.sub}">${label}</div>
    </div>`;
  }

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;gap:8px;margin-bottom:10px">
        ${gauge("Termin (SPI)", spi, spiText)}
        ${gauge("Kosten (CPI)", cpi, cpiText)}
      </div>
      <div style="display:flex;gap:10px;padding-top:8px;border-top:1px solid ${C.line}">
        ${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)}
      </div>
      <div style="font-size:10px;color:${C.sub};margin-top:8px;line-height:1.35">
        Basis: ${basis.length} Arbeitspakete. Der Sollwert wird zeitanteilig aus
        Start- und Endterminen gebildet.
      </div>
    </div>`;
  widget.done();
})();

Anpassen

KonstanteVorbelegungBedeutung
CURRENCY"EUR"Währung der Geldbeträge. Auf die Währung des Mandanten setzen.
  1. 1Widget anlegen
  2. 2Code einfügen
  3. 3Größe setzen und speichern

Bauen Sie Ihre eigene Kachel

Testen Sie WORKSPACE.PM dreißig Tage lang mit allen Funktionen. Die Vorlagen dieser Seite funktionieren im Testmandanten genauso wie in der Produktivumgebung.