1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
const CURRENCY = "EUR";
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 `<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;
}
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 `<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0">
<div style="font-size:18px;font-weight:700;color:${!allowed ? C.sub : gapCount > 0 ? color : C.green}">${allowed ? ratio(gapCount, total) : "n. v."}</div>
<div style="font-size:10px;color:${C.sub}">${label}${allowed && total > 0 ? ` · ${pct} %` : ""}</div>
</div>`;
}
const list: string = gaps.slice(0, 6).map((g) => `
<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid ${C.line};font-size:11px">
<span style="width:3px;height:16px;flex-shrink:0;border-radius:2px;background:${g.kind === "risk" ? C.red : C.green}"></span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(g.title)}</span>
<span style="color:${C.sub};white-space:nowrap">${g.prob} %</span>
<span style="color:${g.kind === "risk" ? C.red : C.green};font-weight:600;white-space:nowrap;min-width:72px;text-align:right">${short(g.value)}</span>
</div>`).join("");
widget.el.innerHTML = `
<div style="padding:10px 12px;color:${C.text}">
<div style="display:flex;gap:8px;margin-bottom:8px">
${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)}
<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0">
<div style="font-size:18px;font-weight:700;color:${exposed > 0 ? C.red : C.green};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${short(exposed)}</div>
<div style="font-size:10px;color:${C.sub}">ungesteuertes Risiko</div>
</div>
</div>
${gaps.length === 0
? note("Zu allen Risiken und Chancen sind Maßnahmen hinterlegt.")
: `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Größte Lücken</div>${list}`}
${gaps.length > 6 ? `<div style="font-size:10px;color:${C.sub};margin-top:5px">${gaps.length - 6} weitere Einträge ohne Maßnahme.</div>` : ""}
</div>`;
widget.done();
})();