// Risiko-Exposure in Euro
// Quelle: widget.query("project.risks")
// Erwartungswert je Risiko = Kostenwirkung × Eintrittswahrscheinlichkeit / 100.
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 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 `
${esc(r.title)}
${r.probability} % · ${short(v)}
`;
}).join("");
widget.el.innerHTML = `
${short(evTotal)}
erwarteter Schaden aus ${open.length} offenen Risiken
${share.toFixed(1)} %
vom Budget
${short(worstCase)}
obere Bandbreite
${Math.round(delayDays)} Tage
erwarteter Verzug
${occurred.length}
bereits eingetreten
Größte Einzelrisiken
${rows}
${res.data.truncated ? `
Auszug — es existieren weitere Risiken.
` : ""}
`;
widget.done();
})();