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, formatAmount } 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({ props }) { const { t } = useTranslation(); const groupBy = props?.group_by || "group"; const data = useData(() => api.cardBudgetDonut(groupBy)); 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({ props }) { const { t } = useTranslation(); const tage = Number(props?.tage) || 90; const data = useData(() => api.cardNetWorth(tage)); 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)} achsenFmt={(v) => formatAmount(v, data.currency)} /> ); } function BudgetProgress({ props }) { const { t } = useTranslation(); const groupBy = props?.group_by || "group"; const data = useData(() => api.cardBudgetProgress(groupBy)); 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)}
))}
); } function ExpenseShare({ props }) { const { t } = useTranslation(); const groupBy = props?.group_by || "category"; const data = useData(() => api.cardExpenseShare(groupBy)); if (!data) return ; if (!data.rows?.length) return
{t("cards.noExpenses")}
; // Kreis: der Donut beschriftet seine Legende selbst mit Betrag und Anteil. if ((props?.darstellung || "liste") === "kreis") { return ( ({ name: r.name, value: r.amount, color: r.color }))} gesamtLabel={t("cards.spent")} fmt={(v) => formatMoney(v, data.currency)} /> ); } return (
{data.rows.map((r) => { // Anteil aus den Rohwerten – gerundete Prozente ergäben in der Summe // sonst nicht 100 %. const pct = data.total > 0 ? (r.amount / data.total) * 100 : 0; return (
● {r.name} {pct.toFixed(1).replace(".", ",")} % {/* Grün im geplanten Rahmen, rot darüber – dieselben Farben wie überall sonst für Einnahme und Ausgabe. Ohne Plan bleibt der Betrag gedeckt: Da gibt es nichts zu überschreiten. */} 0 ? (r.over ? "neg" : "pos") : "muted"}`}> {formatMoney(r.amount, data.currency)}
); })}
{t("cards.total")} {formatMoney(data.total, data.currency)}
); } // Aufschlüsselungs-Option (Gruppen vs. Kategorien) – von Donut und Fortschritt geteilt. const AUFSCHLUESSELUNG = { key: "group_by", labelKey: "cards.breakdown", def: "group", choices: [ { v: "group", labelKey: "cards.byGroup" }, { v: "category", labelKey: "cards.byCategory" }, ], }; // Zeitraum des Vermögensverlaufs. const ZEITRAUM = { key: "tage", labelKey: "cards.range", def: "90", choices: [ { v: "30", labelKey: "cards.days30" }, { v: "90", labelKey: "cards.days90" }, { v: "180", labelKey: "cards.days180" }, { v: "365", labelKey: "cards.days365" }, ], }; // Liste oder Kreis – dieselben Zahlen, zwei Lesarten: die Liste nennt jeden // Anteil genau, der Kreis zeigt die Verteilung auf einen Blick. const DARSTELLUNG = { key: "darstellung", labelKey: "cards.display", def: "liste", choices: [ { v: "liste", labelKey: "cards.asList" }, { v: "kreis", labelKey: "cards.asPie" }, ], }; // Registry: Kartentyp -> { Titel-Schlüssel, Komponente, Standardgröße/Mindestgröße, // optional `options` (im Anordnen-Modus einstellbare Karten-Optionen) }. 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, options: [AUFSCHLUESSELUNG] }, "net-worth": { titleKey: "cards.netWorth", Comp: NetWorth, w: 8, h: 4, minW: 4, minH: 3, options: [ZEITRAUM] }, "budget-progress": { titleKey: "cards.budgetProgress", Comp: BudgetProgress, w: 4, h: 3, minW: 3, minH: 2, options: [AUFSCHLUESSELUNG] }, "expense-share": { titleKey: "cards.expenseShare", Comp: ExpenseShare, w: 4, h: 5, minW: 3, minH: 3, options: [{ ...AUFSCHLUESSELUNG, def: "category" }, DARSTELLUNG] }, "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);