WORKSPACE.PM
PortfolioEmpfohlen 6 × 9PMO

Fristen-Radar 30/60/90

Gruppiert alle Projekte danach, wann sie planmäßig enden, und markiert die, deren Fortschritt dafür zu niedrig ist.

Zum Code
LANDELAST1230 T360 T490 T5gefährdetbeobachtenim Plan

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

Wofür das gut ist

Die Landelast der nächsten drei Monate steht in keiner Liste, weil Projekte nach Status sortiert sind und nicht nach Endtermin. Diese Kachel sortiert nach der Frist: welche Projekte in 30, 60 und 90 Tagen planmäßig enden - und welche davon einen Fortschritt haben, der dazu nicht passt. Abgeschlossene Projekte mit Ist-Ende bleiben außen vor. Das Ergebnis ist die Eskalationsliste, bevor jemand eskaliert.

Was Sie in der Kachel sehen

  • Drei Spalten für die Fenster 30, 60 und 90 Tage, jeweils mit der Zahl der Projekte darin.
  • Je Eintrag ein Projekt; die Farbe zeigt, ob der Fortschritt zum Resttermin passt.
  • Markierte Einträge sind die, deren Fortschritt für die verbleibende Zeit zu niedrig ist.

Der vollständige Code

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

TypeScript128 Zeilen
Rohdatei
// Fristen-Radar 30/60/90
// Quelle: widget.query("portfolio.projects") — seitenweise über die volle Menge.
// Abgeschlossene Projekte (mit Ist-Ende) bleiben außen vor.

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 MAX_PAGES = 3;

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

/** "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);
}

/** Lädt bis zu MAX_PAGES Seiten à 200 Projekte über den Cursor. */
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("Lade Projekttermine …");

(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 running = loaded.items.filter((p) => !p.actualEnd || p.actualEnd === "");
  const withDays = running
    .map((p) => ({ p: p, d: daysLeft(p.planEnd) }))
    .filter((x) => x.d !== null) as { p: WidgetPortfolioProject; d: number }[];

  const buckets = [
    { label: "überfällig", color: C.red, hit: (d: number) => d < 0 },
    { label: "≤ 30 Tage", color: C.amber, hit: (d: number) => d >= 0 && d <= 30 },
    { label: "31–60 Tage", color: C.green, hit: (d: number) => d > 30 && d <= 60 },
    { label: "61–90 Tage", color: C.sub, hit: (d: number) => d > 60 && d <= 90 },
  ];

  const tiles: string = buckets.map((b) => {
    const hits = withDays.filter((x) => b.hit(x.d));
    const critical = hits.filter((x) => x.p.progress < 80).length;
    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:20px;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:${critical > 0 ? C.red : C.sub}">${critical} unter 80 %</div>
    </div>`;
  }).join("");

  const soon = withDays
    .filter((x) => x.d <= 90)
    .sort((a, b) => a.d - b.d)
    .slice(0, 7);

  const rows: string = soon.map((x) => {
    const color: string = x.d < 0 ? C.red : x.d <= 30 ? C.amber : C.sub;
    const pct: number = Math.min(Math.max(x.p.progress, 0), 100);
    const barColor: string = pct >= 80 ? C.green : pct >= 50 ? C.amber : C.red;
    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.p.name)}</span>
      <span style="width:52px;flex-shrink:0;height:5px;background:${C.line};border-radius:3px;overflow:hidden">
        <span style="display:block;height:100%;width:${pct}%;background:${barColor}"></span>
      </span>
      <span style="width:32px;flex-shrink:0;text-align:right;color:${C.sub};font-variant-numeric:tabular-nums">${pct}%</span>
      <span style="min-width:70px;text-align:right;color:${color};font-weight:600;white-space:nowrap">${x.d < 0 ? Math.abs(x.d) + " T. über" : "in " + x.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 endet planmäßig kein Projekt.")
        : `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Nächste Endtermine</div>${rows}`}
      ${!loaded.complete ? `<div style="font-size:10px;color:${C.sub};margin-top:6px">Auswertung über ${loaded.items.length} von ${loaded.total ?? "?"} Projekten.</div>` : ""}
    </div>`;
  widget.done();
})();

Anpassen

KonstanteVorbelegungBedeutung
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.