WORKSPACE.PM
ProjektEmpfohlen 6 × 8ProjektleitungPMO

Chancen-Verfallsradar

Zeigt Chancen, deren Zeitfenster in den nächsten 30, 60 oder 90 Tagen zuläuft, und hebt die ohne Maßnahme hervor.

Zum Code
CHANCEN MIT ABLAUFENDEM ZEITFENSTER30 T60 T90 TIM ZEITFENSTER310.000,0 €30 T260 T390 T4mit Maßnahmeohne Maßnahme

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

Wofür das gut ist

Chancen verfallen still. Anders als ein Risiko meldet sich ein ungenutztes Zeitfenster nicht, es endet einfach. Diese Kachel macht daraus eine Frist mit Countdown: sie ordnet jede Chance nach dem Ende ihres Zeitfensters in einen der drei Ringe 30, 60 oder 90 Tage ein und hebt hervor, zu welcher noch keine Maßnahme läuft. Dazu steht der Wert, der in diesen Fenstern insgesamt auf dem Spiel steht.

Was Sie in der Kachel sehen

  • Je näher ein Punkt am Mittelpunkt liegt, desto eher läuft das Zeitfenster der Chance ab.
  • Ein Ring statt eines gefüllten Punktes markiert eine Chance ohne hinterlegte Maßnahme.
  • Rechts die Zahl der Chancen je Frist und der Wert, der insgesamt im Fenster steht.

Der vollständige Code

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

TypeScript126 Zeilen
Rohdatei
// Chancen-Verfallsradar
// Quelle: widget.query("project.opportunities")
// Bucket-Logik über windowEnd (Ende des Zeitfensters).

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 DAY = 86400000;

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

/** "YYYY-MM-DD" als LOKALE Mitternacht lesen. Date.parse würde reine
 *  Datumsangaben als UTC interpretieren — Tagesdifferenzen kippen dann je
 *  nach Zeitzone um einen Tag. */
function dayStart(iso: string): number {
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || "");
  return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() : NaN;
}

function today0(): number {
  const d = new Date();
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

function daysLeft(iso: string): number | null {
  const t: number = dayStart(iso);
  if (Number.isNaN(t)) return null;
  return Math.round((t - today0()) / DAY);
}

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

(async () => {
  const res = await widget.query("project.opportunities", { limit: 200 });
  if (!res.ok) {
    widget.el.innerHTML = note(
      res.error.code === "forbidden" ? "Keine Berechtigung für die Chancendaten."
        : res.error.code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar."
        : "Chancen konnten nicht geladen werden.",
    );
    widget.done();
    return;
  }

  const items = res.data.items;
  if (items.length === 0) {
    widget.el.innerHTML = note("Keine Chancen erfasst.");
    widget.done();
    return;
  }

  const withDays = items.map((o) => ({ o: o, d: daysLeft(o.windowEnd) }));

  const buckets = [
    { label: "abgelaufen", color: C.sub, hit: (d: number | null) => d !== null && d < 0 },
    { label: "≤ 30 Tage", color: C.red, hit: (d: number | null) => d !== null && d >= 0 && d <= 30 },
    { label: "≤ 60 Tage", color: C.amber, hit: (d: number | null) => d !== null && d > 30 && d <= 60 },
    { label: "≤ 90 Tage", color: C.green, hit: (d: number | null) => d !== null && d > 60 && d <= 90 },
  ];

  const tiles: string = buckets.map((b) => {
    const hits = withDays.filter((x) => b.hit(x.d));
    const value: number = hits.reduce((s, x) => s + x.o.benefit * (x.o.probability / 100), 0);
    return `<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0;border-top:2px solid ${b.color}">
      <div style="font-size:18px;font-weight:700;color:${hits.length > 0 ? b.color : C.sub}">${hits.length}</div>
      <div style="font-size:10px;color:${C.sub}">${b.label}</div>
      <div style="font-size:9px;color:${C.sub};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${short(value)}</div>
    </div>`;
  }).join("");

  const soon = withDays
    .filter((x) => x.d !== null && x.d <= 90)
    .sort((a, b) => (a.d as number) - (b.d as number))
    .slice(0, 6);

  const rows: string = soon.map((x) => {
    const d: number = x.d as number;
    const noMeasure: boolean = x.o.measureCount === 0;
    const color: string = d < 0 ? C.sub : d <= 30 ? C.red : d <= 60 ? C.amber : C.green;
    return `<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid ${C.line};font-size:11px">
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(x.o.title)}</span>
      ${noMeasure ? `<span style="flex-shrink:0;background:${C.red}22;color:${C.red};font-size:9px;font-weight:700;padding:1px 5px;border-radius:8px">ohne Maßnahme</span>` : ""}
      <span style="color:${C.sub};white-space:nowrap">${short(x.o.benefit * (x.o.probability / 100))}</span>
      <span style="color:${color};font-weight:600;white-space:nowrap;min-width:64px;text-align:right">${d < 0 ? Math.abs(d) + " T. vorbei" : "in " + d + " T."}</span>
    </div>`;
  }).join("");

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;gap:6px;margin-bottom:10px">${tiles}</div>
      ${soon.length === 0
        ? note("In den nächsten 90 Tagen läuft kein Chancenfenster aus.")
        : `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Nächste Fristen</div>${rows}`}
    </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.