Zeigt für den laufenden Budget-Monat, wie sich die tatsächlichen Ausgaben prozentual auf die Kategorien verteilen – absteigend sortiert, mit Balken in der Kategoriefarbe, Prozentwert und Betrag. Anders als der Budget-Donut hängt sie nicht am geplanten Budget: Sie zeigt reine Ist-Ausgaben, auch wenn für den Monat nichts geplant wurde. Umschaltbar zwischen Kategorien (Standard) und Gruppen. Die Anteile rechnet die Oberfläche aus Betrag und Summe – gerundete Prozente vom Server addierten sich sonst nicht auf 100 %. Wird die Karte schmal, gibt zuerst der Betrag nach; ohne Kategorienamen sagt die Zeile nichts mehr aus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
209 lines
8.4 KiB
React
209 lines
8.4 KiB
React
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 <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({ props }) {
|
||
const { t } = useTranslation();
|
||
const groupBy = props?.group_by || "group";
|
||
const data = useData(() => api.cardBudgetDonut(groupBy));
|
||
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)}
|
||
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 <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>
|
||
);
|
||
}
|
||
|
||
function ExpenseShare({ props }) {
|
||
const { t } = useTranslation();
|
||
const groupBy = props?.group_by || "category";
|
||
const data = useData(() => api.cardExpenseShare(groupBy));
|
||
if (!data) return <Loading />;
|
||
if (!data.rows?.length) return <div className="dash-empty muted">{t("cards.noExpenses")}</div>;
|
||
return (
|
||
<div className="dash-list">
|
||
{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 (
|
||
<div className="dash-progress" key={r.name}>
|
||
<div className="dash-progress-top share-row">
|
||
<span style={{ color: r.color }}>●</span>
|
||
<span className="grow ellipsis share-name">{r.name}</span>
|
||
<strong className="share-pct">{pct.toFixed(1).replace(".", ",")} %</strong>
|
||
<span className="muted small share-amount">{formatMoney(r.amount, data.currency)}</span>
|
||
</div>
|
||
<div className="budget-bar">
|
||
<div className="budget-bar-fill" style={{ width: `${pct}%`, background: r.color }} />
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
<div className="dash-total">
|
||
<span className="grow">{t("cards.total")}</span>
|
||
<strong>{formatMoney(data.total, data.currency)}</strong>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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" },
|
||
],
|
||
};
|
||
|
||
// 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 },
|
||
"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" }] },
|
||
"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);
|