Files
certain/web/src/dashboard/cards.jsx
T
GuidoandClaude Opus 5 5e70644f73 feat: Ausgaben-Anteile faerben nach Budget
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>
2026-08-30 14:05:28 +02:00

248 lines
9.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);