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}
+
+ ); + })} +
+ )}
); }