ProjectRecommended 4 × 9Project managementControlling

Risk exposure in currency

Multiplies each risk's probability by its cost impact and sums that into an expected loss - placed next to the project budget.

EXPECTED EXPOSURE€165.0K€62.0K€45.0K€29.0K€19.0K€10.0KBUDGET€1.1M15% of the project budget

A rebuild in this website's style. The real tile picks up your tenant's fonts and color scheme.

What it is good for

A risk register is a list. In a steering committee, only a number carries. This tile computes the expected value of each risk from its cost impact and probability, sums it across all risks and places the result next to the project's budget. 'We have a few risks' turns into an amount and a share that can actually be decided on.

What the tile shows you

  • At the top, the expected total loss across all assessed risks.
  • Below it, the largest individual risks as bars, sorted by expected value.
  • At the bottom, the comparison with the project budget: what share of the money is at risk in arithmetic terms.

The complete code

Self-contained, with no external library. Paste it into the editor, save, done.

TypeScript124 lines
Raw file
// 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();
})();

Adjusting it

ConstantDefaultMeaning
CURRENCY"EUR"Currency for monetary amounts. Set it to the tenant's currency.
  1. 1Create the widget
  2. 2Paste the code
  3. 3Set the size and save

Build your own tile

Try WORKSPACE.PM for thirty days with every feature. The templates on this page work in a trial tenant exactly as they do in production.