ProjectRecommended 6 × 8Project managementPMO

Opportunity expiry radar

Shows opportunities whose window closes within the next 30, 60 or 90 days, and highlights those without a measure.

OPPORTUNITIES WITH A CLOSING WINDOW30 d60 d90 dIN THE WINDOW€310.0K30 d260 d390 d4with measureno measure

A rebuild in this website's style. The real tile picks up your tenant's fonts and color scheme.

What it is good for

Opportunities expire quietly. Unlike a risk, an unused window does not announce itself - it simply ends. This tile turns that into a deadline with a countdown: it sorts each opportunity into one of the three rings 30, 60 or 90 days by the end of its window and highlights the ones with no measure running. Alongside it stands the value at stake across those windows.

What the tile shows you

  • The closer a dot sits to the center, the sooner the opportunity's window closes.
  • A ring instead of a filled dot marks an opportunity with no recorded measure.
  • On the right, the number of opportunities per deadline and the total value in the window.

The complete code

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

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

Adjusting it

ConstantDefaultMeaning
CURRENCY"EUR"Currency for monetary amounts. Set it to the tenant's currency.
  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.