WORKSPACE.PM
ProjektEmpfohlen 6 × 8PMOQualität und Revision

Maßnahmen-Lückenwächter

Findet alle Risiken und Chancen mit hoher Bewertung, zu denen keine einzige Maßnahme hinterlegt ist, und zählt sie als Governance-Lücke auf.

Zum Code
HOCH BEWERTET, OHNE MASSNAHME3Auswirkung →Wahrscheinlichkeit →ohne Maßnahmeohne Maßnahmeohne Maßnahmemit Maßnahmeohne Maßnahme

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

Wofür das gut ist

Ein hoch bewertetes Risiko ohne Maßnahme ist der Befund, nach dem im Audit als Erstes gefragt wird. Diese Kachel sucht ihn selbst: sie prüft Risiken und Chancen gemeinsam, filtert auf hohe Bewertung und behält nur die, zu denen keine einzige Maßnahme hinterlegt ist. Beide Abfragen laufen parallel, die Kachel ist also genauso schnell wie eine einzelne.

Was Sie in der Kachel sehen

  • Links das Bewertungsfeld aus Eintrittswahrscheinlichkeit und Auswirkung mit allen bewerteten Einträgen.
  • Ein Ring statt eines gefüllten Punktes markiert einen Eintrag ohne jede Maßnahme.
  • Rechts die Lücken als Liste, mit dem Namen des Risikos oder der Chance.

Der vollständige Code

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

TypeScript112 Zeilen
Rohdatei
// Maßnahmen-Lückenwächter
// Quellen: widget.query("project.risks") + widget.query("project.opportunities")
// Beide Abfragen laufen parallel (das Budget erlaubt bis zu 4 gleichzeitig).

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 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("Prüfe Maßnahmenabdeckung …");

(async () => {
  const results = await Promise.all([
    widget.query("project.risks", { limit: 200 }),
    widget.query("project.opportunities", { limit: 200 }),
  ]);
  const riskRes = results[0];
  const oppRes = results[1];

  if (!riskRes.ok && !oppRes.ok) {
    widget.el.innerHTML = note(
      riskRes.error.code === "forbidden"
        ? "Keine Berechtigung für Risiken und Chancen."
        : riskRes.error.code === "unsupported_in_context"
          ? "Nur auf Projektebene verfügbar."
          : "Daten konnten nicht geladen werden.",
    );
    widget.done();
    return;
  }

  const risks = riskRes.ok ? riskRes.data.items.filter((r) => r.status !== "closed") : [];
  const opps = oppRes.ok ? oppRes.data.items : [];

  const riskGaps = risks.filter((r) => r.measureCount === 0);
  const oppGaps = opps.filter((o) => o.measureCount === 0);

  type Gap = { kind: "risk" | "opp"; title: string; value: number; prob: number };
  const gaps: Gap[] = riskGaps
    .map((r) => ({ kind: "risk" as const, title: r.title, value: r.impact * (r.probability / 100), prob: r.probability }))
    .concat(oppGaps.map((o) => ({ kind: "opp" as const, title: o.title, value: o.benefit * (o.probability / 100), prob: o.probability })))
    .sort((a, b) => b.value - a.value);

  const exposed: number = riskGaps.reduce((s, r) => s + r.impact * (r.probability / 100), 0);

  function ratio(gapCount: number, total: number): string {
    if (total === 0) return "—";
    return gapCount + "/" + total;
  }

  function tile(label: string, gapCount: number, total: number, color: string, allowed: boolean): string {
    const pct: number = total > 0 ? Math.round((gapCount / total) * 100) : 0;
    return `<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0">
      <div style="font-size:18px;font-weight:700;color:${!allowed ? C.sub : gapCount > 0 ? color : C.green}">${allowed ? ratio(gapCount, total) : "n. v."}</div>
      <div style="font-size:10px;color:${C.sub}">${label}${allowed && total > 0 ? ` · ${pct} %` : ""}</div>
    </div>`;
  }

  const list: string = gaps.slice(0, 6).map((g) => `
    <div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid ${C.line};font-size:11px">
      <span style="width:3px;height:16px;flex-shrink:0;border-radius:2px;background:${g.kind === "risk" ? C.red : C.green}"></span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(g.title)}</span>
      <span style="color:${C.sub};white-space:nowrap">${g.prob} %</span>
      <span style="color:${g.kind === "risk" ? C.red : C.green};font-weight:600;white-space:nowrap;min-width:72px;text-align:right">${short(g.value)}</span>
    </div>`).join("");

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;gap:8px;margin-bottom:8px">
        ${tile("Risiken ohne Maßnahme", riskGaps.length, risks.length, C.red, riskRes.ok)}
        ${tile("Chancen ohne Maßnahme", oppGaps.length, opps.length, C.amber, oppRes.ok)}
        <div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0">
          <div style="font-size:18px;font-weight:700;color:${exposed > 0 ? C.red : C.green};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${short(exposed)}</div>
          <div style="font-size:10px;color:${C.sub}">ungesteuertes Risiko</div>
        </div>
      </div>
      ${gaps.length === 0
        ? note("Zu allen Risiken und Chancen sind Maßnahmen hinterlegt.")
        : `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Größte Lücken</div>${list}`}
      ${gaps.length > 6 ? `<div style="font-size:10px;color:${C.sub};margin-top:5px">${gaps.length - 6} weitere Einträge ohne Maßnahme.</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.