WORKSPACE.PM
PortfolioEmpfohlen 5 × 11Geschäftsführung

Nutzen-Dringlichkeits-Matrix

Verteilt die Projekte auf vier Felder aus Nutzenbewertung und Dringlichkeit, die Blasengröße ist das Budget.

Zum Code
NUTZEN UND DRINGLICHKEITDringlichkeit →Nutzen →Blasengröße entspricht dem Budget

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

Wofür das gut ist

Ob das Geld in den Projekten mit dem größten Nutzen steckt, lässt sich aus einer Tabelle nicht ablesen. Diese Kachel zeigt es in einem Bild: jedes Projekt eine Blase, waagerecht die Dringlichkeit, senkrecht der Nutzen, die Größe das Budget. Beide Skalen laufen von eins bis zehn, die Trennlinien liegen bei 5,5. Eine große Blase unten rechts ist der Befund - viel Geld in etwas Dringendem ohne belegten Nutzen.

Was Sie in der Kachel sehen

  • Waagerecht die Dringlichkeit, senkrecht der Nutzen, beide auf der Skala von eins bis zehn.
  • Jede Blase ist ein Projekt, ihre Fläche entspricht dem Budget.
  • Oben rechts steht, was Nutzen und Dringlichkeit verbindet; unten rechts, was nur laut ist.

Der vollständige Code

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

TypeScript136 Zeilen
Rohdatei
// Nutzen-Dringlichkeits-Matrix
// Quelle: widget.query("portfolio.projects")
// Skala von Dringlichkeit und Nutzen ist 1–10; die Trennlinien liegen bei 5,5.

const CURRENCY = "EUR"; // an die Mandantenwährung anpassen

const isDark: boolean = widget.data.theme === "dark";
const C = {
  text: isDark ? "#f1f5f9" : "#1e293b",
  sub: "#94a3b8",
  line: isDark ? "#334155" : "#e2e8f0",
  quad: isDark ? "#1e293b" : "#f8fafc",
  green: "#22c55e",
  amber: "#f59e0b",
  red: "#ef4444",
  blue: "#3b82f6",
};

const MAX_PAGES = 3;

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;
}

async function loadProjects() {
  const items: WidgetPortfolioProject[] = [];
  let cursor: string | undefined = undefined;
  let total: number | null = null;
  for (let page = 0; page < MAX_PAGES; page++) {
    const res = await widget.query("portfolio.projects", { limit: 200, cursor: cursor });
    if (!res.ok) return { error: res.error.code, items: items, total: total, complete: false };
    if (total === null) total = res.data.total;
    res.data.items.forEach((p) => items.push(p));
    if (!res.data.nextCursor) return { error: null, items: items, total: total, complete: true };
    cursor = res.data.nextCursor;
  }
  return { error: null, items: items, total: total, complete: false };
}

widget.el.innerHTML = note("Lade Bewertungen …");

(async () => {
  const loaded = await loadProjects();
  if (loaded.error) {
    widget.el.innerHTML = note(
      loaded.error === "forbidden" ? "Keine Berechtigung für die Portfoliodaten."
        : loaded.error === "unsupported_in_context" ? "Nur auf Portfolioebene verfügbar."
        : "Projekte konnten nicht geladen werden.",
    );
    widget.done();
    return;
  }

  const rated = loaded.items.filter((p) => p.urgency > 0 && p.benefitRating > 0);
  if (rated.length === 0) {
    widget.el.innerHTML = note("Für keines der Projekte sind Dringlichkeit und Nutzen bewertet.");
    widget.done();
    return;
  }

  const W = 320, H = 210, PAD = 22;
  const maxBudget: number = Math.max.apply(null, rated.map((p) => p.budget)) || 1;

  function sx(v: number): number { return PAD + ((v - 1) / 9) * (W - PAD - 10); }
  function sy(v: number): number { return H - PAD - ((v - 1) / 9) * (H - PAD - 10); }
  function radius(budget: number): number { return 3 + Math.sqrt(Math.max(budget, 0) / maxBudget) * 9; }

  const midX: number = sx(5.5);
  const midY: number = sy(5.5);

  const dots: string = rated.map((p) => {
    const color: string = p.benefitRating >= 5.5
      ? (p.urgency >= 5.5 ? C.green : C.blue)
      : (p.urgency >= 5.5 ? C.amber : C.sub);
    return `<circle cx="${sx(p.urgency).toFixed(1)}" cy="${sy(p.benefitRating).toFixed(1)}" r="${radius(p.budget).toFixed(1)}" fill="${color}" fill-opacity="0.45" stroke="${color}" stroke-width="1" />`;
  }).join("");

  function quadrant(label: string, count: number, budget: number, color: string): string {
    return `<div style="flex:1;min-width:0">
      <div style="font-size:13px;font-weight:700;color:${color}">${count}</div>
      <div style="font-size:9px;color:${C.sub};line-height:1.25">${label}<br>${short(budget)}</div>
    </div>`;
  }

  function bucket(hiU: boolean, hiB: boolean) {
    const set = rated.filter((p) => (p.urgency >= 5.5) === hiU && (p.benefitRating >= 5.5) === hiB);
    return { count: set.length, budget: set.reduce((s, p) => s + p.budget, 0) };
  }

  const qA = bucket(true, true);
  const qB = bucket(false, true);
  const qC = bucket(true, false);
  const qD = bucket(false, false);

  widget.el.innerHTML = `
    <div style="padding:8px 12px;color:${C.text}">
      <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block">
        <rect x="${midX}" y="10" width="${W - 10 - midX}" height="${midY - 10}" fill="${C.green}" fill-opacity="0.05" />
        <line x1="${midX}" y1="10" x2="${midX}" y2="${H - PAD}" stroke="${C.line}" stroke-width="1" stroke-dasharray="3 3" />
        <line x1="${PAD}" y1="${midY}" x2="${W - 10}" y2="${midY}" stroke="${C.line}" stroke-width="1" stroke-dasharray="3 3" />
        <line x1="${PAD}" y1="${H - PAD}" x2="${W - 10}" y2="${H - PAD}" stroke="${C.line}" stroke-width="1" />
        <line x1="${PAD}" y1="10" x2="${PAD}" y2="${H - PAD}" stroke="${C.line}" stroke-width="1" />
        ${dots}
        <text x="${W - 10}" y="${H - 6}" text-anchor="end" font-size="9" fill="${C.sub}">Dringlichkeit →</text>
        <text x="6" y="16" font-size="9" fill="${C.sub}">Nutzen ↑</text>
      </svg>
      <div style="display:flex;gap:8px;margin-top:6px;padding-top:6px;border-top:1px solid ${C.line}">
        ${quadrant("Jetzt umsetzen", qA.count, qA.budget, C.green)}
        ${quadrant("Einplanen", qB.count, qB.budget, C.blue)}
        ${quadrant("Hinterfragen", qC.count, qC.budget, C.amber)}
        ${quadrant("Zurückstellen", qD.count, qD.budget, C.sub)}
      </div>
      <div style="font-size:10px;color:${C.sub};margin-top:5px">
        ${rated.length} bewertete Projekte, Blasengröße = Budget.
        ${loaded.items.length - rated.length > 0 ? ` ${loaded.items.length - rated.length} ohne Bewertung ausgelassen.` : ""}
      </div>
    </div>`;
  widget.done();
})();

Anpassen

KonstanteVorbelegungBedeutung
CURRENCY"EUR"Währung der Geldbeträge. Auf die Währung des Mandanten setzen.
MAX_PAGES3Wie viele Seiten die Abfrage höchstens lädt. Höher setzen, wenn das Portfolio mehr Projekte hat, als die Auswertung erfasst.
  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.