// 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(); })();