WORKSPACE.PM
PortfolioEmpfohlen 6 × 9PMOQualität und RevisionGeschäftsführung

Ampel-Widerspruchsdetektor

Sucht Projekte, die auf Grün gemeldet sind, obwohl Enddatum, Fortschritt oder Berichtsalter dagegen sprechen.

Zum Code
WIDERSPRÜCHE ZUR AMPEL4gemeldet!Endtermin überschritten-12 T!Fortschritt hinter Zeitverlauf-34 %!Bericht veraltet62 TDatenlage widerspricht der Meldung

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

Wofür das gut ist

Schönwetter-Reporting fällt selten auf, weil niemand die Meldung gegen die Daten hält. Genau das tut diese Kachel: sie nimmt die grüne Ampel aus dem jeweils letzten Bericht und prüft drei Gegenanzeigen - ist das Enddatum überschritten, liegt der Fortschritt weit hinter dem verstrichenen Zeitanteil, ist der Bericht selbst zu alt, um noch etwas zu belegen. Wie weit der Rückstand gehen darf und ab wann ein Bericht veraltet ist, steht als Schwelle im Kopf des Codes.

Was Sie in der Kachel sehen

  • Links die gemeldete Ampel des Projekts, so wie sie im letzten Bericht steht.
  • Rechts die Gegenanzeigen aus den Daten, jede mit ihrem Ausmaß beziffert.
  • Unten das Fazit: die Datenlage trägt die gemeldete Farbe nicht.

Der vollständige Code

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

TypeScript140 Zeilen
Rohdatei
// Ampel-Widerspruchsdetektor
// Quelle: widget.query("portfolio.projects")
// Ampelwerte kommen aus dem jeweils letzten Report: "green" | "yellow" | "red" | ""

const LAG_TOLERANCE = 20; // Prozentpunkte Rückstand, ab denen Grün unglaubwürdig wird
const REPORT_STALE_DAYS = 45;

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

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 Ampeln gegen die Fakten …");

(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 now: number = Date.now();
  const midnight = new Date();
  midnight.setHours(0, 0, 0, 0);
  const today: number = midnight.getTime();

  const active = loaded.items.filter((p) => !p.actualEnd || p.actualEnd === "");
  const reported = active.filter((p) => p.termin !== "" || p.kosten !== "" || p.leistung !== "");

  type Finding = { name: string; lead: string; reason: string };
  const findings: Finding[] = [];

  active.forEach((p) => {
    const end: number = dayStart(p.planEnd);
    const start: number = dayStart(p.planStart);
    const reasons: string[] = [];

    // 1. Grün gemeldet, Endtermin trotzdem vorbei.
    if (p.termin === "green" && !Number.isNaN(end) && end < today) {
      reasons.push("Endtermin seit " + Math.round((today - end) / DAY) + " Tagen überschritten");
    }

    // 2. Grün gemeldet, Fortschritt hinkt dem Zeitverlauf hinterher.
    if ((p.termin === "green" || p.leistung === "green") && !Number.isNaN(start) && !Number.isNaN(end) && end > start) {
      const elapsed: number = Math.min(Math.max((now - start) / (end - start), 0), 1) * 100;
      if (elapsed - p.progress > LAG_TOLERANCE) {
        reasons.push(Math.round(elapsed - p.progress) + " Punkte hinter dem Zeitverlauf");
      }
    }

    // 3. Alles grün, aber der Bericht ist alt.
    const rd: number = dayStart(p.reportDate);
    if (p.termin === "green" && p.kosten === "green" && p.leistung === "green" && !Number.isNaN(rd)) {
      const age: number = Math.round((today - rd) / DAY);
      if (age > REPORT_STALE_DAYS) reasons.push("Bericht ist " + age + " Tage alt");
    }

    if (reasons.length > 0) {
      findings.push({
        name: p.name,
        lead: p.projectLeadName || "ohne Leitung",
        reason: reasons.join(" · "),
      });
    }
  });

  const quote: number = reported.length > 0 ? Math.round((findings.length / reported.length) * 100) : 0;

  const rows: string = findings.slice(0, 8).map((f) => `
    <div style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-bottom:1px solid ${C.line};font-size:11px">
      <span style="width:3px;align-self:stretch;flex-shrink:0;border-radius:2px;background:${C.red}"></span>
      <div style="flex:1;min-width:0">
        <div style="display:flex;gap:8px">
          <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text};font-weight:600">${esc(f.name)}</span>
          <span style="color:${C.sub};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px">${esc(f.lead)}</span>
        </div>
        <div style="color:${C.amber};font-size:10px;margin-top:1px">${esc(f.reason)}</div>
      </div>
    </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:${findings.length === 0 ? C.green : quote >= 20 ? C.red : C.amber}">${findings.length}</span>
        <span style="font-size:11px;color:${C.sub}">Projekte melden günstiger, als die Daten hergeben — von ${reported.length} berichteten</span>
      </div>
      ${findings.length === 0
        ? note("Keine Widersprüche zwischen Ampel und Datenlage gefunden.")
        : rows}
      ${findings.length > 8 ? `<div style="font-size:10px;color:${C.sub};margin-top:6px">${findings.length - 8} weitere Auffälligkeiten.</div>` : ""}
      ${!loaded.complete ? `<div style="font-size:10px;color:${C.sub};margin-top:4px">Auswertung über ${loaded.items.length} von ${loaded.total ?? "?"} Projekten.</div>` : ""}
    </div>`;
  widget.done();
})();

Anpassen

KonstanteVorbelegungBedeutung
LAG_TOLERANCE20Um wie viele Prozentpunkte der Fortschritt hinter dem Zeitverlauf liegen darf, bevor eine grüne Ampel als unglaubwürdig gilt.
REPORT_STALE_DAYS45Ab wie vielen Tagen ein Statusbericht zu alt ist, um eine grüne Ampel noch zu tragen.
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.