WORKSPACE.PM
ProjektEmpfohlen 4 × 9ProjektleitungControlling

Risiko-Exposure in Euro

Multipliziert die Eintrittswahrscheinlichkeit jedes Risikos mit seiner Kostenwirkung und summiert das zum erwarteten Schaden - gestellt neben das Projektbudget.

Zum Code
ERWARTETER SCHADEN165.000,0 €62.000,0 €45.000,0 €29.000,0 €19.000,0 €10.000,0 €BUDGET1,1 Mio. €15 % des Projektbudgets

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

Wofür das gut ist

Eine Risikoliste ist eine Liste. Im Lenkungsausschuss trägt aber nur eine Zahl. Diese Kachel rechnet je Risiko den Erwartungswert aus Kostenwirkung und Eintrittswahrscheinlichkeit, summiert ihn über alle Risiken und stellt das Ergebnis neben das Budget des Projekts. Aus „wir haben da ein paar Risiken" wird damit ein Betrag und ein Anteil, über den sich entscheiden lässt.

Was Sie in der Kachel sehen

  • Oben der erwartete Gesamtschaden über alle bewerteten Risiken.
  • Darunter die größten Einzelrisiken als Balken, sortiert nach Erwartungswert.
  • Ganz unten der Vergleich mit dem Projektbudget: welcher Anteil des Geldes rechnerisch im Risiko steht.

Der vollständige Code

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

TypeScript124 Zeilen
Rohdatei
// Risiko-Exposure in Euro
// Quelle: widget.query("project.risks")
// Erwartungswert je Risiko = Kostenwirkung × Eintrittswahrscheinlichkeit / 100.

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 esc(s: string): string {
  return String(s).replace(/[&<>"]/g, (c: string) =>
    c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;");
}

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 die Risikodaten."
      : code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar."
      : "Risikodaten 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("Lade Risiken …");

(async () => {
  const res = await widget.query("project.risks", { limit: 200 });
  if (!res.ok) {
    widget.el.innerHTML = queryError(res.error.code);
    widget.done();
    return;
  }

  const all = res.data.items;
  const open = all.filter((r) => r.status !== "closed");
  const occurred = all.filter((r) => r.status === "occurred");

  if (open.length === 0) {
    widget.el.innerHTML = note("Keine offenen Risiken erfasst.");
    widget.done();
    return;
  }

  function expected(r: WidgetRisk): number {
    return r.impact * (r.probability / 100);
  }

  const evTotal: number = open.reduce((s, r) => s + expected(r), 0);
  const worstCase: number = open.reduce(
    (s, r) => s + (r.costImpactMax != null ? r.costImpactMax : r.impact) * (r.probability / 100),
    0,
  );
  const delayDays: number = open.reduce(
    (s, r) => s + (r.scheduleImpactDays != null ? r.scheduleImpactDays : 0) * (r.probability / 100),
    0,
  );

  const budget: number = widget.data.project ? widget.data.project.budget : 0;
  const share: number = budget > 0 ? (evTotal / budget) * 100 : 0;
  const shareColor: string = share >= 20 ? C.red : share >= 10 ? C.amber : C.green;

  const top = open.slice().sort((a, b) => expected(b) - expected(a)).slice(0, 5);
  const max: number = expected(top[0]) || 1;

  const rows: string = top.map((r) => {
    const v: number = expected(r);
    const w: number = Math.max(2, (v / max) * 100);
    return `<div style="margin-bottom:6px">
      <div style="display:flex;justify-content:space-between;gap:8px;font-size:11px;margin-bottom:2px">
        <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(r.title)}</span>
        <span style="color:${C.sub};white-space:nowrap">${r.probability} % · ${short(v)}</span>
      </div>
      <div style="height:5px;background:${C.line};border-radius:3px;overflow:hidden">
        <div style="height:100%;width:${w.toFixed(0)}%;background:${C.red};opacity:.8"></div>
      </div>
    </div>`;
  }).join("");

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;align-items:flex-end;gap:12px;margin-bottom:4px">
        <div>
          <div style="font-size:26px;font-weight:700;line-height:1.1;color:${C.red}">${short(evTotal)}</div>
          <div style="font-size:10px;color:${C.sub}">erwarteter Schaden aus ${open.length} offenen Risiken</div>
        </div>
        <div style="margin-left:auto;text-align:right">
          <div style="font-size:16px;font-weight:700;color:${shareColor}">${share.toFixed(1)} %</div>
          <div style="font-size:10px;color:${C.sub}">vom Budget</div>
        </div>
      </div>
      <div style="display:flex;gap:10px;padding:8px 0;margin-bottom:6px;border-top:1px solid ${C.line};border-bottom:1px solid ${C.line}">
        <div style="flex:1"><div style="font-size:12px;font-weight:600">${short(worstCase)}</div><div style="font-size:10px;color:${C.sub}">obere Bandbreite</div></div>
        <div style="flex:1"><div style="font-size:12px;font-weight:600">${Math.round(delayDays)} Tage</div><div style="font-size:10px;color:${C.sub}">erwarteter Verzug</div></div>
        <div style="flex:1"><div style="font-size:12px;font-weight:600;color:${occurred.length > 0 ? C.amber : C.text}">${occurred.length}</div><div style="font-size:10px;color:${C.sub}">bereits eingetreten</div></div>
      </div>
      <div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:5px">Größte Einzelrisiken</div>
      ${rows}
      ${res.data.truncated ? `<div style="font-size:10px;color:${C.sub};margin-top:4px">Auszug — es existieren weitere Risiken.</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.