// Maßnahmen-Lückenwächter // Quellen: widget.query("project.risks") + widget.query("project.opportunities") // Beide Abfragen laufen parallel (das Budget erlaubt bis zu 4 gleichzeitig). 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 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 === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """); } function note(text: string): string { return `
${text}
`; } 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; } widget.el.innerHTML = note("Prüfe Maßnahmenabdeckung …"); (async () => { const results = await Promise.all([ widget.query("project.risks", { limit: 200 }), widget.query("project.opportunities", { limit: 200 }), ]); const riskRes = results[0]; const oppRes = results[1]; if (!riskRes.ok && !oppRes.ok) { widget.el.innerHTML = note( riskRes.error.code === "forbidden" ? "Keine Berechtigung für Risiken und Chancen." : riskRes.error.code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar." : "Daten konnten nicht geladen werden.", ); widget.done(); return; } const risks = riskRes.ok ? riskRes.data.items.filter((r) => r.status !== "closed") : []; const opps = oppRes.ok ? oppRes.data.items : []; const riskGaps = risks.filter((r) => r.measureCount === 0); const oppGaps = opps.filter((o) => o.measureCount === 0); type Gap = { kind: "risk" | "opp"; title: string; value: number; prob: number }; const gaps: Gap[] = riskGaps .map((r) => ({ kind: "risk" as const, title: r.title, value: r.impact * (r.probability / 100), prob: r.probability })) .concat(oppGaps.map((o) => ({ kind: "opp" as const, title: o.title, value: o.benefit * (o.probability / 100), prob: o.probability }))) .sort((a, b) => b.value - a.value); const exposed: number = riskGaps.reduce((s, r) => s + r.impact * (r.probability / 100), 0); function ratio(gapCount: number, total: number): string { if (total === 0) return "—"; return gapCount + "/" + total; } function tile(label: string, gapCount: number, total: number, color: string, allowed: boolean): string { const pct: number = total > 0 ? Math.round((gapCount / total) * 100) : 0; return `
${allowed ? ratio(gapCount, total) : "n. v."}
${label}${allowed && total > 0 ? ` · ${pct} %` : ""}
`; } const list: string = gaps.slice(0, 6).map((g) => `
${esc(g.title)} ${g.prob} % ${short(g.value)}
`).join(""); widget.el.innerHTML = `
${tile("Risiken ohne Maßnahme", riskGaps.length, risks.length, C.red, riskRes.ok)} ${tile("Chancen ohne Maßnahme", oppGaps.length, opps.length, C.amber, oppRes.ok)}
${short(exposed)}
ungesteuertes Risiko
${gaps.length === 0 ? note("Zu allen Risiken und Chancen sind Maßnahmen hinterlegt.") : `
Größte Lücken
${list}`} ${gaps.length > 6 ? `
${gaps.length - 6} weitere Einträge ohne Maßnahme.
` : ""}
`; widget.done(); })();