WORKSPACE.PM
PortfolioEmpfohlen 6 × 9PMO

Berichtsdisziplin

Rechnet je Projekt aus, wie viele Tage der letzte Statusbericht zurückliegt, und stellt Nachzügler und nie berichtete Projekte nach oben.

Zum Code
TAGE SEIT LETZTEM BERICHT2nie62 T41 T22 T8 Tfällig

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

Wofür das gut ist

Der Nachfass-Zettel der PMO schreibt sich hier von selbst. Die Kachel rechnet je Projekt das Alter des letzten Statusberichts in Tagen aus und sortiert absteigend, sodass die Nachzügler oben stehen. Projekte, zu denen noch nie ein Bericht erstellt wurde, sind der härtere Fall und werden eigens ausgewiesen statt stillschweigend als „null Tage" durchzurutschen. Zwei Schwellen im Kopf des Codes bestimmen, ab wann ein Bericht fällig und ab wann er überfällig ist.

Was Sie in der Kachel sehen

  • Je Zeile ein Projekt, sortiert nach dem Alter seines letzten Statusberichts.
  • Der Balken ist das Berichtsalter in Tagen, die gestrichelte Linie die Fälligkeitsschwelle.
  • Projekte ohne jeden Bericht stehen ganz oben und sind eigens gekennzeichnet.

Der vollständige Code

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

TypeScript134 Zeilen
Rohdatei
// Berichtsdisziplin
// Quelle: widget.query("portfolio.projects") — reportDate ist leer,
// wenn zum Projekt noch nie ein Bericht erstellt wurde.

const STALE_DAYS = 30;  // ab hier gilt ein Bericht als überfällig
const DUE_DAYS = 14;    // ab hier wird er fällig

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 ageInDays(iso: string): number | null {
  if (!iso || iso === "") return null;
  const t: number = dayStart(iso);
  if (Number.isNaN(t)) return null;
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  return Math.max(0, Math.round((today.getTime() - t) / DAY));
}

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("Prüfe Berichtsstände …");

(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 active = loaded.items.filter((p) => !p.actualEnd || p.actualEnd === "");
  if (active.length === 0) {
    widget.el.innerHTML = note("Keine laufenden Projekte im Portfolio.");
    widget.done();
    return;
  }

  const scored = active.map((p) => ({ p: p, age: ageInDays(p.reportDate) }));
  const never = scored.filter((x) => x.age === null);
  const stale = scored.filter((x) => x.age !== null && (x.age as number) > STALE_DAYS);
  const due = scored.filter((x) => x.age !== null && (x.age as number) > DUE_DAYS && (x.age as number) <= STALE_DAYS);
  const fresh = scored.filter((x) => x.age !== null && (x.age as number) <= DUE_DAYS);

  const quote: number = Math.round((fresh.length / active.length) * 100);

  function tile(label: string, count: number, color: string): string {
    return `<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0;border-top:2px solid ${color}">
      <div style="font-size:20px;font-weight:700;color:${count > 0 ? color : C.sub}">${count}</div>
      <div style="font-size:10px;color:${C.sub}">${label}</div>
    </div>`;
  }

  // Sortierung: nie berichtet zuerst, dann nach Alter absteigend.
  const worst = never
    .concat(stale.slice().sort((a, b) => (b.age as number) - (a.age as number)))
    .concat(due.slice().sort((a, b) => (b.age as number) - (a.age as number)))
    .slice(0, 7);

  const rows: string = worst.map((x) => {
    const age = x.age;
    const color: string = age === null ? C.red : age > STALE_DAYS ? C.red : C.amber;
    const txt: string = age === null ? "nie berichtet" : "vor " + age + " Tagen";
    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="color:${C.sub};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px">${esc(x.p.projectLeadName || "ohne Leitung")}</span>
      <span style="min-width:88px;text-align:right;color:${color};font-weight:600;white-space:nowrap">${txt}</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:${quote >= 80 ? C.green : quote >= 50 ? C.amber : C.red}">${quote} %</span>
        <span style="font-size:11px;color:${C.sub}">der ${active.length} laufenden Projekte sind aktuell berichtet (≤ ${DUE_DAYS} Tage)</span>
      </div>
      <div style="display:flex;gap:6px;margin-bottom:10px">
        ${tile("aktuell", fresh.length, C.green)}
        ${tile("fällig", due.length, C.amber)}
        ${tile("überfällig", stale.length, C.red)}
        ${tile("nie berichtet", never.length, C.red)}
      </div>
      ${worst.length === 0
        ? note("Alle Projekte sind aktuell berichtet.")
        : `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Nachfassliste</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
STALE_DAYS30Ab wie vielen Tagen ohne Bericht ein Projekt als überfällig gilt.
DUE_DAYS14Ab wie vielen Tagen ohne Bericht ein Projekt als fällig markiert wird.
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.