Files
certain/web/src/dashboard/cards.jsx
T
GuidoandClaude Opus 4.8 e10ccdf185 feat: Dashboard-Karte „Ausgaben-Anteile"
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>
2026-08-21 09:30:14 +02:00

209 lines
8.4 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() {
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);