WORKSPACE.PM
PortfolioEmpfohlen 6 × 9GeschäftsführungControlling

Budget-Konzentration (Pareto)

Sortiert die Projekte nach Budget und legt die kumulierte Kurve darüber: wie wenige Projekte binden 80 Prozent des Geldes.

Zum Code
BUDGET IM PORTFOLIO4,2 Mio. €80 %7 von 12 Projekten binden 80 Prozent des BudgetsLinie: kumulierter Anteil am Gesamtbudget

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

Wofür das gut ist

Klumpenrisiken bleiben unsichtbar, solange die Projektliste nach Status sortiert ist. Diese Kachel sortiert nach Geld: absteigend nach Einzelbudget, mit der kumulierten Kurve darüber und einer Marke bei 80 Prozent. Wo die Kurve diese Marke schneidet, steht die Antwort auf die Frage, von wie wenigen Projekten das Portfolio finanziell abhängt - und damit, wie viel eine einzelne Fehlentscheidung kosten kann.

Was Sie in der Kachel sehen

  • Die Balken sind die Einzelbudgets, absteigend sortiert.
  • Die Linie ist der kumulierte Anteil am Gesamtbudget, die gestrichelte Marke liegt bei 80 Prozent.
  • Die hervorgehobenen Balken sind die Projekte bis zum Schnittpunkt - sie binden vier Fünftel des Geldes.

Der vollständige Code

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

TypeScript124 Zeilen
Rohdatei
// Budget-Konzentration (Pareto)
// Quelle: widget.query("portfolio.projects")
// Balken = Einzelbudget der größten Projekte, Linie = kumulierter Anteil.

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

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

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("Berechne Budgetverteilung …");

(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 funded = loaded.items.filter((p) => p.budget > 0).sort((a, b) => b.budget - a.budget);
  if (funded.length === 0) {
    widget.el.innerHTML = note("Für kein Projekt ist ein Budget hinterlegt.");
    widget.done();
    return;
  }

  const total: number = funded.reduce((s, p) => s + p.budget, 0);

  // Wie viele Projekte machen 80 Prozent des Budgets aus?
  let running = 0;
  let count80 = 0;
  for (let i = 0; i < funded.length; i++) {
    running += funded[i].budget;
    count80 = i + 1;
    if (running / total >= 0.8) break;
  }
  const shareOfCount: number = Math.round((count80 / funded.length) * 100);

  const shown = funded.slice(0, TOP_N);
  const maxBudget: number = shown[0].budget;

  let cum = 0;
  const rows: string = shown.map((p, i) => {
    cum += p.budget;
    const cumPct: number = (cum / total) * 100;
    const w: number = (p.budget / maxBudget) * 100;
    const inTop80: boolean = i < count80;
    return `<div style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:11px">
      <span style="width:96px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(p.name)}</span>
      <span style="flex:1;height:9px;background:${C.line};border-radius:2px;overflow:hidden;position:relative">
        <span style="display:block;height:100%;width:${w.toFixed(1)}%;background:${inTop80 ? C.blue : C.sub};opacity:${inTop80 ? "1" : ".5"}"></span>
      </span>
      <span style="width:66px;flex-shrink:0;text-align:right;color:${C.sub};white-space:nowrap;font-variant-numeric:tabular-nums">${short(p.budget)}</span>
      <span style="width:34px;flex-shrink:0;text-align:right;color:${cumPct >= 80 ? C.amber : C.sub};font-variant-numeric:tabular-nums">${cumPct.toFixed(0)}%</span>
    </div>`;
  }).join("");

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px">
        <span style="font-size:26px;font-weight:700;color:${shareOfCount <= 30 ? C.red : shareOfCount <= 50 ? C.amber : C.green}">${count80}</span>
        <span style="font-size:11px;color:${C.sub}">von ${funded.length} Projekten binden 80 % des Budgets (${shareOfCount} % der Projekte, ${short(total)} gesamt)</span>
      </div>
      <div style="display:flex;gap:6px;font-size:9px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;padding-bottom:3px;border-bottom:1px solid ${C.line};margin-bottom:3px">
        <span style="width:96px">Projekt</span><span style="flex:1">Budget</span>
        <span style="width:66px;text-align:right">Betrag</span><span style="width:34px;text-align:right">kum.</span>
      </div>
      ${rows}
      ${funded.length > TOP_N ? `<div style="font-size:10px;color:${C.sub};margin-top:5px">${funded.length - TOP_N} weitere Projekte mit kleinerem Budget.</div>` : ""}
      ${!loaded.complete ? `<div style="font-size:10px;color:${C.sub};margin-top:4px">Auswertung über ${loaded.items.length} von ${loaded.total ?? "?"} Projekten.</div>` : ""}
    </div>`;
  widget.done();
})();

Anpassen

KonstanteVorbelegungBedeutung
CURRENCY"EUR"Währung der Geldbeträge. Auf die Währung des Mandanten setzen.
TOP_N12Wie viele Projekte einzeln dargestellt werden. Der Rest wird als Sammelposten ausgewiesen.
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.