PortfolioRecommended 6 × 9Executive managementControlling

Budget concentration (Pareto)

Sorts projects by budget and lays the cumulative curve over them: how few projects tie up 80 percent of the money.

BUDGET ACROSS THE PORTFOLIO€4.2M80%7 of 12 projects tie up 80 percent of the budgetLine: cumulative share of the total 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

Concentration risk stays invisible as long as the project list is sorted by status. This tile sorts by money: descending by individual budget, with the cumulative curve on top and a mark at 80 percent. Where the curve crosses that mark is the answer to how few projects the portfolio depends on financially - and therefore how much a single wrong decision can cost.

What the tile shows you

  • The bars are the individual budgets, sorted descending.
  • The line is the cumulative share of the total budget; the dashed mark sits at 80 percent.
  • The highlighted bars are the projects up to the crossing point - they tie up four fifths of the money.

The complete code

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

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

Adjusting it

ConstantDefaultMeaning
CURRENCY"EUR"Currency for monetary amounts. Set it to the tenant's currency.
TOP_N12How many projects are shown individually. The rest is reported as a combined entry.
MAX_PAGES3How many pages the query loads at most. Raise it if the portfolio holds more projects than the analysis covers.
  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.