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
const CURRENCY = "EUR";
const isDark: boolean = widget.data.theme === "dark";
const C = {
text: isDark ? "#f1f5f9" : "#1e293b",
sub: "#94a3b8",
line: isDark ? "#334155" : "#e2e8f0",
blue: "#3b82f6",
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 die Kostendaten."
: code === "unsupported_in_context" ? "In diesem Dashboard nicht verfügbar."
: code === "timeout" ? "Zeitüberschreitung beim Laden."
: "Kostendaten 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;
}
const p = widget.data.project;
if (!p) {
widget.el.innerHTML = note("Nur auf Projektebene verfügbar.");
widget.done();
return;
}
widget.el.innerHTML = note("Lade Kostendaten …");
(async () => {
const res = await widget.query("project.costs");
if (!res.ok) {
widget.el.innerHTML = queryError(res.error.code);
widget.done();
return;
}
const totals = res.data.totals;
const budget: number = totals.budget > 0 ? totals.budget : p.budget;
const sorted = res.data.bookings
.map((b) => ({ t: Date.parse(b.date), v: b.amount }))
.filter((b) => !Number.isNaN(b.t))
.sort((a, b) => a.t - b.t);
if (sorted.length === 0) {
widget.el.innerHTML = note("Noch keine Kostenbuchungen erfasst.");
widget.done();
return;
}
let acc = 0;
const curve = sorted.map((b) => { acc += b.v; return { t: b.t, v: acc }; });
const t0: number = Date.parse(p.planStart) || curve[0].t;
const t1: number = Date.parse(p.planEnd) || curve[curve.length - 1].t;
const span: number = Math.max(t1 - t0, 86400000);
const yMax: number = Math.max(budget, acc) * 1.08 || 1;
const W = 320, H = 140, PL = 4, PR = 4, PT = 8, PB = 14;
function px(t: number): string {
return (PL + Math.min(Math.max((t - t0) / span, 0), 1) * (W - PL - PR)).toFixed(1);
}
function py(v: number): string {
return (H - PB - Math.min(Math.max(v / yMax, 0), 1) * (H - PT - PB)).toFixed(1);
}
const pts = [{ t: t0, v: 0 }].concat(curve);
const path: string = pts.map((pt, i) => `${i === 0 ? "M" : "L"} ${px(pt.t)} ${py(pt.v)}`).join(" ");
const last = curve[curve.length - 1];
const area: string = `${path} L ${px(last.t)} ${py(0)} L ${px(t0)} ${py(0)} Z`;
const now: number = Date.now();
const used: number = budget > 0 ? Math.round((totals.actual / budget) * 100) : 0;
const rest: number = budget - totals.actual;
const restColor: string = rest < 0 ? C.red : used >= 90 ? C.amber : C.green;
function kpi(label: string, value: string, color: string): string {
return `<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:700;color:${color};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${value}</div>
<div style="font-size:10px;color:${C.sub}">${label}</div>
</div>`;
}
function legend(color: string, label: string): string {
return `<span style="white-space:nowrap"><span style="display:inline-block;width:12px;height:2px;background:${color};vertical-align:middle;margin-right:4px"></span>${label}</span>`;
}
widget.el.innerHTML = `
<div style="padding:10px 12px;color:${C.text}">
<div style="display:flex;gap:10px;margin-bottom:8px">
${kpi("Budget", short(budget), C.text)}
${kpi("Ist", short(totals.actual), C.blue)}
${kpi("Verbraucht", used + " %", used > 100 ? C.red : C.text)}
${kpi("Rest", short(rest), restColor)}
</div>
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block">
<line x1="${PL}" y1="${py(budget)}" x2="${W - PR}" y2="${py(budget)}" stroke="${C.red}" stroke-width="1" stroke-dasharray="4 3" />
<line x1="${px(t0)}" y1="${py(0)}" x2="${px(t1)}" y2="${py(budget)}" stroke="${C.sub}" stroke-width="1" stroke-dasharray="2 3" />
${now > t0 && now < t1 ? `<line x1="${px(now)}" y1="${PT}" x2="${px(now)}" y2="${H - PB}" stroke="${C.amber}" stroke-width="1" />` : ""}
<path d="${area}" fill="${C.blue}" fill-opacity="0.15" />
<path d="${path}" fill="none" stroke="${C.blue}" stroke-width="2" stroke-linejoin="round" />
<line x1="${PL}" y1="${H - PB}" x2="${W - PR}" y2="${H - PB}" stroke="${C.line}" stroke-width="1" />
</svg>
<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:10px;color:${C.sub};margin-top:6px">
${legend(C.blue, "Ist kumuliert")}
${legend(C.sub, "linearer Sollverlauf")}
${legend(C.red, "Budget")}
</div>
</div>`;
widget.done();
})();