Die Karte zeigte nur, wie sich die Ausgaben verteilen – nicht, ob sie im Rahmen liegen. Der Betrag ist jetzt gruen, solange er unter dem Geplanten bleibt, und rot darueber. Dieselben Farben wie ueberall sonst fuer Einnahme und Ausgabe. Ohne Plan bleibt der Betrag gedeckt. "Ueber Budget" waere sonst die Aussage ueber jede Kategorie, fuer die nie eine Vorgabe gemacht wurde – die Karte stuende dann fast durchgehend auf Rot und sagte nichts mehr. Das Geplante kommt aus derselben Quelle wie im Budget-Fortschritt, also einschliesslich eines Monats-Werts, der die Vorlage uebersteuert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
9.8 KiB
React
248 lines
9.8 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({ props }) {
|
||
const { t } = useTranslation();
|
||
const tage = Number(props?.tage) || 90;
|
||
const data = useData(() => api.cardNetWorth(tage));
|
||
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>;
|
||
// Kreis: der Donut beschriftet seine Legende selbst mit Betrag und Anteil.
|
||
if ((props?.darstellung || "liste") === "kreis") {
|
||
return (
|
||
<Donut
|
||
segmente={data.rows.map((r) => ({ name: r.name, value: r.amount, color: r.color }))}
|
||
gesamtLabel={t("cards.spent")}
|
||
fmt={(v) => formatMoney(v, data.currency)}
|
||
/>
|
||
);
|
||
}
|
||
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>
|
||
{/* 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. */}
|
||
<span className={`amount small share-amount ${
|
||
r.planned > 0 ? (r.over ? "neg" : "pos") : "muted"}`}>
|
||
{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" },
|
||
],
|
||
};
|
||
|
||
// 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);
|