diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 4e1d64e..af26fad 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -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),
kein Rollover in v1.
-## Phase 3 – Dashboard
+## Phase 3 – Dashboard ✅
Konfigurierbares Karten-Grid: Budget-Donut, Kontostände, Vermögensverlauf,
Budget-Fortschritt, Einnahmen/Ausgaben, letzte Buchungen.
diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx
new file mode 100644
index 0000000..a59d5e6
--- /dev/null
+++ b/web/src/dashboard/cards.jsx
@@ -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
{t("common.loading")}
;
+}
+
+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 ;
+ if (!data.accounts?.length) return {t("accounts.empty")}
;
+ return (
+
+ {data.accounts.map((a) => (
+
+
+ {a.name}
+
+
+ ))}
+
+ {t("cards.total")}
+ {formatMoney(data.total, data.currency)}
+
+
+ );
+}
+
+function BudgetDonut() {
+ const { t } = useTranslation();
+ const data = useData(() => api.cardBudgetDonut());
+ if (!data) return ;
+ if (!data.segments?.length) return {t("cards.noBudget")}
;
+ const label = data.mode === "planned" ? t("cards.planned") : t("cards.spent");
+ return formatMoney(v, data.currency)} />;
+}
+
+function NetWorth() {
+ const { t } = useTranslation();
+ const data = useData(() => api.cardNetWorth(90));
+ if (!data) return ;
+ const labels = (data.labels || []).map(shortDate);
+ const serien = [{ name: t("cards.assets"), color: tokenFarbe("--accent", "#3f51b5"), werte: data.values || [] }];
+ return formatMoney(v, data.currency)} />;
+}
+
+function BudgetProgress() {
+ const { t } = useTranslation();
+ const data = useData(() => api.cardBudgetProgress());
+ if (!data) return ;
+ if (!data.rows?.length) return {t("cards.noBudget")}
;
+ return (
+
+ {data.rows.map((r) => {
+ const pct = r.planned > 0 ? Math.min(r.actual / r.planned, 1) * 100 : r.actual > 0 ? 100 : 0;
+ return (
+
+
+ ●
+ {r.name}
+ {formatMoney(r.actual, data.currency)}
+ / {formatMoney(r.planned, data.currency)}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function IncomeExpense() {
+ const { t } = useTranslation();
+ const data = useData(() => api.cardIncomeExpense());
+ if (!data) return ;
+ return (
+
+
+ {t("cards.income")}
+ {formatMoney(data.income, data.currency)}
+
+
+ {t("cards.expense")}
+ {formatMoney(data.expense, data.currency)}
+
+
+ {t("cards.net")}
+ {formatMoney(data.net, data.currency)}
+
+
+ );
+}
+
+function RecentTransactions() {
+ const { t } = useTranslation();
+ const { formatDate } = useSettings();
+ const data = useData(() => api.cardRecent(8));
+ if (!data) return ;
+ if (!data.transactions?.length) return {t("transactions.empty")}
;
+ return (
+
+ {data.transactions.map((tx) => (
+
+
+
+
{tx.title}
+
{formatDate(tx.date)}
+
+
+
+ ))}
+
+ );
+}
+
+// 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);
diff --git a/web/src/finance.css b/web/src/finance.css
index 29523ba..59c8a9f 100644
--- a/web/src/finance.css
+++ b/web/src/finance.css
@@ -371,3 +371,151 @@ tr.budget-total-row td {
color: var(--text);
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);
+}
diff --git a/web/src/locales/de.json b/web/src/locales/de.json
index 198d528..2352452 100644
--- a/web/src/locales/de.json
+++ b/web/src/locales/de.json
@@ -76,7 +76,28 @@
},
"dashboard": {
"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": {
"unreachable": "Server nicht erreichbar.",
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index ede4da9..583630f 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -76,7 +76,28 @@
},
"dashboard": {
"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": {
"unreachable": "Server unreachable.",
diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx
index 058b482..19d9a77 100644
--- a/web/src/pages/Dashboard.jsx
+++ b/web/src/pages/Dashboard.jsx
@@ -1,18 +1,151 @@
+import { createElement, useEffect, useState } from "react";
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 { KARTEN, KARTEN_TYPEN } from "../dashboard/cards";
+
+const Grid = WidthProvider(GridLayout);
-// Platzhalter: das konfigurierbare Karten-Dashboard wird in Phase 3 aufgebaut.
export default function Dashboard() {
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 {t("common.loading")}
;
+
+ const present = new Set(items.map((it) => it.type));
+ const missing = KARTEN_TYPEN.filter((tp) => !present.has(tp));
+
return (
-
{t("dashboard.title")}
-
-
-
-
{t("dashboard.empty")}
+
+
{t("dashboard.title")}
+
+ {edit && missing.length > 0 && (
+
+
+ {addOpen && (
+
+ {missing.map((tp) => (
+
+ ))}
+
+ )}
+
+ )}
+ {edit && (
+
+ )}
+
+
+ {items.length === 0 ? (
+
+
+
+
{t("dashboard.emptyCards")}
+
+
+ ) : (
+
setItems(merge(l))}
+ onDragStop={(l) => persist(merge(l))}
+ onResizeStop={(l) => persist(merge(l))}
+ useCSSTransforms
+ >
+ {items.map((it) => {
+ const def = KARTEN[it.type];
+ return (
+
+
+ {def ? t(def.titleKey) : it.type}
+ {edit && (
+
+ )}
+
+
{def ? createElement(def.Comp) : null}
+
+ );
+ })}
+
+ )}
);
}