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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
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 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 den Strukturplan."
: code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar."
: "Daten 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("Berechne Leistungswerte …");
(async () => {
const res = await widget.query("project.containers", { limit: 500 });
if (!res.ok) {
widget.el.innerHTML = queryError(res.error.code);
widget.done();
return;
}
const work = res.data.items.filter((c) => c.type === "container");
const leaves = work.filter((c) => c.childrenCount === 0);
const basis = leaves.length > 0 ? leaves : work;
if (basis.length === 0) {
widget.el.innerHTML = note("Kein Strukturplan hinterlegt.");
widget.done();
return;
}
const now: number = Date.now();
let bac = 0, ev = 0, pv = 0, ac = 0;
basis.forEach((c) => {
const b: number = c.budget != null ? c.budget : (c.costPlan != null ? c.costPlan : 0);
bac += b;
ev += b * (c.progress / 100);
ac += c.costActual != null ? c.costActual : 0;
const s: number = Date.parse(c.startDate);
const e: number = Date.parse(c.endDate);
let frac = 1;
if (!Number.isNaN(s) && !Number.isNaN(e) && e > s) {
frac = Math.min(Math.max((now - s) / (e - s), 0), 1);
} else if (!Number.isNaN(s) && now < s) {
frac = 0;
}
pv += b * frac;
});
const spi: number | null = pv > 0 ? ev / pv : null;
const cpi: number | null = ac > 0 ? ev / ac : null;
const eac: number = cpi !== null && cpi > 0 ? bac / cpi : bac;
function tone(v: number | null): string {
if (v === null) return C.sub;
if (v >= 0.95) return C.green;
if (v >= 0.85) return C.amber;
return C.red;
}
function fmt(v: number | null): string {
return v === null ? "—" : v.toLocaleString(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function gauge(label: string, v: number | null, hint: string): string {
const color: string = tone(v);
const pct: number = v === null ? 0 : (Math.min(Math.max(v, 0), 1.5) / 1.5) * 100;
return `<div style="flex:1;background:${C.card};border-radius:8px;padding:10px 12px;min-width:0">
<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em">${label}</div>
<div style="font-size:26px;font-weight:700;line-height:1.15;color:${color}">${fmt(v)}</div>
<div style="height:4px;background:${C.line};border-radius:2px;overflow:hidden;margin:6px 0 5px">
<div style="height:100%;width:${pct.toFixed(0)}%;background:${color}"></div>
</div>
<div style="font-size:10px;color:${C.sub};line-height:1.3">${hint}</div>
</div>`;
}
const spiText: string = spi === null ? "Keine Termindaten"
: spi >= 1 ? Math.round((spi - 1) * 100) + " % vor Plan"
: Math.round((1 - spi) * 100) + " % hinter Plan";
const cpiText: string = cpi === null ? "Noch keine Ist-Kosten"
: cpi >= 1 ? Math.round((cpi - 1) * 100) + " % günstiger als geplant"
: Math.round((1 / cpi - 1) * 100) + " % teurer als geplant";
const abw: number = eac - bac;
function cell(label: string, value: string, color: string): string {
return `<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:600;color:${color};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${value}</div>
<div style="font-size:10px;color:${C.sub}">${label}</div>
</div>`;
}
widget.el.innerHTML = `
<div style="padding:10px 12px;color:${C.text}">
<div style="display:flex;gap:8px;margin-bottom:10px">
${gauge("Termin (SPI)", spi, spiText)}
${gauge("Kosten (CPI)", cpi, cpiText)}
</div>
<div style="display:flex;gap:10px;padding-top:8px;border-top:1px solid ${C.line}">
${cell("Gesamtbudget", short(bac), C.text)}
${cell("Leistungswert", short(ev), C.text)}
${cell("Ist-Kosten", short(ac), C.text)}
${cell("Prognose Ende", short(eac), abw > 0 ? C.red : C.green)}
</div>
<div style="font-size:10px;color:${C.sub};margin-top:8px;line-height:1.35">
Basis: ${basis.length} Arbeitspakete. Der Sollwert wird zeitanteilig aus
Start- und Endterminen gebildet.
</div>
</div>`;
widget.done();
})();