feat: Die Buchungsliste zeigt die Notiz und laesst waehlen, welche Arten erscheinen

Die Notiz stand nur im Bearbeiten-Dialog, obwohl man sie schreibt, um sie
spaeter wiederzufinden. Jetzt hat sie eine eigene Spalte mit eigenem Filter;
der ganze Text steht im Tooltip. Das Feld im Dialog ist wieder einzeilig und
laesst sich bei Bedarf aufziehen.

Statt einer Einfachauswahl gibt es drei Haken: Ausgaben, Einnahmen,
Uebertraege. Man will oft zwei Arten gleichzeitig sehen und eine dritte
weglassen.

Und "nur Einnahmen" zeigt keine Uebertraege mehr. Eine verknuepfte Buchung ist
weder Ausgabe noch Einnahme, sie verschiebt nur Geld - nach ihrem rohen Typ
gefiltert stand sie trotzdem unter den Einnahmen, und weil der Server nur eine
Seite des Paares lieferte, sogar einzeln statt als eine Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-19 11:17:37 +02:00
co-authored by Claude Opus 5
parent 50fc0e3353
commit 498774b0da
5 changed files with 56 additions and 15 deletions
+8
View File
@@ -286,6 +286,14 @@
} }
/* Filterleiste. */ /* Filterleiste. */
.art-filter {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
}
.art-filter .checkline { margin-bottom: 0; }
.filter-bar { .filter-bar {
display: flex; display: flex;
gap: var(--sp-3); gap: var(--sp-3);
+3 -1
View File
@@ -318,7 +318,9 @@
"empty": "Noch keine Buchungen.", "empty": "Noch keine Buchungen.",
"needAccount": "Lege zuerst ein Konto an, um Buchungen zu erfassen.", "needAccount": "Lege zuerst ein Konto an, um Buchungen zu erfassen.",
"allAccounts": "Alle Konten", "allAccounts": "Alle Konten",
"allTypes": "Alle Typen", "filterExpenses": "Ausgaben",
"filterIncome": "Einnahmen",
"filterTransfers": "Überträge",
"deleteConfirm": "Diese Buchung löschen?", "deleteConfirm": "Diese Buchung löschen?",
"amountRequired": "Bitte einen Betrag eingeben.", "amountRequired": "Bitte einen Betrag eingeben.",
"accountRequired": "Bitte ein Konto wählen.", "accountRequired": "Bitte ein Konto wählen.",
+3 -1
View File
@@ -318,7 +318,9 @@
"empty": "No transactions yet.", "empty": "No transactions yet.",
"needAccount": "Create an account first to record transactions.", "needAccount": "Create an account first to record transactions.",
"allAccounts": "All accounts", "allAccounts": "All accounts",
"allTypes": "All types", "filterExpenses": "Expenses",
"filterIncome": "Income",
"filterTransfers": "Transfers",
"deleteConfirm": "Delete this transaction?", "deleteConfirm": "Delete this transaction?",
"amountRequired": "Please enter an amount.", "amountRequired": "Please enter an amount.",
"accountRequired": "Please choose an account.", "accountRequired": "Please choose an account.",
+41 -12
View File
@@ -59,7 +59,11 @@ export default function Transactions() {
const [accounts, setAccounts] = useState([]); const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [tags, setTags] = useState([]); const [tags, setTags] = useState([]);
const [filter, setFilter] = useState({ account_id: "", type: "" }); // Drei Haken statt eines Typs: Man will oft zwei Arten gleichzeitig sehen
// und eine dritte weglassen, und genau das ging mit einer Einfachauswahl nicht.
const [filter, setFilter] = useState({
account_id: "", arten: { expense: true, income: true, uebertrag: true },
});
const [form, setForm] = useState(null); const [form, setForm] = useState(null);
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -92,7 +96,7 @@ export default function Transactions() {
// Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen, // Verknüpfungen getrennt: Sie sind eine Schicht über den Buchungen,
// kein Feld an ihnen – deshalb auch ein eigener Abruf. // kein Feld an ihnen – deshalb auch ein eigener Abruf.
const [liste, verkn, vorschlaege, aboListe] = await Promise.all([ const [liste, verkn, vorschlaege, aboListe] = await Promise.all([
api.listTransactions({ account_id: filter.account_id, type: filter.type }), api.listTransactions({ account_id: filter.account_id }),
api.transactionLinks().catch(() => ({})), api.transactionLinks().catch(() => ({})),
// Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts // Nur fragen, nichts tun: Beim Anschauen der Liste darf nichts
// verknüpft werden. // verknüpft werden.
@@ -207,7 +211,8 @@ export default function Transactions() {
} }
} }
useEffect(() => { ladenStamm(); }, []); useEffect(() => { ladenStamm(); }, []);
useEffect(() => { ladenBuchungen(); }, [filter.account_id, filter.type]); // Nur das Konto geht an den Server; nach Art wird im Browser gefiltert.
useEffect(() => { ladenBuchungen(); }, [filter.account_id]);
const fromAcc = form && accountById[Number(form.account_id)]; const fromAcc = form && accountById[Number(form.account_id)];
const toAcc = form && accountById[Number(form.to_account_id)]; const toAcc = form && accountById[Number(form.to_account_id)];
@@ -356,8 +361,12 @@ export default function Transactions() {
if (gegenseiteDa) paare[tx.id] = txns.find((x) => x.id === link.other_id); if (gegenseiteDa) paare[tx.id] = txns.find((x) => x.id === link.other_id);
sichtbar.push(tx); sichtbar.push(tx);
} }
return { zeilen: sichtbar, paarZu: paare }; // Eine verknuepfte Buchung ist weder Ausgabe noch Einnahme – sie verschiebt
}, [txns, links]); // nur Geld (siehe transfers.linkedHint). Nach ihrem rohen `type` zu filtern
// zeigte sie unter „Einnahmen", wo sie niemand sucht.
const art = (t) => (t.type === "transfer" || links[String(t.id)] ? "uebertrag" : t.type);
return { zeilen: sichtbar.filter((t) => filter.arten[art(t)]), paarZu: paare };
}, [txns, links, filter.arten]);
function rowLabel(tx) { function rowLabel(tx) {
const gegen = paarZu[tx.id]; const gegen = paarZu[tx.id];
@@ -495,6 +504,16 @@ export default function Transactions() {
); );
}, },
}, },
{
// Die Notiz stand bisher nur im Bearbeiten-Dialog. Wer sie schreibt,
// will sie aber spaeter sehen, ohne jede Zeile aufzuklappen.
key: "notes", header: t("transactions.notes"), width: 180,
filterText: (tx) => tx.notes || "",
render: (tx) => (tx.notes
// Ganzer Text im Tooltip: Die Spalte ist schmal, die Notiz darf lang sein.
? <span className="ellipsis" title={tx.notes}>{tx.notes}</span>
: <span className="muted">—</span>),
},
{ {
// Eigene Spalte, damit die Auswahl in der Zeile steht und nicht unter dem // Eigene Spalte, damit die Auswahl in der Zeile steht und nicht unter dem
// Text – gestapelt wurden die Zeilen hoch und die Spalte ausgefranst. // Text – gestapelt wurden die Zeilen hoch und die Spalte ausgefranst.
@@ -654,12 +673,22 @@ export default function Transactions() {
placeholder={t("transactions.allAccounts")} showCurrency={false} placeholder={t("transactions.allAccounts")} showCurrency={false}
minWidth={190} minWidth={190}
onChange={(id) => setFilter({ ...filter, account_id: id != null ? String(id) : "" })} /> onChange={(id) => setFilter({ ...filter, account_id: id != null ? String(id) : "" })} />
<select value={filter.type} onChange={(e) => setFilter({ ...filter, type: e.target.value })}> <div className="art-filter">
<option value="">{t("transactions.allTypes")}</option> {[["expense", "filterExpenses"], ["income", "filterIncome"],
<option value="expense">{t("transactions.expense")}</option> ["uebertrag", "filterTransfers"]].map(([art, schluessel]) => (
<option value="income">{t("transactions.income")}</option> <label className="checkline" key={art}>
<option value="transfer">{t("transactions.transfer")}</option> <input
</select> type="checkbox"
checked={filter.arten[art]}
onChange={(e) => setFilter({
...filter,
arten: { ...filter.arten, [art]: e.target.checked },
})}
/>
<span>{t(`transactions.${schluessel}`)}</span>
</label>
))}
</div>
</div> </div>
<div className="card"> <div className="card">
@@ -802,7 +831,7 @@ export default function Transactions() {
Freier Text, weil sich das nicht in Schubladen fassen laesst. */} Freier Text, weil sich das nicht in Schubladen fassen laesst. */}
<label> <label>
<span>{t("transactions.notes")}</span> <span>{t("transactions.notes")}</span>
<textarea rows={2} value={form.notes} <textarea rows={1} value={form.notes}
placeholder={t("transactions.notesPlaceholder")} placeholder={t("transactions.notesPlaceholder")}
onChange={(e) => setForm({ ...form, notes: e.target.value })} /> onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</label> </label>
+1 -1
View File
@@ -240,7 +240,7 @@ input:focus, select:focus, textarea:focus {
} }
input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.7; } input::placeholder, textarea::placeholder { color: var(--muted); opacity: 0.7; }
/* Nur in der Hoehe ziehbar: seitwaerts sprengte das Feld den Dialog. */ /* Nur in der Hoehe ziehbar: seitwaerts sprengte das Feld den Dialog. */
textarea { resize: vertical; min-height: 4.5em; line-height: 1.45; } textarea { resize: vertical; }
/* Ankreuzfelder sind keine Textfelder: die Regel oben (width:100%, margin-top) /* Ankreuzfelder sind keine Textfelder: die Regel oben (width:100%, margin-top)
zoege sie sonst ueber die volle Breite, das Haekchen landete in der Mitte und zoege sie sonst ueber die volle Breite, das Haekchen landete in der Mitte und
der Text daneben ganz rechts. Hier zentral ausgenommen, damit das nicht an der Text daneben ganz rechts. Hier zentral ausgenommen, damit das nicht an