// Kostenverlauf (S-Kurve)
// Quellen: widget.data.project (Termine, Budget) + widget.query("project.costs")
// project.costs erfordert das Kostenrecht — sonst erscheint ein Hinweistext.
const CURRENCY = "EUR"; // an die Mandantenwährung anpassen
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 `
${text}
`;
}
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 ``;
}
function legend(color: string, label: string): string {
return `${label}`;
}
widget.el.innerHTML = `
${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)}
${legend(C.blue, "Ist kumuliert")}
${legend(C.sub, "linearer Sollverlauf")}
${legend(C.red, "Budget")}
`;
widget.done();
})();