WORKSPACE.PM
ProjektEmpfohlen 6 × 8ProjektleitungControlling

Kostenverlauf (S-Kurve)

Zeichnet die aufgelaufenen Ist-Buchungen über die Zeit gegen Budget und einen linearen Sollverlauf. Zeigt auf einen Blick, ob das Geld schneller abfließt als geplant.

Zum Code
IST840.000,0 €JanFebMärAprMaiJunBudgetPlanIst

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

Wofür das gut ist

„Liegen wir im Budget" beantwortet ein Restbetrag erst am Projektende, und dann ist es zu spät. Diese Kachel legt drei Linien übereinander: das Budget als Deckel, den linearen Sollverlauf zwischen Start- und Endtermin und die tatsächlich aufgelaufenen Buchungen. Wo die Ist-Kurve über dem Soll liegt, fließt das Geld schneller ab als geplant - sichtbar im ersten Projektdrittel statt im letzten.

Was Sie in der Kachel sehen

  • Die gestrichelte Waagerechte ist das Projektbudget, die helle Gerade der lineare Sollverlauf zwischen Start- und Endtermin.
  • Die kräftige Kurve sind die kumulierten Ist-Buchungen; ihr Endpunkt ist der heutige Stand.
  • Liegt die Ist-Kurve über der Soll-Geraden, ist mehr Geld abgeflossen, als der verstrichene Zeitanteil hergibt.

Der vollständige Code

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

TypeScript134 Zeilen
Rohdatei
// Kostenverlauf (S-Kurve)
// Quellen: widget.data.project (Termine, Budget) + widget.query("project.costs")
// project.costs erfordert das Kostenrecht — sonst erscheint ein Hinweistext.

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",
  blue: "#3b82f6",
  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 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 queryError(code: string): string {
  return note(
    code === "forbidden" ? "Keine Berechtigung für die Kostendaten."
      : code === "unsupported_in_context" ? "In diesem Dashboard nicht verfügbar."
      : code === "timeout" ? "Zeitüberschreitung beim Laden."
      : "Kostendaten konnten nicht geladen werden.",
  );
}

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

const p = widget.data.project;
if (!p) {
  widget.el.innerHTML = note("Nur auf Projektebene verfügbar.");
  widget.done();
  return;
}

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

(async () => {
  const res = await widget.query("project.costs");
  if (!res.ok) {
    widget.el.innerHTML = queryError(res.error.code);
    widget.done();
    return;
  }

  const totals = res.data.totals;
  const budget: number = totals.budget > 0 ? totals.budget : p.budget;

  const sorted = res.data.bookings
    .map((b) => ({ t: Date.parse(b.date), v: b.amount }))
    .filter((b) => !Number.isNaN(b.t))
    .sort((a, b) => a.t - b.t);

  if (sorted.length === 0) {
    widget.el.innerHTML = note("Noch keine Kostenbuchungen erfasst.");
    widget.done();
    return;
  }

  let acc = 0;
  const curve = sorted.map((b) => { acc += b.v; return { t: b.t, v: acc }; });

  const t0: number = Date.parse(p.planStart) || curve[0].t;
  const t1: number = Date.parse(p.planEnd) || curve[curve.length - 1].t;
  const span: number = Math.max(t1 - t0, 86400000);
  const yMax: number = Math.max(budget, acc) * 1.08 || 1;

  const W = 320, H = 140, PL = 4, PR = 4, PT = 8, PB = 14;
  function px(t: number): string {
    return (PL + Math.min(Math.max((t - t0) / span, 0), 1) * (W - PL - PR)).toFixed(1);
  }
  function py(v: number): string {
    return (H - PB - Math.min(Math.max(v / yMax, 0), 1) * (H - PT - PB)).toFixed(1);
  }

  const pts = [{ t: t0, v: 0 }].concat(curve);
  const path: string = pts.map((pt, i) => `${i === 0 ? "M" : "L"} ${px(pt.t)} ${py(pt.v)}`).join(" ");
  const last = curve[curve.length - 1];
  const area: string = `${path} L ${px(last.t)} ${py(0)} L ${px(t0)} ${py(0)} Z`;

  const now: number = Date.now();
  const used: number = budget > 0 ? Math.round((totals.actual / budget) * 100) : 0;
  const rest: number = budget - totals.actual;
  const restColor: string = rest < 0 ? C.red : used >= 90 ? C.amber : C.green;

  function kpi(label: string, value: string, color: string): string {
    return `<div style="flex:1;min-width:0">
      <div style="font-size:14px;font-weight:700;color:${color};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${value}</div>
      <div style="font-size:10px;color:${C.sub}">${label}</div>
    </div>`;
  }

  function legend(color: string, label: string): string {
    return `<span style="white-space:nowrap"><span style="display:inline-block;width:12px;height:2px;background:${color};vertical-align:middle;margin-right:4px"></span>${label}</span>`;
  }

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;gap:10px;margin-bottom:8px">
        ${kpi("Budget", short(budget), C.text)}
        ${kpi("Ist", short(totals.actual), C.blue)}
        ${kpi("Verbraucht", used + " %", used > 100 ? C.red : C.text)}
        ${kpi("Rest", short(rest), restColor)}
      </div>
      <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block">
        <line x1="${PL}" y1="${py(budget)}" x2="${W - PR}" y2="${py(budget)}" stroke="${C.red}" stroke-width="1" stroke-dasharray="4 3" />
        <line x1="${px(t0)}" y1="${py(0)}" x2="${px(t1)}" y2="${py(budget)}" stroke="${C.sub}" stroke-width="1" stroke-dasharray="2 3" />
        ${now > t0 && now < t1 ? `<line x1="${px(now)}" y1="${PT}" x2="${px(now)}" y2="${H - PB}" stroke="${C.amber}" stroke-width="1" />` : ""}
        <path d="${area}" fill="${C.blue}" fill-opacity="0.15" />
        <path d="${path}" fill="none" stroke="${C.blue}" stroke-width="2" stroke-linejoin="round" />
        <line x1="${PL}" y1="${H - PB}" x2="${W - PR}" y2="${H - PB}" stroke="${C.line}" stroke-width="1" />
      </svg>
      <div style="display:flex;gap:10px;flex-wrap:wrap;font-size:10px;color:${C.sub};margin-top:6px">
        ${legend(C.blue, "Ist kumuliert")}
        ${legend(C.sub, "linearer Sollverlauf")}
        ${legend(C.red, "Budget")}
      </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.