fix: systemd ohne ProtectHome (venv unter /root sichtbar) + Phase-3-Backend
Deployment-Fixes (Dienst startete nicht -> 203/EXEC "uvicorn not found"): - Unit ohne ProtectHome/ProtectSystem: Repo/venv dürfen unter /root oder /home liegen (ProtectHome=true blendete diese Pfade für den Dienst aus). - install.sh: Python-3.11+-Prüfung mit klarer Meldung; prüft nach der Installation, dass uvicorn wirklich in der venv liegt. - EZB-Kursabruf läuft beim Start jetzt im Hintergrund (blockiert den Start nie). Phase-3-Backend (Dashboard): - Modell + Migration 0004: dashboard_layouts (JSON-Layout je Nutzer). - routers/dashboard: Layout speichern/laden/zurücksetzen + Kartendaten (Budget-Donut, Budget-Fortschritt, Einnahmen/Ausgaben, Kontostände, Vermögensverlauf, letzte Buchungen). - Frontend-Infrastruktur: Chart-Komponenten (Donut/Line), palette, API-Methoden, react-grid-layout. (Dashboard-Seite folgt im nächsten Commit.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+94
@@ -11,6 +11,7 @@
|
||||
"i18next": "^24.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-grid-layout": "^1.5.3",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
@@ -1368,6 +1369,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1452,6 +1462,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
|
||||
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1625,6 +1641,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1674,6 +1699,17 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -1699,6 +1735,38 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
|
||||
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-grid-layout": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.4.tgz",
|
||||
"integrity": "sha512-AyScMfqhgRfie9ECXddQeaYmTuNlomDj9SIryF/+vtiwXS6IsxX3cI6CCWbSRXqbAePHW397nPxvnekO5gViPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"fast-equals": "^4.0.3",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-resizable": "^3.0.5",
|
||||
"resize-observer-polyfill": "^1.5.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
|
||||
@@ -1725,6 +1793,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -1735,6 +1809,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
|
||||
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "15.x",
|
||||
"react-draggable": "^4.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3",
|
||||
"react-dom": ">= 16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.6",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
|
||||
@@ -1767,6 +1855,12 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.4",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"i18next": "^24.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-grid-layout": "^1.5.3",
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
|
||||
@@ -175,4 +175,15 @@ export const api = {
|
||||
createIncome: (body) => request("/budget/income", { method: "POST", body }),
|
||||
updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }),
|
||||
deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Dashboard
|
||||
dashboardLayout: () => request("/dashboard/layout"),
|
||||
saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }),
|
||||
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
|
||||
cardBudgetDonut: () => request("/dashboard/budget-donut"),
|
||||
cardBudgetProgress: () => request("/dashboard/budget-progress"),
|
||||
cardIncomeExpense: () => request("/dashboard/income-expense"),
|
||||
cardBalances: () => request("/dashboard/balances"),
|
||||
cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`),
|
||||
cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
|
||||
// Ringdiagramm für Anteile. segmente: [{ name, value, color }]
|
||||
// fmt: Formatierer für die Werte (z. B. Geldbetrag).
|
||||
export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v) => String(v) }) {
|
||||
const [aktiv, setAktiv] = useState(null);
|
||||
|
||||
const werte = segmente.filter((s) => s.value > 0);
|
||||
const summe = werte.reduce((s, e) => s + e.value, 0);
|
||||
if (summe <= 0) return <div className="empty">Keine Daten vorhanden.</div>;
|
||||
|
||||
const groesse = 180;
|
||||
const mitte = groesse / 2;
|
||||
const radius = 74;
|
||||
const dicke = 26;
|
||||
const umfang = 2 * Math.PI * radius;
|
||||
const luecke = 2;
|
||||
|
||||
let versatz = 0;
|
||||
const boegen = werte.map((s) => {
|
||||
const anteil = s.value / summe;
|
||||
const laenge = Math.max(anteil * umfang - luecke, 0.5);
|
||||
const bogen = { ...s, laenge, versatz, anteil };
|
||||
versatz += anteil * umfang;
|
||||
return bogen;
|
||||
});
|
||||
const gezeigt = aktiv ?? null;
|
||||
|
||||
return (
|
||||
<div className="chart-donut">
|
||||
<svg viewBox={`0 0 ${groesse} ${groesse}`} className="donut-svg" role="img"
|
||||
aria-label={`Ringdiagramm, ${werte.length} Anteile`}>
|
||||
<g transform={`rotate(-90 ${mitte} ${mitte})`}>
|
||||
{boegen.map((b) => (
|
||||
<circle key={b.name}
|
||||
cx={mitte} cy={mitte} r={radius} fill="none" stroke={b.color}
|
||||
strokeWidth={gezeigt?.name === b.name ? dicke + 4 : dicke}
|
||||
strokeDasharray={`${b.laenge} ${umfang - b.laenge}`}
|
||||
strokeDashoffset={-b.versatz}
|
||||
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
|
||||
))}
|
||||
</g>
|
||||
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value">
|
||||
{fmt(gezeigt ? gezeigt.value : summe)}
|
||||
</text>
|
||||
<text x={mitte} y={mitte + 14} textAnchor="middle" className="donut-label">
|
||||
{gezeigt ? gezeigt.name : gesamtLabel}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<div className="chart-legend-scroll">
|
||||
<ul className="chart-legend">
|
||||
{boegen.map((b) => (
|
||||
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
|
||||
<span className="swatch" style={{ background: b.color }} />
|
||||
<span className="legend-name">{b.name}</span>
|
||||
<span className="legend-value">{fmt(b.value)} · {Math.round(b.anteil * 100)} %</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { tokenFarbe } from "../palette";
|
||||
import useGroesse from "./useGroesse";
|
||||
|
||||
// Liniendiagramm über die Zeit.
|
||||
// labels: ["01.07.", …] serien: [{ name, color, werte: number[] }]
|
||||
// fmt: Werteformatierer.
|
||||
export default function Line({ labels = [], serien = [], fmt = (v) => String(v) }) {
|
||||
const wirt = useRef(null);
|
||||
const plot = useRef(null);
|
||||
const { breite, hoehe } = useGroesse(plot);
|
||||
const [index, setIndex] = useState(null);
|
||||
|
||||
const legende = serien.length > 1;
|
||||
const hatWerte = serien.some((s) => s.werte?.length);
|
||||
|
||||
if (labels.length <= 1 || !hatWerte) {
|
||||
return <div className="chart-line" ref={wirt}><div className="empty">Keine Daten vorhanden.</div></div>;
|
||||
}
|
||||
|
||||
const rand = { oben: 12, rechts: 12, unten: 24, links: 52 };
|
||||
const w = Math.max(breite, 240);
|
||||
const h = Math.max(hoehe, 120);
|
||||
const flaecheB = Math.max(w - rand.links - rand.rechts, 10);
|
||||
const flaecheH = Math.max(h - rand.oben - rand.unten, 10);
|
||||
|
||||
const alleWerte = serien.flatMap((s) => s.werte);
|
||||
const maxWert = Math.max(...alleWerte, 1);
|
||||
const minWert = Math.min(...alleWerte, 0);
|
||||
const spanne = Math.max(maxWert - minWert, 1);
|
||||
const skalaMax = maxWert + spanne * 0.1;
|
||||
const skalaMin = minWert - spanne * 0.05;
|
||||
const skalaSpanne = Math.max(skalaMax - skalaMin, 1);
|
||||
|
||||
const x = (i) => rand.links + (i / (labels.length - 1)) * flaecheB;
|
||||
const y = (wert) => rand.oben + flaecheH - ((wert - skalaMin) / skalaSpanne) * flaecheH;
|
||||
|
||||
const gitter = [0, 0.5, 1].map((teil) => {
|
||||
const wert = skalaMin + skalaSpanne * teil;
|
||||
return { wert, y: y(wert) };
|
||||
});
|
||||
|
||||
const schritt = Math.max(1, Math.round(labels.length / 5));
|
||||
const achse = labels.map((l, i) => ({ l, i })).filter(({ i }) => i % schritt === 0);
|
||||
|
||||
const gitterFarbe = tokenFarbe("--border", "#ddd");
|
||||
const textFarbe = tokenFarbe("--muted", "#888");
|
||||
|
||||
function bewegen(event) {
|
||||
const box = event.currentTarget.getBoundingClientRect();
|
||||
const rel = event.clientX - box.left - rand.links;
|
||||
const i = Math.round((rel / flaecheB) * (labels.length - 1));
|
||||
setIndex(Math.min(Math.max(i, 0), labels.length - 1));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chart-line" ref={wirt}>
|
||||
{legende && (
|
||||
<ul className="chart-legend inline">
|
||||
{serien.map((s) => (
|
||||
<li key={s.name}>
|
||||
<span className="swatch" style={{ background: s.color }} />
|
||||
<span className="legend-name">{s.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="line-plot" ref={plot}>
|
||||
<svg width={w} height={h} role="img" aria-label={`Liniendiagramm über ${labels.length} Tage`}
|
||||
onMouseMove={bewegen} onMouseLeave={() => setIndex(null)}>
|
||||
{gitter.map((g) => (
|
||||
<g key={g.wert}>
|
||||
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y} stroke={gitterFarbe} strokeWidth="1" />
|
||||
<text x={rand.links - 8} y={g.y + 4} textAnchor="end" fontSize="11" fill={textFarbe}>{fmt(g.wert)}</text>
|
||||
</g>
|
||||
))}
|
||||
{achse.map(({ l, i }) => (
|
||||
<text key={l + i} x={x(i)} y={h - 6} textAnchor="middle" fontSize="11" fill={textFarbe}>{l}</text>
|
||||
))}
|
||||
{serien.map((s) => (
|
||||
<path key={s.name} fill="none" stroke={s.color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round"
|
||||
d={s.werte.map((wert, i) => `${i === 0 ? "M" : "L"} ${x(i)} ${y(wert)}`).join(" ")} />
|
||||
))}
|
||||
{index != null && (
|
||||
<>
|
||||
<line x1={x(index)} x2={x(index)} y1={rand.oben} y2={rand.oben + flaecheH} stroke={gitterFarbe} strokeWidth="1" />
|
||||
{serien.map((s) => (
|
||||
<circle key={s.name} cx={x(index)} cy={y(s.werte[index] ?? 0)} r="4.5"
|
||||
fill={s.color} stroke={tokenFarbe("--surface", "#fff")} strokeWidth="2" />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
{index != null && (
|
||||
<div className="line-tooltip" style={{ left: Math.min(Math.max(x(index), 60), w - 60) }}>
|
||||
<div className="tip-date">{labels[index]}</div>
|
||||
{serien.map((s) => (
|
||||
<div key={s.name} className="tip-row">
|
||||
<span className="swatch" style={{ background: s.color }} />
|
||||
{serien.length > 1 && <span className="tip-name">{s.name}</span>}
|
||||
<strong>{fmt(s.werte[index] ?? 0)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Misst die tatsächliche Größe eines Elements, damit ein SVG in echten Pixeln
|
||||
// zeichnen kann (feste viewBox mit Skalierung würde Linien/Schrift verzerren).
|
||||
export default function useGroesse(ref) {
|
||||
const [groesse, setGroesse] = useState({ breite: 0, hoehe: 0 });
|
||||
useEffect(() => {
|
||||
if (!ref.current) return undefined;
|
||||
const beobachter = new ResizeObserver(([eintrag]) => {
|
||||
setGroesse({ breite: eintrag.contentRect.width, hoehe: eintrag.contentRect.height });
|
||||
});
|
||||
beobachter.observe(ref.current);
|
||||
return () => beobachter.disconnect();
|
||||
}, [ref]);
|
||||
return groesse;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Farben für Diagramme. Kategorie-Farben in fester, farbfehlsicht-geprüfter
|
||||
// Reihenfolge; Zustandsfarben (grün/rot) kommen aus den CSS-Tokens.
|
||||
|
||||
export const CATEGORY_COLORS_LIGHT = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300"];
|
||||
export const CATEGORY_COLORS_DARK = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300"];
|
||||
export const OTHER_COLOR_LIGHT = "#8a8f98";
|
||||
export const OTHER_COLOR_DARK = "#6f7783";
|
||||
|
||||
export function istDunkel() {
|
||||
if (typeof document === "undefined") return false;
|
||||
const gesetzt = document.documentElement.getAttribute("data-theme");
|
||||
if (gesetzt === "dark") return true;
|
||||
if (gesetzt === "light") return false;
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
||||
}
|
||||
|
||||
export function kategorieFarben(dunkel) {
|
||||
return dunkel ? CATEGORY_COLORS_DARK : CATEGORY_COLORS_LIGHT;
|
||||
}
|
||||
|
||||
/** Liest einen CSS-Tokenwert aus (die Diagramme zeichnen in echtem SVG). */
|
||||
export function tokenFarbe(name, fallback = "#888") {
|
||||
if (typeof document === "undefined") return fallback;
|
||||
const wert = getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
return wert?.trim() || fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user