// Chancen-Verfallsradar // Quelle: widget.query("project.opportunities") // Bucket-Logik über windowEnd (Ende des Zeitfensters). 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 DAY = 86400000; 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; } /** "YYYY-MM-DD" als LOKALE Mitternacht lesen. Date.parse würde reine * Datumsangaben als UTC interpretieren — Tagesdifferenzen kippen dann je * nach Zeitzone um einen Tag. */ function dayStart(iso: string): number { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || ""); return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() : NaN; } function today0(): number { const d = new Date(); d.setHours(0, 0, 0, 0); return d.getTime(); } function daysLeft(iso: string): number | null { const t: number = dayStart(iso); if (Number.isNaN(t)) return null; return Math.round((t - today0()) / DAY); } widget.el.innerHTML = note("Lade Chancen …"); (async () => { const res = await widget.query("project.opportunities", { limit: 200 }); if (!res.ok) { widget.el.innerHTML = note( res.error.code === "forbidden" ? "Keine Berechtigung für die Chancendaten." : res.error.code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar." : "Chancen konnten nicht geladen werden.", ); widget.done(); return; } const items = res.data.items; if (items.length === 0) { widget.el.innerHTML = note("Keine Chancen erfasst."); widget.done(); return; } const withDays = items.map((o) => ({ o: o, d: daysLeft(o.windowEnd) })); const buckets = [ { label: "abgelaufen", color: C.sub, hit: (d: number | null) => d !== null && d < 0 }, { label: "≤ 30 Tage", color: C.red, hit: (d: number | null) => d !== null && d >= 0 && d <= 30 }, { label: "≤ 60 Tage", color: C.amber, hit: (d: number | null) => d !== null && d > 30 && d <= 60 }, { label: "≤ 90 Tage", color: C.green, hit: (d: number | null) => d !== null && d > 60 && d <= 90 }, ]; const tiles: string = buckets.map((b) => { const hits = withDays.filter((x) => b.hit(x.d)); const value: number = hits.reduce((s, x) => s + x.o.benefit * (x.o.probability / 100), 0); return `
${hits.length}
${b.label}
${short(value)}
`; }).join(""); const soon = withDays .filter((x) => x.d !== null && x.d <= 90) .sort((a, b) => (a.d as number) - (b.d as number)) .slice(0, 6); const rows: string = soon.map((x) => { const d: number = x.d as number; const noMeasure: boolean = x.o.measureCount === 0; const color: string = d < 0 ? C.sub : d <= 30 ? C.red : d <= 60 ? C.amber : C.green; return `
${esc(x.o.title)} ${noMeasure ? `ohne Maßnahme` : ""} ${short(x.o.benefit * (x.o.probability / 100))} ${d < 0 ? Math.abs(d) + " T. vorbei" : "in " + d + " T."}
`; }).join(""); widget.el.innerHTML = `
${tiles}
${soon.length === 0 ? note("In den nächsten 90 Tagen läuft kein Chancenfenster aus.") : `
Nächste Fristen
${rows}`}
`; widget.done(); })();