feat: Phase 3 – konfigurierbares Karten-Dashboard

Frontend:
- Dashboard-Seite mit react-grid-layout: Karten frei verschieben/skalieren,
  hinzufügen/entfernen, Standard-Layout zurücksetzen; Layout je Nutzer gespeichert.
- Sechs Karten: Kontostände, Budget-Donut, Vermögensverlauf (Linien-Chart),
  Budget-Fortschritt (grün/rot), Einnahmen/Ausgaben, letzte Buchungen.
- SVG-Charts (Donut/Line) mit währungsbewusster Formatierung; i18n DE/EN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 19:42:14 +02:00
co-authored by Claude Opus 4.8
parent a998f70241
commit b871815a46
6 changed files with 488 additions and 9 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ Einstellbarer Budget-Monat-Starttag, Standard-Budget als Vorlage (Snapshot-on-
Customize), geplante Einnahmen mit Zielkonto, Soll/Ist-Tabelle (grün/rot), Customize), geplante Einnahmen mit Zielkonto, Soll/Ist-Tabelle (grün/rot),
kein Rollover in v1. kein Rollover in v1.
## Phase 3 – Dashboard ## Phase 3 – Dashboard ✅
Konfigurierbares Karten-Grid: Budget-Donut, Kontostände, Vermögensverlauf, Konfigurierbares Karten-Grid: Budget-Donut, Kontostände, Vermögensverlauf,
Budget-Fortschritt, Einnahmen/Ausgaben, letzte Buchungen. Budget-Fortschritt, Einnahmen/Ausgaben, letzte Buchungen.
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
import { useSettings } from "../settings";
import IconChip from "../components/IconChip";
import Money from "../components/Money";
import { formatMoney } from "../format";
import { tokenFarbe } from "./palette";
import Donut from "./charts/Donut";
import Line from "./charts/Line";
function useData(loader) {
const [data, setData] = useState(null);
useEffect(() => {
let ok = true;
loader().then((d) => ok && setData(d)).catch(() => ok && setData({ __error: true }));
return () => { ok = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return data;
}
function Loading() {
const { t } = useTranslation();
return <div className="dash-loading muted">{t("common.loading")}</div>;
}
function shortDate(iso) {
const [, m, d] = iso.split("-");
return `${d}.${m}.`;
}
function AccountBalances() {
const { t } = useTranslation();
const { baseCurrency } = useSettings();
const data = useData(() => api.cardBalances());
if (!data) return <Loading />;
if (!data.accounts?.length) return <div className="dash-empty muted">{t("accounts.empty")}</div>;
return (
<div className="dash-list">
{data.accounts.map((a) => (
<div className="dash-line" key={a.id}>
<IconChip name={a.icon} color={a.color} size={26} />
<span className="grow ellipsis">{a.name}</span>
<Money cents={a.balance_cents} currency={a.currency} baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
</div>
))}
<div className="dash-total">
<span className="grow">{t("cards.total")}</span>
<strong>{formatMoney(data.total, data.currency)}</strong>
</div>
</div>
);
}
function BudgetDonut() {
const { t } = useTranslation();
const data = useData(() => api.cardBudgetDonut());
if (!data) return <Loading />;
if (!data.segments?.length) return <div className="dash-empty muted">{t("cards.noBudget")}</div>;
const label = data.mode === "planned" ? t("cards.planned") : t("cards.spent");
return <Donut segmente={data.segments} gesamtLabel={label} fmt={(v) => formatMoney(v, data.currency)} />;
}
function NetWorth() {
const { t } = useTranslation();
const data = useData(() => api.cardNetWorth(90));
if (!data) return <Loading />;
const labels = (data.labels || []).map(shortDate);
const serien = [{ name: t("cards.assets"), color: tokenFarbe("--accent", "#3f51b5"), werte: data.values || [] }];
return <Line labels={labels} serien={serien} fmt={(v) => formatMoney(v, data.currency)} />;
}
function BudgetProgress() {
const { t } = useTranslation();
const data = useData(() => api.cardBudgetProgress());
if (!data) return <Loading />;
if (!data.rows?.length) return <div className="dash-empty muted">{t("cards.noBudget")}</div>;
return (
<div className="dash-list">
{data.rows.map((r) => {
const pct = r.planned > 0 ? Math.min(r.actual / r.planned, 1) * 100 : r.actual > 0 ? 100 : 0;
return (
<div className="dash-progress" key={r.name}>
<div className="dash-progress-top">
<span className="ellipsis" style={{ color: r.color }}>●</span>
<span className="grow ellipsis">{r.name}</span>
<span className={`amount small ${r.over ? "neg" : "muted"}`}>{formatMoney(r.actual, data.currency)}</span>
<span className="muted small"> / {formatMoney(r.planned, data.currency)}</span>
</div>
<div className="budget-bar">
<div className={`budget-bar-fill${r.over ? " over" : ""}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
);
}
function IncomeExpense() {
const { t } = useTranslation();
const data = useData(() => api.cardIncomeExpense());
if (!data) return <Loading />;
return (
<div className="dash-kpis">
<div className="dash-kpi">
<span className="muted small">{t("cards.income")}</span>
<strong className="amount pos">{formatMoney(data.income, data.currency)}</strong>
</div>
<div className="dash-kpi">
<span className="muted small">{t("cards.expense")}</span>
<strong className="amount neg">{formatMoney(data.expense, data.currency)}</strong>
</div>
<div className="dash-kpi">
<span className="muted small">{t("cards.net")}</span>
<strong className={`amount ${data.net < 0 ? "neg" : "pos"}`}>{formatMoney(data.net, data.currency)}</strong>
</div>
</div>
);
}
function RecentTransactions() {
const { t } = useTranslation();
const { formatDate } = useSettings();
const data = useData(() => api.cardRecent(8));
if (!data) return <Loading />;
if (!data.transactions?.length) return <div className="dash-empty muted">{t("transactions.empty")}</div>;
return (
<div className="dash-list">
{data.transactions.map((tx) => (
<div className="dash-line" key={tx.id}>
<IconChip name={tx.icon} color={tx.color} size={26} />
<div className="grow ellipsis">
<div className="ellipsis">{tx.title}</div>
<div className="muted small">{formatDate(tx.date)}</div>
</div>
<Money cents={tx.amount_cents} currency={tx.currency} baseCents={tx.amount_base_cents}
baseCurrency={data.currency} sign={tx.sign} showConverted={false} />
</div>
))}
</div>
);
}
// Registry: Kartentyp -> { Titel-Schlüssel, Komponente, Standardgröße/Mindestgröße }.
export const KARTEN = {
"account-balances": { titleKey: "cards.balances", Comp: AccountBalances, w: 4, h: 5, minW: 3, minH: 3 },
"budget-donut": { titleKey: "cards.budgetDonut", Comp: BudgetDonut, w: 4, h: 5, minW: 3, minH: 4 },
"net-worth": { titleKey: "cards.netWorth", Comp: NetWorth, w: 8, h: 4, minW: 4, minH: 3 },
"budget-progress": { titleKey: "cards.budgetProgress", Comp: BudgetProgress, w: 4, h: 3, minW: 3, minH: 2 },
"income-expense": { titleKey: "cards.incomeExpense", Comp: IncomeExpense, w: 4, h: 2, minW: 2, minH: 2 },
"recent-transactions": { titleKey: "cards.recent", Comp: RecentTransactions, w: 4, h: 4, minW: 3, minH: 3 },
};
export const KARTEN_TYPEN = Object.keys(KARTEN);
+148
View File
@@ -371,3 +371,151 @@ tr.budget-total-row td {
color: var(--text); color: var(--text);
font-weight: 560; font-weight: 560;
} }
/* ---------- Dashboard-Grid ---------- */
.dash-toolbar {
display: flex;
align-items: center;
gap: var(--sp-2);
position: relative;
}
.dash-add {
position: relative;
}
.dash-add-menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 20;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
min-width: 200px;
overflow: hidden;
}
.dash-add-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
border: none;
background: none;
color: var(--text);
cursor: pointer;
}
.dash-add-item:hover {
background: var(--surface-2);
}
.dash-grid {
position: relative;
}
.dash-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
.dash-card-head {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 8px 12px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.dash-card-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
font-weight: 650;
flex: 1;
}
.dash-grid.editing .dash-card-head {
cursor: move;
background: var(--surface-2);
}
.dash-remove {
color: var(--danger);
}
.dash-card-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--sp-3);
}
.dash-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.dash-line {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.dash-line:last-child {
border-bottom: none;
}
.dash-total {
display: flex;
align-items: baseline;
gap: var(--sp-2);
padding-top: var(--sp-2);
margin-top: 2px;
border-top: 2px solid var(--border-strong);
font-variant-numeric: tabular-nums;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-loading, .dash-empty {
padding: var(--sp-4);
text-align: center;
}
.dash-progress {
padding: 6px 0;
}
.dash-progress-top {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: 4px;
}
.dash-kpis {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--sp-2);
height: 100%;
}
.dash-kpi {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.dash-kpi strong {
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
}
/* react-grid-layout: Platzhalter + Resize-Griff an das Theme angleichen. */
.react-grid-item.react-grid-placeholder {
background: var(--accent);
opacity: 0.15;
border-radius: var(--radius);
}
.react-grid-item > .react-resizable-handle::after {
border-right-color: var(--border-strong);
border-bottom-color: var(--border-strong);
}
+22 -1
View File
@@ -76,7 +76,28 @@
}, },
"dashboard": { "dashboard": {
"title": "Übersicht", "title": "Übersicht",
"empty": "Noch keine Karten. Das Dashboard wird in einer späteren Phase ausgebaut." "emptyCards": "Keine Karten vorhanden – über den Anordnen-Modus hinzufügen.",
"edit": "Anordnen",
"done": "Fertig",
"addCard": "Karte hinzufügen",
"reset": "Zurücksetzen",
"resetConfirm": "Dashboard auf das Standard-Layout zurücksetzen?"
},
"cards": {
"balances": "Kontostände",
"budgetDonut": "Budget",
"netWorth": "Vermögensverlauf",
"budgetProgress": "Budget-Fortschritt",
"incomeExpense": "Einnahmen & Ausgaben",
"recent": "Letzte Buchungen",
"total": "Gesamt",
"planned": "geplant",
"spent": "ausgegeben",
"assets": "Vermögen",
"income": "Einnahmen",
"expense": "Ausgaben",
"net": "Saldo",
"noBudget": "Noch kein Budget für diesen Monat."
}, },
"server": { "server": {
"unreachable": "Server nicht erreichbar.", "unreachable": "Server nicht erreichbar.",
+22 -1
View File
@@ -76,7 +76,28 @@
}, },
"dashboard": { "dashboard": {
"title": "Overview", "title": "Overview",
"empty": "No cards yet. The dashboard will be built out in a later phase." "emptyCards": "No cards. Click “Arrange” and add some.",
"edit": "Arrange",
"done": "Done",
"addCard": "Add card",
"reset": "Reset",
"resetConfirm": "Reset the dashboard to the default layout?"
},
"cards": {
"balances": "Account balances",
"budgetDonut": "Budget",
"netWorth": "Net worth",
"budgetProgress": "Budget progress",
"incomeExpense": "Income & expenses",
"recent": "Recent transactions",
"total": "Total",
"planned": "planned",
"spent": "spent",
"assets": "Net worth",
"income": "Income",
"expense": "Expenses",
"net": "Balance",
"noBudget": "No budget for this month yet."
}, },
"server": { "server": {
"unreachable": "Server unreachable.", "unreachable": "Server unreachable.",
+139 -6
View File
@@ -1,18 +1,151 @@
import { createElement, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import GridLayout, { WidthProvider } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { KARTEN, KARTEN_TYPEN } from "../dashboard/cards";
const Grid = WidthProvider(GridLayout);
// Platzhalter: das konfigurierbare Karten-Dashboard wird in Phase 3 aufgebaut.
export default function Dashboard() { export default function Dashboard() {
const { t } = useTranslation(); const { t } = useTranslation();
const confirm = useConfirm();
const toast = useToast();
const [items, setItems] = useState(null);
const [edit, setEdit] = useState(false);
const [addOpen, setAddOpen] = useState(false);
function normalize(it) {
const def = KARTEN[it.type] || {};
return { i: it.i || it.type, minW: def.minW || 2, minH: def.minH || 2, ...it };
}
async function load() {
try {
const res = await api.dashboardLayout();
setItems(res.layout.filter((it) => KARTEN[it.type]).map(normalize));
} catch (e) {
toast(e.message, "error");
setItems([]);
}
}
useEffect(() => { load(); }, []);
function merge(layout) {
const byId = Object.fromEntries((items || []).map((it) => [it.i, it]));
return layout.map((l) => ({ ...byId[l.i], x: l.x, y: l.y, w: l.w, h: l.h }));
}
async function persist(next) {
setItems(next);
try {
await api.saveDashboardLayout(next.map((it) => ({ i: it.i, type: it.type, x: it.x, y: it.y, w: it.w, h: it.h })));
} catch (e) {
toast(e.message, "error");
}
}
function addCard(type) {
const def = KARTEN[type];
const maxY = items.reduce((m, it) => Math.max(m, it.y + it.h), 0);
setAddOpen(false);
persist([...items, normalize({ i: type, type, x: 0, y: maxY, w: def.w, h: def.h })]);
}
function removeCard(i) {
persist(items.filter((it) => it.i !== i));
}
async function reset() {
if (!(await confirm({ title: t("dashboard.reset"), message: t("dashboard.resetConfirm"), confirmLabel: t("dashboard.reset") }))) return;
try {
const res = await api.resetDashboardLayout();
setItems(res.layout.filter((it) => KARTEN[it.type]).map(normalize));
setEdit(false);
} catch (e) {
toast(e.message, "error");
}
}
if (!items) return <div className="muted">{t("common.loading")}</div>;
const present = new Set(items.map((it) => it.type));
const missing = KARTEN_TYPEN.filter((tp) => !present.has(tp));
return ( return (
<div> <div>
<h1>{t("dashboard.title")}</h1> <div className="page-head">
<div className="card"> <h1>{t("dashboard.title")}</h1>
<div className="empty-state"> <div className="dash-toolbar">
<Icon name="pie-chart" size={40} /> {edit && missing.length > 0 && (
<p>{t("dashboard.empty")}</p> <div className="dash-add">
<button className="btn sm" onClick={() => setAddOpen((o) => !o)}>
<Icon name="plus" size={15} /> {t("dashboard.addCard")}
</button>
{addOpen && (
<div className="dash-add-menu">
{missing.map((tp) => (
<button key={tp} className="dash-add-item" onClick={() => addCard(tp)}>
{t(KARTEN[tp].titleKey)}
</button>
))}
</div>
)}
</div>
)}
{edit && (
<button className="btn sm" onClick={reset}>
<Icon name="refresh-ccw" size={14} /> {t("dashboard.reset")}
</button>
)}
<button className={`btn sm ${edit ? "primary" : ""}`} onClick={() => { setEdit((e) => !e); setAddOpen(false); }}>
<Icon name={edit ? "check" : "edit"} size={14} /> {edit ? t("dashboard.done") : t("dashboard.edit")}
</button>
</div> </div>
</div> </div>
{items.length === 0 ? (
<div className="card">
<div className="empty-state">
<Icon name="pie-chart" size={40} />
<p>{t("dashboard.emptyCards")}</p>
</div>
</div>
) : (
<Grid
className={`dash-grid${edit ? " editing" : ""}`}
cols={12}
rowHeight={44}
margin={[16, 16]}
isDraggable={edit}
isResizable={edit}
draggableHandle=".dash-card-head"
layout={items}
onLayoutChange={(l) => setItems(merge(l))}
onDragStop={(l) => persist(merge(l))}
onResizeStop={(l) => persist(merge(l))}
useCSSTransforms
>
{items.map((it) => {
const def = KARTEN[it.type];
return (
<div key={it.i} className="dash-card">
<div className="dash-card-head">
<span className="dash-card-title">{def ? t(def.titleKey) : it.type}</span>
{edit && (
<button className="dash-remove btn-icon" onClick={() => removeCard(it.i)} title={t("common.delete")}>
<Icon name="close" size={14} />
</button>
)}
</div>
<div className="dash-card-body">{def ? createElement(def.Comp) : null}</div>
</div>
);
})}
</Grid>
)}
</div> </div>
); );
} }