445 lines
14 KiB
JavaScript
445 lines
14 KiB
JavaScript
const urlBase64ToUint8Array = (base64String) => {
|
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
|
const rawData = atob(base64);
|
|
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
|
|
};
|
|
|
|
async function registerServiceWorker() {
|
|
if (!("serviceWorker" in navigator)) return null;
|
|
return navigator.serviceWorker.register("/static/service-worker.js");
|
|
}
|
|
|
|
async function enablePushNotifications() {
|
|
const vapidPublicKey = document.body.dataset.vapidPublicKey;
|
|
const csrfToken = document.body.dataset.csrfToken;
|
|
if (!vapidPublicKey || !("PushManager" in window)) return;
|
|
const registration = await registerServiceWorker();
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== "granted") return;
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
|
});
|
|
await fetch("/planning/push/subscribe", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-CSRF-Token": csrfToken,
|
|
},
|
|
body: JSON.stringify(subscription),
|
|
});
|
|
}
|
|
|
|
function injectCsrfTokens() {
|
|
const csrfToken = document.body.dataset.csrfToken;
|
|
if (!csrfToken) return;
|
|
document.querySelectorAll('form[method="post"]').forEach((form) => {
|
|
let input = form.querySelector('input[name="csrf_token"]');
|
|
if (!input) {
|
|
input = document.createElement("input");
|
|
input.type = "hidden";
|
|
input.name = "csrf_token";
|
|
form.appendChild(input);
|
|
}
|
|
input.value = csrfToken;
|
|
});
|
|
}
|
|
|
|
function mountCharts() {
|
|
const palette = ["#146a63", "#f2b35d", "#d97757", "#6f8677", "#7d6b91", "#6f8fc2", "#c58d5a", "#3f7f9b", "#9a6f5f", "#5f8c6d"];
|
|
const styles = getComputedStyle(document.body);
|
|
const chartTextColor = styles.getPropertyValue("--text").trim() || "#1d2a33";
|
|
const chartGridColor = styles.getPropertyValue("--line").trim() || "rgba(29, 42, 51, 0.08)";
|
|
const buildDatasets = (node) => {
|
|
const datasetPayload = JSON.parse(node.dataset.datasets || "null");
|
|
if (Array.isArray(datasetPayload) && datasetPayload.length) {
|
|
return datasetPayload.map((dataset, index) => {
|
|
const color = palette[index % palette.length];
|
|
return {
|
|
label: dataset.label,
|
|
data: (dataset.data || []).map((value) => Number(value)),
|
|
backgroundColor: `${color}22`,
|
|
borderColor: color,
|
|
pointBackgroundColor: color,
|
|
pointBorderColor: color,
|
|
borderWidth: 3,
|
|
borderRadius: 0,
|
|
tension: 0.32,
|
|
fill: false,
|
|
};
|
|
});
|
|
}
|
|
|
|
const values = JSON.parse(node.dataset.values || "[]").map((value) => Number(value));
|
|
const secondaryValues = JSON.parse(node.dataset.secondaryValues || "[]").map((value) => Number(value));
|
|
const datasets = [{
|
|
label: "Werte",
|
|
data: values,
|
|
backgroundColor: palette,
|
|
borderColor: "#146a63",
|
|
borderRadius: 12,
|
|
tension: 0.3,
|
|
}];
|
|
if (secondaryValues.length) {
|
|
datasets[0].label = "Einkommen";
|
|
datasets[0].fill = false;
|
|
datasets.push({
|
|
label: "Kosten",
|
|
data: secondaryValues,
|
|
backgroundColor: "#f2b35d",
|
|
borderColor: "#f2b35d",
|
|
tension: 0.3,
|
|
});
|
|
}
|
|
if ((node.dataset.chartType || "bar") === "line") {
|
|
datasets[0].backgroundColor = "#146a6322";
|
|
datasets[0].borderWidth = 3;
|
|
datasets[0].borderRadius = 0;
|
|
datasets[0].pointBackgroundColor = "#146a63";
|
|
datasets[0].pointBorderColor = "#146a63";
|
|
datasets[0].fill = false;
|
|
}
|
|
return datasets;
|
|
};
|
|
|
|
document.querySelectorAll(".chart").forEach((node) => {
|
|
if (node.dataset.drilldownSource === "true" || node.dataset.drilldownMounted === "true") {
|
|
return;
|
|
}
|
|
const labels = JSON.parse(node.dataset.labels || "[]");
|
|
const indexAxis = node.dataset.indexAxis || "x";
|
|
const chartType = node.dataset.chartType || "bar";
|
|
const datasets = buildDatasets(node);
|
|
new Chart(node, {
|
|
type: chartType,
|
|
data: {
|
|
labels,
|
|
datasets,
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
indexAxis,
|
|
scales: chartType === "pie" || chartType === "doughnut"
|
|
? {}
|
|
: {
|
|
x: {
|
|
ticks: { color: chartTextColor },
|
|
grid: { color: chartGridColor },
|
|
},
|
|
y: {
|
|
ticks: { color: chartTextColor },
|
|
grid: { color: chartGridColor },
|
|
},
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
display: chartType === "pie" || chartType === "doughnut" || chartType === "line",
|
|
position: "bottom",
|
|
labels: {
|
|
color: chartTextColor,
|
|
},
|
|
},
|
|
tooltip: {
|
|
titleColor: chartTextColor,
|
|
bodyColor: chartTextColor,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('[data-drilldown-source="true"]').forEach((sourceNode) => {
|
|
if (sourceNode.dataset.drilldownMounted === "true") {
|
|
return;
|
|
}
|
|
sourceNode.dataset.drilldownMounted = "true";
|
|
const rootLabels = JSON.parse(sourceNode.dataset.labels || "[]");
|
|
const rootValues = JSON.parse(sourceNode.dataset.values || "[]").map((value) => Number(value));
|
|
const detailKeys = JSON.parse(sourceNode.dataset.detailKeys || "[]");
|
|
const detailMap = JSON.parse(sourceNode.dataset.detailMap || "{}");
|
|
const titleTarget = document.getElementById(sourceNode.dataset.detailTitleTarget);
|
|
const subtitleTarget = document.getElementById(sourceNode.dataset.detailSubtitleTarget);
|
|
const backButton = document.getElementById(sourceNode.dataset.detailBackTarget);
|
|
const rootTitlePayload = document.getElementById(`${sourceNode.id}-root-title`);
|
|
const rootTitle = rootTitlePayload ? JSON.parse(rootTitlePayload.textContent) : {
|
|
title: titleTarget ? titleTarget.textContent : "",
|
|
subtitle: subtitleTarget ? subtitleTarget.textContent : "",
|
|
};
|
|
|
|
let isInDetail = false;
|
|
const chart = new Chart(sourceNode, {
|
|
type: sourceNode.dataset.chartType || "pie",
|
|
data: {
|
|
labels: rootLabels,
|
|
datasets: [
|
|
{
|
|
label: "Kategorien",
|
|
data: rootValues,
|
|
backgroundColor: palette,
|
|
borderWidth: 0,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: {
|
|
display: true,
|
|
position: "bottom",
|
|
labels: {
|
|
color: chartTextColor,
|
|
},
|
|
},
|
|
},
|
|
onClick: (_event, elements) => {
|
|
if (!elements.length || isInDetail) {
|
|
return;
|
|
}
|
|
const clickedIndex = elements[0].index;
|
|
const detailKey = detailKeys[clickedIndex];
|
|
if (detailKey) {
|
|
renderDetailChart(detailKey);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
const renderRootChart = () => {
|
|
chart.config.type = sourceNode.dataset.chartType || "pie";
|
|
chart.data.labels = rootLabels;
|
|
chart.data.datasets = [
|
|
{
|
|
label: "Kategorien",
|
|
data: rootValues,
|
|
backgroundColor: palette,
|
|
borderWidth: 0,
|
|
},
|
|
];
|
|
chart.options.plugins.legend.display = true;
|
|
chart.update();
|
|
isInDetail = false;
|
|
if (titleTarget) {
|
|
titleTarget.textContent = rootTitle.title;
|
|
}
|
|
if (subtitleTarget) {
|
|
subtitleTarget.textContent = rootTitle.subtitle;
|
|
}
|
|
if (backButton) {
|
|
backButton.hidden = true;
|
|
}
|
|
};
|
|
|
|
const renderDetailChart = (detailKey) => {
|
|
const detail = detailMap[detailKey];
|
|
if (!detail) {
|
|
return;
|
|
}
|
|
if (titleTarget) {
|
|
titleTarget.textContent = detail.label;
|
|
}
|
|
if (subtitleTarget) {
|
|
subtitleTarget.textContent = `${detail.account} · ${detail.labels.length} Einträge`;
|
|
}
|
|
chart.config.type = "doughnut";
|
|
chart.data.labels = detail.labels;
|
|
chart.data.datasets = [
|
|
{
|
|
label: detail.label,
|
|
data: detail.values,
|
|
backgroundColor: palette,
|
|
borderWidth: 0,
|
|
},
|
|
];
|
|
chart.options.plugins.legend.display = true;
|
|
chart.update();
|
|
isInDetail = true;
|
|
if (backButton) {
|
|
backButton.hidden = false;
|
|
}
|
|
};
|
|
|
|
if (backButton) {
|
|
backButton.addEventListener("click", () => {
|
|
renderRootChart();
|
|
});
|
|
}
|
|
|
|
renderRootChart();
|
|
});
|
|
}
|
|
|
|
function mountDialogs() {
|
|
const openDialogById = (dialogId, node = null) => {
|
|
const dialog = document.getElementById(dialogId);
|
|
if (!dialog) return;
|
|
document.querySelectorAll("dialog[open]").forEach((openDialog) => {
|
|
if (openDialog !== dialog) {
|
|
openDialog.close();
|
|
}
|
|
});
|
|
const accountIdInput = dialog.querySelector("[data-dialog-account-id]");
|
|
const areaInput = dialog.querySelector("[data-dialog-area]");
|
|
const categoryNameInput = dialog.querySelector("[data-dialog-category-name]");
|
|
const returnDialogInput = dialog.querySelector("[data-dialog-return-dialog]");
|
|
const placeholderInput = dialog.querySelector("[data-dialog-name-placeholder]");
|
|
const communityAccountField = dialog.querySelector("[data-community-account-field]");
|
|
if (accountIdInput) {
|
|
accountIdInput.value = node?.dataset.accountId || "";
|
|
}
|
|
if (areaInput) {
|
|
areaInput.value = node?.dataset.area || areaInput.defaultValue || areaInput.value;
|
|
}
|
|
if (categoryNameInput) {
|
|
categoryNameInput.value = node?.dataset.categoryName || "";
|
|
}
|
|
if (placeholderInput) {
|
|
if (!placeholderInput.dataset.defaultPlaceholder) {
|
|
placeholderInput.dataset.defaultPlaceholder = placeholderInput.placeholder;
|
|
}
|
|
placeholderInput.placeholder = node?.dataset.placeholder || placeholderInput.dataset.defaultPlaceholder;
|
|
}
|
|
if (returnDialogInput) {
|
|
returnDialogInput.value = node?.dataset.returnDialog || "";
|
|
}
|
|
if (communityAccountField && areaInput) {
|
|
communityAccountField.hidden = areaInput.value !== "budget";
|
|
}
|
|
dialog.querySelectorAll("[data-annual-visibility]").forEach((element) => {
|
|
element.hidden = areaInput ? areaInput.value !== "budget" : false;
|
|
});
|
|
dialog.showModal();
|
|
};
|
|
|
|
document.querySelectorAll("[data-open-dialog]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
openDialogById(node.dataset.openDialog, node);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll("dialog").forEach((dialog) => {
|
|
dialog.addEventListener("click", (event) => {
|
|
const rect = dialog.getBoundingClientRect();
|
|
const inside =
|
|
rect.top <= event.clientY &&
|
|
event.clientY <= rect.bottom &&
|
|
rect.left <= event.clientX &&
|
|
event.clientX <= rect.right;
|
|
if (!inside) {
|
|
dialog.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
const initialDialogId = new URLSearchParams(window.location.search).get("dialog");
|
|
if (initialDialogId) {
|
|
openDialogById(initialDialogId);
|
|
const nextUrl = new URL(window.location.href);
|
|
nextUrl.searchParams.delete("dialog");
|
|
window.history.replaceState({}, "", nextUrl);
|
|
}
|
|
}
|
|
|
|
function mountPersonalSplitSync() {
|
|
const floInputs = document.querySelectorAll('[data-personal-split="flo"]');
|
|
const desiInputs = document.querySelectorAll('[data-personal-split="desi"]');
|
|
const syncPair = (source, target) => {
|
|
source.addEventListener("input", () => {
|
|
const sourceValue = Number(source.value || 0);
|
|
const boundedValue = Math.max(0, Math.min(100, sourceValue));
|
|
target.value = String(Math.max(0, Math.min(100, 100 - boundedValue)));
|
|
});
|
|
};
|
|
floInputs.forEach((floInput, index) => {
|
|
const desiInput = desiInputs[index];
|
|
if (!desiInput) return;
|
|
syncPair(floInput, desiInput);
|
|
syncPair(desiInput, floInput);
|
|
});
|
|
}
|
|
|
|
function mountAnnualAmountSync() {
|
|
document.querySelectorAll("[data-annual-sync-wrapper]").forEach((wrapper) => {
|
|
const monthlyInput = wrapper.querySelector('[data-annual-sync="monthly"]');
|
|
const yearlyInput = wrapper.querySelector('[data-annual-sync="yearly"]');
|
|
if (!monthlyInput || !yearlyInput) return;
|
|
let syncing = false;
|
|
const formatValue = (value) => (Number.isFinite(value) ? value.toFixed(2) : "");
|
|
|
|
monthlyInput.addEventListener("input", () => {
|
|
if (syncing) return;
|
|
syncing = true;
|
|
const monthlyValue = Number(monthlyInput.value || 0);
|
|
yearlyInput.value = formatValue(monthlyValue * 12);
|
|
syncing = false;
|
|
});
|
|
|
|
yearlyInput.addEventListener("input", () => {
|
|
if (syncing) return;
|
|
syncing = true;
|
|
const yearlyValue = Number(yearlyInput.value || 0);
|
|
monthlyInput.value = formatValue(yearlyValue / 12);
|
|
syncing = false;
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('form').forEach((form) => {
|
|
const annualVisibility = form.querySelector("[data-annual-visibility]");
|
|
const areaInput = form.querySelector("[data-dialog-area]");
|
|
if (!annualVisibility || !areaInput) return;
|
|
annualVisibility.hidden = areaInput.value !== "budget";
|
|
});
|
|
}
|
|
|
|
function mountThemeToggle() {
|
|
const storageKey = "saldo-theme";
|
|
const body = document.body;
|
|
if (!body) return;
|
|
|
|
const applyTheme = (theme) => {
|
|
body.dataset.theme = theme;
|
|
};
|
|
|
|
const storedTheme = window.localStorage.getItem(storageKey);
|
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
applyTheme(storedTheme || (prefersDark ? "dark" : "light"));
|
|
|
|
document.querySelectorAll("[data-theme-toggle]").forEach((node) => {
|
|
node.addEventListener("click", () => {
|
|
const nextTheme = body.dataset.theme === "dark" ? "light" : "dark";
|
|
applyTheme(nextTheme);
|
|
window.localStorage.setItem(storageKey, nextTheme);
|
|
});
|
|
});
|
|
}
|
|
|
|
function mountDeleteConfirmations() {
|
|
document.querySelectorAll("[data-confirm-submit]").forEach((node) => {
|
|
node.addEventListener("click", (event) => {
|
|
const message = node.dataset.confirmSubmit || "Wirklich löschen?";
|
|
if (!window.confirm(message)) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
await registerServiceWorker();
|
|
injectCsrfTokens();
|
|
mountThemeToggle();
|
|
mountCharts();
|
|
mountDialogs();
|
|
mountPersonalSplitSync();
|
|
mountAnnualAmountSync();
|
|
mountDeleteConfirmations();
|
|
document.querySelectorAll("[data-enable-push]").forEach((node) => {
|
|
node.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
await enablePushNotifications();
|
|
});
|
|
});
|
|
});
|