ProjectRecommended 6 × 8PMOQuality and audit

Missing-measure watchdog

Finds every highly rated risk and opportunity with not a single recorded measure, and lists them as a governance gap.

HIGH RATING, NO MEASURE3Impact →Probability →no measureno measureno measurewith measureno measure

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 highly rated risk with no measure is the finding an audit asks about first. This tile looks for it on its own: it checks risks and opportunities together, filters on a high rating and keeps only those without a single recorded measure. Both queries run in parallel, so the tile is no slower than a single one.

What the tile shows you

  • On the left, the assessment grid of probability and impact with every rated entry.
  • A ring instead of a filled dot marks an entry without any measure at all.
  • On the right, the gaps as a list, each with the name of the risk or opportunity.

The complete code

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

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

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.