feat: Zeigen, welche Buchungen in einem Ausgaben-Anteil stecken
Eine Zahl, die man nicht aufschluesseln kann, muss man glauben. Genau das war der Streitpunkt: Die Karte nannte fuer Lebensmittel einen Betrag, und es gab keine Moeglichkeit nachzusehen, welche Zahlungen darin einbezogen sind und welche nicht. Jetzt oeffnet ein Klick auf einen Anteil - in der Liste wie in der Legende des Kreises - die Buchungen dahinter: Datum, Empfaenger, Konto, Betrag. Die Liste kommt aus derselben Stelle, die auch die Summe bildet (`budget.expense_rows`), sie kann also gar nicht etwas anderes zeigen als das, was gezaehlt wurde. Ein Test haelt das fest. Steht eine Buchung darin, obwohl ihr Datum ausserhalb des Zeitraums liegt, traegt sie die Kalendermarke: Dann wurde ihr Budget-Monat von Hand gesetzt, und das ist der Grund. Nebenbei: Dialoge haengen jetzt am Seitenkoerper. Kacheln der Uebersicht werden per `transform` positioniert - damit war die Kachel der Bezugsrahmen fuer `position: fixed`, und der Dialog sass eingeklemmt darin statt ueber der Seite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -329,6 +329,9 @@ export const api = {
|
||||
resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }),
|
||||
cardBudgetDonut: (groupBy) => request(`/dashboard/budget-donut${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||||
cardExpenseShare: (groupBy) => request(`/dashboard/expense-share${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||||
// Woraus ein Anteil besteht – dieselbe Stelle, die auch die Summe bildet.
|
||||
cardExpenseShareRows: (ids) =>
|
||||
request(`/dashboard/expense-share/buchungen?${ids.map((i) => `ids=${i}`).join("&")}`),
|
||||
cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`),
|
||||
cardIncomeExpense: () => request("/dashboard/income-expense"),
|
||||
cardBalances: () => request("/dashboard/balances"),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import Icon from "./Icon";
|
||||
|
||||
@@ -9,7 +10,11 @@ export default function Modal({ title, onClose, children, footer, wide = false }
|
||||
// liegt das Mouseup auf dem Hintergrund; der Klick feuert dann auf dem
|
||||
// gemeinsamen Elternelement und schloss den Dialog mitten im Markieren.
|
||||
const startAussen = useRef(false);
|
||||
return (
|
||||
// An den Seitenkoerper, nicht an die Stelle im Baum: Steht der Aufrufer in
|
||||
// einem Element mit `transform` – jede Kachel der Uebersicht tut das –, waere
|
||||
// das sonst der Bezugsrahmen fuer `position: fixed`, und der Dialog sitzt
|
||||
// eingeklemmt in der Kachel statt ueber der Seite.
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(e) => { startAussen.current = e.target === e.currentTarget; }}
|
||||
@@ -28,6 +33,7 @@ export default function Modal({ title, onClose, children, footer, wide = false }
|
||||
<div className="modal-body">{children}</div>
|
||||
{footer && <div className="btn-pair">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { api } from "../api";
|
||||
import { useSettings } from "../settings";
|
||||
import IconChip from "../components/IconChip";
|
||||
import Modal from "../components/Modal";
|
||||
import Icon from "../components/Icon";
|
||||
import Money from "../components/Money";
|
||||
import { formatDate, formatMoney, formatAmount } from "../format";
|
||||
import { tokenFarbe } from "./palette";
|
||||
@@ -150,10 +152,71 @@ function RecentTransactions() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Woraus ein Anteil besteht.
|
||||
*
|
||||
* Eine Zahl, die man nicht aufschlüsseln kann, muss man glauben. Die Liste
|
||||
* kommt aus derselben Stelle wie die Summe auf der Karte – sie kann also gar
|
||||
* nicht etwas anderes zeigen als das, was gezählt wurde. */
|
||||
function AnteilDetails({ einheit, currency, onClose }) {
|
||||
const { t } = useTranslation();
|
||||
const { formatDate } = useSettings();
|
||||
const [data, setData] = useState(null);
|
||||
useEffect(() => {
|
||||
let ok = true;
|
||||
api.cardExpenseShareRows(einheit.ids || [])
|
||||
.then((d) => ok && setData(d))
|
||||
.catch(() => ok && setData({ rows: [], __error: true }));
|
||||
return () => { ok = false; };
|
||||
}, [einheit]);
|
||||
|
||||
return (
|
||||
<Modal title={einheit.name} onClose={onClose} wide>
|
||||
{!data ? <Loading /> : (
|
||||
<>
|
||||
<p className="muted small">{formatDate(data.von)} – {formatDate(data.bis)}</p>
|
||||
{!data.rows.length && <div className="dash-empty muted">{t("cards.noExpenses")}</div>}
|
||||
<div className="anteil-liste">
|
||||
{data.rows.map((r) => (
|
||||
<div className="anteil-zeile" key={r.id}>
|
||||
<span className="muted small anteil-datum">{formatDate(r.date)}</span>
|
||||
<span className="grow ellipsis">
|
||||
{r.title || "–"}
|
||||
{/* Steht eine Buchung hier, obwohl ihr Datum ausserhalb
|
||||
liegt, ist das der Grund – und dann soll man ihn sehen. */}
|
||||
{r.budget_period_manuell && (
|
||||
<span className="badge accent" style={{ marginLeft: 6 }}
|
||||
title={t("transactions.budgetPeriodManual")}>
|
||||
<Icon name="calendar" size={11} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="muted small ellipsis anteil-konto">{r.account}</span>
|
||||
<span className="amount small">
|
||||
{formatMoney(r.amount_cents, r.currency)}
|
||||
{/* Der umgerechnete Betrag nur, wenn er ein anderer ist –
|
||||
sonst steht dieselbe Zahl zweimal nebeneinander. */}
|
||||
{(r.currency !== currency || r.amount_cents !== r.base_cents) && (
|
||||
<span className="muted"> ≈ {formatMoney(r.base_cents, currency)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="dash-total">
|
||||
<span className="grow">{t("cards.total")}</span>
|
||||
<strong>{formatMoney(data.total, currency)}</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpenseShare({ props }) {
|
||||
const { t } = useTranslation();
|
||||
const groupBy = props?.group_by || "category";
|
||||
const data = useData(() => api.cardExpenseShare(groupBy));
|
||||
const [details, setDetails] = useState(null);
|
||||
if (!data) return <Loading />;
|
||||
if (!data.rows?.length) return <div className="dash-empty muted">{t("cards.noExpenses")}</div>;
|
||||
// Der Zeitraum gehoert dazu: Bei einem Budget-Monat, der nicht am Ersten
|
||||
@@ -177,7 +240,13 @@ function ExpenseShare({ props }) {
|
||||
gesamtLabel={t("cards.spent")}
|
||||
gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null}
|
||||
fmt={(v) => formatMoney(v, data.currency)}
|
||||
// Eine Zahl, die man nicht aufschlüsseln kann, muss man glauben.
|
||||
onWaehlen={(b) => setDetails(data.rows.find((r) => r.name === b.name))}
|
||||
/>
|
||||
{details && (
|
||||
<AnteilDetails einheit={details} currency={data.currency}
|
||||
onClose={() => setDetails(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -189,7 +258,8 @@ function ExpenseShare({ props }) {
|
||||
// sonst nicht 100 %.
|
||||
const pct = data.total > 0 ? (r.amount / data.total) * 100 : 0;
|
||||
return (
|
||||
<div className="dash-progress" key={r.name}>
|
||||
<button type="button" className="dash-progress anteil-knopf" key={r.name}
|
||||
onClick={() => setDetails(r)}>
|
||||
<div className="dash-progress-top share-row">
|
||||
<span style={{ color: r.color }}>●</span>
|
||||
<span className="grow ellipsis share-name">{r.name}</span>
|
||||
@@ -210,13 +280,17 @@ function ExpenseShare({ props }) {
|
||||
style={{ width: `${pct}%`,
|
||||
...(r.planned > 0 ? {} : { background: r.color }) }} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="dash-total">
|
||||
<span className="grow">{t("cards.total")}</span>
|
||||
<strong>{formatMoney(data.total, data.currency)}</strong>
|
||||
</div>
|
||||
{details && (
|
||||
<AnteilDetails einheit={details} currency={data.currency}
|
||||
onClose={() => setDetails(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { createElement, useState } from "react";
|
||||
|
||||
// Ringdiagramm für Anteile. segmente: [{ name, value, color, budget }]
|
||||
// `budget` ist "ok", "over" oder fehlt – fehlt es, wird nichts eingefärbt.
|
||||
// `gesamtBudget` gilt für die Mitte, solange kein Segment angezeigt wird.
|
||||
// fmt: Formatierer für die Werte (z. B. Geldbetrag).
|
||||
// onWaehlen: Ist es gesetzt, lässt sich ein Anteil anklicken – dann kann der
|
||||
// Aufrufer zeigen, woraus die Zahl besteht.
|
||||
export default function Donut({ segmente = [], gesamtLabel = "gesamt",
|
||||
gesamtBudget = null, fmt = (v) => String(v) }) {
|
||||
gesamtBudget = null, fmt = (v) => String(v),
|
||||
onWaehlen = null }) {
|
||||
const [aktiv, setAktiv] = useState(null);
|
||||
|
||||
const werte = segmente.filter((s) => s.value > 0);
|
||||
@@ -48,7 +51,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt",
|
||||
strokeWidth={gezeigt?.name === b.name ? dicke + 4 : dicke}
|
||||
strokeDasharray={`${b.laenge} ${umfang - b.laenge}`}
|
||||
strokeDashoffset={-b.versatz}
|
||||
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
|
||||
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}
|
||||
onClick={onWaehlen ? () => onWaehlen(b) : undefined}
|
||||
style={onWaehlen ? { cursor: "pointer" } : undefined} />
|
||||
))}
|
||||
</g>
|
||||
<text x={mitte} y={mitte - 4} textAnchor="middle"
|
||||
@@ -65,11 +70,21 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt",
|
||||
<ul className="chart-legend">
|
||||
{boegen.map((b) => (
|
||||
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
|
||||
<span className="swatch" style={{ background: b.color }} />
|
||||
<span className="legend-name">{b.name}</span>
|
||||
<span className={`legend-value${b.budget ? ` budget-${b.budget}` : ""}`}>
|
||||
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
|
||||
</span>
|
||||
{/* Anklickbar wird ein echter Knopf, kein Listeneintrag mit
|
||||
onClick: Sonst ist der Anteil weder mit der Tastatur noch
|
||||
mit einem Screenreader erreichbar. */}
|
||||
{createElement(onWaehlen ? "button" : "div",
|
||||
onWaehlen
|
||||
? { type: "button", className: "legend-zeile klickbar",
|
||||
onClick: () => onWaehlen(b) }
|
||||
: { className: "legend-zeile" },
|
||||
<>
|
||||
<span className="swatch" style={{ background: b.color }} />
|
||||
<span className="legend-name">{b.name}</span>
|
||||
<span className={`legend-value${b.budget ? ` budget-${b.budget}` : ""}`}>
|
||||
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
|
||||
</span>
|
||||
</>)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1523,3 +1523,67 @@ tr.row-dup {
|
||||
font-weight: 580;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ---------- Woraus ein Anteil besteht ----------
|
||||
Eine Zahl, die man nicht aufschlüsseln kann, muss man glauben. Deshalb ist
|
||||
jede Zeile der Ausgaben-Anteile ein Knopf – sie sieht aus wie vorher, führt
|
||||
aber zu den Buchungen dahinter. */
|
||||
.anteil-knopf {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.anteil-knopf:hover .share-name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chart-legend li > .legend-zeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.chart-legend li > .legend-zeile.klickbar {
|
||||
cursor: pointer;
|
||||
}
|
||||
.chart-legend li > .legend-zeile.klickbar:hover .legend-name {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.anteil-liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.anteil-zeile {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-3);
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
/* Feste Breite für Datum und Konto: Sonst rutscht der Betrag von Zeile zu
|
||||
Zeile, und genau die Beträge will man hier untereinander vergleichen. */
|
||||
.anteil-datum {
|
||||
flex: 0 0 auto;
|
||||
min-width: 5.5em;
|
||||
}
|
||||
.anteil-konto {
|
||||
flex: 0 1 auto;
|
||||
max-width: 9em;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user