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
113
114
115
116
117
118
119
120
121
122
123
124
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 queryError(code: string): string {
return note(
code === "forbidden" ? "Keine Berechtigung für die Risikodaten."
: code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar."
: "Risikodaten konnten nicht geladen werden.",
);
}
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("Lade Risiken …");
(async () => {
const res = await widget.query("project.risks", { limit: 200 });
if (!res.ok) {
widget.el.innerHTML = queryError(res.error.code);
widget.done();
return;
}
const all = res.data.items;
const open = all.filter((r) => r.status !== "closed");
const occurred = all.filter((r) => r.status === "occurred");
if (open.length === 0) {
widget.el.innerHTML = note("Keine offenen Risiken erfasst.");
widget.done();
return;
}
function expected(r: WidgetRisk): number {
return r.impact * (r.probability / 100);
}
const evTotal: number = open.reduce((s, r) => s + expected(r), 0);
const worstCase: number = open.reduce(
(s, r) => s + (r.costImpactMax != null ? r.costImpactMax : r.impact) * (r.probability / 100),
0,
);
const delayDays: number = open.reduce(
(s, r) => s + (r.scheduleImpactDays != null ? r.scheduleImpactDays : 0) * (r.probability / 100),
0,
);
const budget: number = widget.data.project ? widget.data.project.budget : 0;
const share: number = budget > 0 ? (evTotal / budget) * 100 : 0;
const shareColor: string = share >= 20 ? C.red : share >= 10 ? C.amber : C.green;
const top = open.slice().sort((a, b) => expected(b) - expected(a)).slice(0, 5);
const max: number = expected(top[0]) || 1;
const rows: string = top.map((r) => {
const v: number = expected(r);
const w: number = Math.max(2, (v / max) * 100);
return `<div style="margin-bottom:6px">
<div style="display:flex;justify-content:space-between;gap:8px;font-size:11px;margin-bottom:2px">
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(r.title)}</span>
<span style="color:${C.sub};white-space:nowrap">${r.probability} % · ${short(v)}</span>
</div>
<div style="height:5px;background:${C.line};border-radius:3px;overflow:hidden">
<div style="height:100%;width:${w.toFixed(0)}%;background:${C.red};opacity:.8"></div>
</div>
</div>`;
}).join("");
widget.el.innerHTML = `
<div style="padding:10px 12px;color:${C.text}">
<div style="display:flex;align-items:flex-end;gap:12px;margin-bottom:4px">
<div>
<div style="font-size:26px;font-weight:700;line-height:1.1;color:${C.red}">${short(evTotal)}</div>
<div style="font-size:10px;color:${C.sub}">erwarteter Schaden aus ${open.length} offenen Risiken</div>
</div>
<div style="margin-left:auto;text-align:right">
<div style="font-size:16px;font-weight:700;color:${shareColor}">${share.toFixed(1)} %</div>
<div style="font-size:10px;color:${C.sub}">vom Budget</div>
</div>
</div>
<div style="display:flex;gap:10px;padding:8px 0;margin-bottom:6px;border-top:1px solid ${C.line};border-bottom:1px solid ${C.line}">
<div style="flex:1"><div style="font-size:12px;font-weight:600">${short(worstCase)}</div><div style="font-size:10px;color:${C.sub}">obere Bandbreite</div></div>
<div style="flex:1"><div style="font-size:12px;font-weight:600">${Math.round(delayDays)} Tage</div><div style="font-size:10px;color:${C.sub}">erwarteter Verzug</div></div>
<div style="flex:1"><div style="font-size:12px;font-weight:600;color:${occurred.length > 0 ? C.amber : C.text}">${occurred.length}</div><div style="font-size:10px;color:${C.sub}">bereits eingetreten</div></div>
</div>
<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:5px">Größte Einzelrisiken</div>
${rows}
${res.data.truncated ? `<div style="font-size:10px;color:${C.sub};margin-top:4px">Auszug — es existieren weitere Risiken.</div>` : ""}
</div>`;
widget.done();
})();