Wer seinen Kreditrahmen festhalten wollte, hatte dafuer nur den
Anfangssaldo. Also standen dort 2.500, und Certain meldete ein Vermoegen,
das es nicht gibt: Das Geld ist nicht da, es darf nur ausgegeben werden.
Jetzt hat der Rahmen ein eigenes Feld – sichtbar nur bei Kreditkarten,
denn an einem Girokonto gaebe es nichts zu begrenzen. Die Kontokarte
zeigt daneben, was noch geht ("noch 1.754,50 von 2.500,00 frei") und
warnt, wenn der Rahmen ueberzogen ist.
Der Saldo selbst bleibt, was er ist: abbezahlt null, jede Ausgabe ins
Minus, die Rechnung holt ihn zurueck. Gerechnet werden musste dafuer
nichts neu – die Saldenrechnung war schon vorzeichenoffen.
Zwei Dinge fielen dabei auf und sind miterledigt:
- Die Summe "Gesamt" auf der Kontenseite zaehlte archivierte Konten mit,
das Backend nicht. Zwei Zahlen fuer dieselbe Sache.
- "Gesamt" wurde ungefaerbt ausgegeben. Solange nur Guthaben
zusammenkamen, fiel das nicht auf; mit einer Kartenschuld stuende ein
negatives Gesamt still und unauffaellig da.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
698 lines
28 KiB
React
698 lines
28 KiB
React
import { useEffect, useMemo, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { api } from "../api";
|
||
import { useSettings } from "../settings";
|
||
import { useConfirm } from "../confirm";
|
||
import { useToast } from "../toast";
|
||
import Icon from "../components/Icon";
|
||
import IconChip from "../components/IconChip";
|
||
import Money from "../components/Money";
|
||
import Modal from "../components/Modal";
|
||
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
|
||
import CategorySelect from "../components/CategorySelect";
|
||
import { gruppenFuer } from "../kategorien";
|
||
import CurrencySelect from "../components/CurrencySelect";
|
||
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
|
||
|
||
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
|
||
const TYPE_ICON = {
|
||
girokonto: "landmark",
|
||
sparkonto: "piggy-bank",
|
||
bargeld: "wallet",
|
||
kreditkarte: "credit-card",
|
||
depot: "trending-up",
|
||
sonstiges: "wallet",
|
||
};
|
||
|
||
function emptyForm() {
|
||
return {
|
||
name: "",
|
||
type: "girokonto",
|
||
currency: "EUR",
|
||
icon: "landmark",
|
||
color: "#3f51b5",
|
||
opening_balance: "",
|
||
opening_date: "",
|
||
credit_limit: "",
|
||
savings_category_id: "",
|
||
identifiers: [],
|
||
};
|
||
}
|
||
|
||
// Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto
|
||
// wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten.
|
||
function TransferKeywords({ accounts }) {
|
||
const { t } = useTranslation();
|
||
const toast = useToast();
|
||
const [words, setWords] = useState([]);
|
||
const [input, setInput] = useState("");
|
||
const [amount, setAmount] = useState("");
|
||
const [target, setTarget] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
// Aufgeschlüsselter Saldo je Konto, sobald man danach fragt.
|
||
const [aufschluss, setAufschluss] = useState({});
|
||
// Was beim nachträglichen Anwenden übersprungen wurde, samt Grund.
|
||
const [uebersprungen, setUebersprungen] = useState([]);
|
||
const confirm = useConfirm();
|
||
const accName = (id) => accounts.find((a) => a.id === id)?.name;
|
||
|
||
// Regeln greifen beim Import. Alles, was vorher hereinkam – oder bevor es die
|
||
// Regel gab –, liegt noch ohne Kategorie da; bei einer Bankanbindung dauernd.
|
||
// Erst zeigen, dann tun: Ein Übertrag verschiebt Geld.
|
||
async function nachtraeglichAnwenden() {
|
||
setBusy(true);
|
||
try {
|
||
const vor = await api.applyRules(true);
|
||
if (!vor.kategorien && !vor.uebertraege) {
|
||
toast(t("categories.applyNothing"));
|
||
setUebersprungen(vor.uebersprungen || []);
|
||
return;
|
||
}
|
||
if (!(await confirm({
|
||
title: t("categories.applyRules"),
|
||
message: t("categories.applyPreview", {
|
||
kategorien: vor.kategorien, uebertraege: vor.uebertraege,
|
||
}),
|
||
confirmLabel: t("categories.applyRules"),
|
||
}))) return;
|
||
const r = await api.applyRules(false);
|
||
toast(t("categories.applyDone", {
|
||
kategorien: r.kategorien, uebertraege: r.uebertraege,
|
||
}));
|
||
setUebersprungen(r.uebersprungen || []);
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
|
||
async function laden() {
|
||
try { setWords(await api.listTransferKeywords()); }
|
||
catch (err) { toast(err.message, "error"); }
|
||
}
|
||
useEffect(() => { laden(); }, []);
|
||
|
||
async function hinzufuegen() {
|
||
const w = input.trim();
|
||
if (!w) return;
|
||
setBusy(true);
|
||
try {
|
||
const body = { word: w };
|
||
const c = parseAmountToCents(amount);
|
||
if (c) body.amount_cents = c;
|
||
if (target) body.target_account_id = Number(target);
|
||
await api.addTransferKeyword(body);
|
||
setInput("");
|
||
setAmount("");
|
||
setTarget("");
|
||
await laden();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
async function entfernen(id) {
|
||
try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); }
|
||
catch (err) { toast(err.message, "error"); }
|
||
}
|
||
|
||
return (
|
||
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
|
||
<h2>{t("transferRules.title")}</h2>
|
||
<p className="muted small">{t("transferRules.hint")}</p>
|
||
<div className="kw-add">
|
||
<input value={input} placeholder={t("transferRules.wordPlaceholder")}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
|
||
<input value={amount} inputMode="decimal" placeholder={t("categories.keywordAmount")}
|
||
style={{ flex: "0 0 110px", minWidth: 0, marginTop: 0 }}
|
||
onChange={(e) => setAmount(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
|
||
<select value={target} onChange={(e) => setTarget(e.target.value)}
|
||
style={{ flex: "0 0 160px", minWidth: 0, marginTop: 0 }}>
|
||
<option value="">{t("transferRules.autoTarget")}</option>
|
||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||
</select>
|
||
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
|
||
<Icon name="plus" size={15} />
|
||
</button>
|
||
</div>
|
||
<div className="mail-actions" style={{ marginTop: "var(--sp-2)" }}>
|
||
<button className="btn sm" disabled={busy} onClick={nachtraeglichAnwenden}>
|
||
{t("categories.applyRules")}
|
||
</button>
|
||
<span className="muted small">{t("categories.applyRulesHint")}</span>
|
||
</div>
|
||
{uebersprungen.length > 0 && (
|
||
<div className="alert warn" style={{ display: "block", marginTop: "var(--sp-2)" }}>
|
||
<strong className="small">{t("categories.applySkipped")}</strong>
|
||
<ul className="muted small" style={{ paddingLeft: "1.2em", margin: 0 }}>
|
||
{uebersprungen.map((m, i) => (
|
||
<li key={i} className="selectable">{m.datum} · {m.text} — {m.grund}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{words.length === 0 ? (
|
||
<div className="muted small">{t("transferRules.empty")}</div>
|
||
) : (
|
||
<div className="kw-chips">
|
||
{words.map((w) => (
|
||
<span className="kw-chip" key={w.id}>
|
||
{w.word}
|
||
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
|
||
<span className="kw-amount">→ {w.target_account_id ? (accName(w.target_account_id) || "?") : t("transferRules.autoTarget")}</span>
|
||
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
|
||
<Icon name="close" size={13} />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function Accounts() {
|
||
const { t } = useTranslation();
|
||
const { baseCurrency } = useSettings();
|
||
const confirm = useConfirm();
|
||
const toast = useToast();
|
||
|
||
const [accounts, setAccounts] = useState([]);
|
||
// Nur fuer die Auswahl des Sparziels. Ausgaben-Gruppen, denn Sparen zaehlt im
|
||
// Budget wie eine Ausgabe.
|
||
const [groups, setGroups] = useState([]);
|
||
const [editing, setEditing] = useState(null); // Account-Objekt oder null
|
||
const [form, setForm] = useState(null); // Formularzustand oder null (geschlossen)
|
||
const [busy, setBusy] = useState(false);
|
||
// Aufgeschlüsselter Saldo je Konto, sobald man danach fragt.
|
||
const [aufschluss, setAufschluss] = useState({});
|
||
|
||
async function aufschluesseln(a) {
|
||
if (aufschluss[a.id]) { setAufschluss((s) => ({ ...s, [a.id]: null })); return; }
|
||
try {
|
||
const daten = await api.balanceBreakdown(a.id);
|
||
setAufschluss((s) => ({ ...s, [a.id]: daten }));
|
||
} catch (e) {
|
||
toast(e.message, "error");
|
||
}
|
||
}
|
||
|
||
async function saldoUebernehmen(a) {
|
||
if (!(await confirm({
|
||
title: t("accounts.adoptBankBalance"),
|
||
message: t(a.bank_balance_vergleichbar
|
||
? "accounts.adoptConfirm" : "accounts.adoptConfirmAvailable", {
|
||
bank: formatMoney(a.bank_balance_cents, a.currency),
|
||
diff: formatMoney(a.balance_cents - a.bank_balance_cents, a.currency),
|
||
}),
|
||
confirmLabel: t("accounts.adoptBankBalance"),
|
||
}))) return;
|
||
try {
|
||
await api.adoptBankBalance(a.id);
|
||
toast(t("accounts.adopted"));
|
||
laden();
|
||
} catch (e) {
|
||
toast(e.message, "error");
|
||
}
|
||
}
|
||
|
||
async function laden() {
|
||
try {
|
||
setAccounts(await api.listAccounts());
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
useEffect(() => { laden(); }, []);
|
||
useEffect(() => {
|
||
api.listCategories().then(setGroups).catch(() => setGroups([]));
|
||
}, []);
|
||
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
|
||
// Eine Kategorie mit "Nicht in Auswertungen einrechnen" kann im Budget
|
||
// nichts zeigen – der Haken an der Kategorie gewinnt. Das gehoert hierhin
|
||
// gesagt und nicht erst gemerkt, wenn die Zahl fehlt.
|
||
const sparzielNeutral = useMemo(() => {
|
||
const id = Number(form?.savings_category_id);
|
||
if (!id) return false;
|
||
for (const g of groups)
|
||
for (const c of g.categories || []) if (c.id === id) return !!c.excluded_from_stats;
|
||
return false;
|
||
}, [groups, form?.savings_category_id]);
|
||
|
||
function openNew() {
|
||
setEditing(null);
|
||
setForm(emptyForm());
|
||
}
|
||
function openEdit(acc) {
|
||
setEditing(acc);
|
||
setForm({
|
||
name: acc.name,
|
||
type: acc.type,
|
||
currency: acc.currency,
|
||
icon: acc.icon,
|
||
color: acc.color,
|
||
opening_balance: centsToInput(acc.opening_balance_cents),
|
||
opening_date: acc.opening_date || "",
|
||
credit_limit: acc.credit_limit_cents != null
|
||
? centsToInput(acc.credit_limit_cents) : "",
|
||
savings_category_id: acc.savings_category_id ? String(acc.savings_category_id) : "",
|
||
identifiers: acc.identifiers.map((i) => ({ kind: i.kind, value: i.value })),
|
||
});
|
||
}
|
||
|
||
function setType(type) {
|
||
// Icon mitziehen, solange der Nutzer das Standard-Icon des alten Typs hatte.
|
||
setForm((f) => ({
|
||
...f,
|
||
type,
|
||
icon: f.icon === TYPE_ICON[f.type] ? TYPE_ICON[type] : f.icon,
|
||
}));
|
||
}
|
||
|
||
async function speichern(e) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
const body = {
|
||
name: form.name.trim(),
|
||
type: form.type,
|
||
currency: form.currency,
|
||
icon: form.icon,
|
||
color: form.color,
|
||
opening_balance_cents: parseAmountToCents(form.opening_balance) ?? 0,
|
||
opening_date: form.opening_date || null,
|
||
// Ausdruecklich null bei jedem anderen Typ: Wer eine Kreditkarte zum
|
||
// Girokonto macht, soll den Rahmen nicht unsichtbar mitschleppen.
|
||
credit_limit_cents: form.type === "kreditkarte"
|
||
? (parseAmountToCents(form.credit_limit) ?? null) : null,
|
||
savings_category_id: form.savings_category_id ? Number(form.savings_category_id) : null,
|
||
identifiers: form.identifiers
|
||
.filter((i) => i.value.trim())
|
||
.map((i) => ({ kind: i.kind, value: i.value.trim() })),
|
||
};
|
||
try {
|
||
if (editing) await api.updateAccount(editing.id, body);
|
||
else await api.createAccount(body);
|
||
setForm(null);
|
||
toast(t("settings.saved"));
|
||
laden();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
// Reihenfolge per Pfeil: die Liste stand sonst in der Anlegereihenfolge.
|
||
// Optimistisch umsortieren, damit die Liste nicht bei jedem Klick springt.
|
||
async function verschiebe(index, richtung) {
|
||
const ziel = index + richtung;
|
||
if (ziel < 0 || ziel >= accounts.length) return;
|
||
const neu = [...accounts];
|
||
[neu[index], neu[ziel]] = [neu[ziel], neu[index]];
|
||
setAccounts(neu);
|
||
try {
|
||
await api.setAccountOrder(neu.map((a) => a.id));
|
||
} catch (e) {
|
||
toast(e.message, "error");
|
||
laden();
|
||
}
|
||
}
|
||
|
||
async function loeschen(acc) {
|
||
if (!(await confirm({ title: t("common.delete"), message: t("accounts.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||
try {
|
||
await api.deleteAccount(acc.id);
|
||
laden();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
// Archivierte bleiben draussen – das Backend rechnet ebenso
|
||
// (balances.net_worth_cents, dashboard.py). Zwei Zahlen fuer dieselbe Sache
|
||
// sind eine Zahl zu viel.
|
||
const total = accounts
|
||
.filter((a) => !a.is_archived)
|
||
.reduce((s, a) => s + a.balance_base_cents, 0);
|
||
|
||
return (
|
||
<div>
|
||
<div className="page-head">
|
||
<h1>{t("accounts.title")}</h1>
|
||
<button className="btn primary" onClick={openNew}>
|
||
<Icon name="plus" size={16} /> {t("accounts.new")}
|
||
</button>
|
||
</div>
|
||
|
||
{accounts.length === 0 ? (
|
||
<div className="card"><div className="empty-state">{t("accounts.empty")}</div></div>
|
||
) : (
|
||
<div className="card">
|
||
<div className="account-total">
|
||
<span className="muted">{t("accounts.total")}</span>
|
||
<strong><Money cents={total} currency={baseCurrency} /></strong>
|
||
</div>
|
||
<div className="account-list">
|
||
{accounts.map((a, i) => (
|
||
<div className={`account-row${aufschluss[a.id] ? " mit-aufschluss" : ""}`}
|
||
key={a.id}>
|
||
<IconChip name={a.icon} color={a.color} />
|
||
<div className="named-text grow">
|
||
<div><strong>{a.name}</strong></div>
|
||
<div className="named-sub">
|
||
{t(`accountTypes.${a.type}`)} · {a.currency}
|
||
{/* „1 Nr." sagte niemandem, was gemeint ist: die Kennungen, an denen
|
||
Certain eine Auszugszeile diesem Konto zuordnet. */}
|
||
{a.identifiers.length > 0
|
||
&& ` · ${t("accounts.identShort", { count: a.identifiers.length })}`}
|
||
</div>
|
||
<Kreditrahmen account={a} t={t} />
|
||
{/* Bei angebundenen Konten der Saldo, den die Bank fuehrt.
|
||
Er ersetzt die eigene Rechnung nicht, er prueft sie: Eine
|
||
Abweichung heisst, dass eine Buchung fehlt oder doppelt
|
||
ist – sonst faellt das erst beim Vergleich von Hand auf. */}
|
||
{a.bank_balance_cents != null && (
|
||
<div className="named-sub">
|
||
{/* Ein VERFÜGBARER Saldo enthält Vorgemerktes, das
|
||
Certain bewusst nicht bucht. Daraus eine Abweichung
|
||
zu machen, wäre eine Falschmeldung. */}
|
||
{!a.bank_balance_vergleichbar ? (
|
||
<span className="muted">
|
||
{t("accounts.bankBalanceAvailable", {
|
||
bank: formatMoney(a.bank_balance_cents, a.currency),
|
||
})}{" "}
|
||
{/* Nicht vergleichbar heisst nicht, dass man nicht
|
||
angleichen dürfte – nur, dass Certain es nicht
|
||
von sich aus als Abweichung meldet. Die
|
||
Entscheidung bleibt beim Nutzer. */}
|
||
{a.bank_balance_cents !== a.balance_cents && (
|
||
<button type="button" className="btn sm"
|
||
onClick={() => saldoUebernehmen(a)}>
|
||
{t("accounts.adoptBankBalance")}
|
||
</button>
|
||
)}
|
||
</span>
|
||
) : a.bank_balance_cents === a.balance_cents ? (
|
||
<span className="saldo-ok">
|
||
{t("accounts.bankBalanceMatches")}
|
||
{a.bank_balance_at ? ` · ${formatDate(a.bank_balance_at)}` : ""}
|
||
</span>
|
||
) : (
|
||
<span className="saldo-abweichung">
|
||
{t("accounts.bankBalanceDiffers", {
|
||
bank: formatMoney(a.bank_balance_cents, a.currency),
|
||
diff: formatMoney(a.balance_cents - a.bank_balance_cents,
|
||
a.currency),
|
||
})}{" "}
|
||
{/* Die Bank kennt den wahren Stand. Uebernehmen legt
|
||
die Differenz in den Anfangsbestand – Buchungen
|
||
werden nicht angefasst. */}
|
||
<button type="button" className="btn sm"
|
||
onClick={() => saldoUebernehmen(a)}>
|
||
{t("accounts.adoptBankBalance")}
|
||
</button>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button type="button" className="btn sm ghost" title={t("accounts.breakdown")}
|
||
onClick={() => aufschluesseln(a)}>
|
||
<Money cents={a.balance_cents} currency={a.currency}
|
||
baseCents={a.balance_base_cents} baseCurrency={baseCurrency} />
|
||
</button>
|
||
{aufschluss[a.id] && (
|
||
<Aufschluesselung daten={aufschluss[a.id]} t={t} />
|
||
)}
|
||
<div className="row-actions">
|
||
<button className="btn sm ghost" title={t("accounts.moveUp")}
|
||
disabled={i === 0} onClick={() => verschiebe(i, -1)}>
|
||
<Icon name="chevron-up" size={15} />
|
||
</button>
|
||
<button className="btn sm ghost" title={t("accounts.moveDown")}
|
||
disabled={i === accounts.length - 1} onClick={() => verschiebe(i, 1)}>
|
||
<Icon name="chevron-down" size={15} />
|
||
</button>
|
||
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(a)}>
|
||
<Icon name="edit" size={15} />
|
||
</button>
|
||
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(a)}>
|
||
<Icon name="trash" size={15} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<TransferKeywords accounts={accounts} />
|
||
|
||
{form && (
|
||
<Modal
|
||
title={editing ? t("accounts.edit") : t("accounts.new")}
|
||
onClose={() => setForm(null)}
|
||
footer={
|
||
<>
|
||
<button className="btn" onClick={() => setForm(null)}>{t("common.cancel")}</button>
|
||
<button className="btn primary" form="account-form" type="submit" disabled={busy}>
|
||
{t("common.save")}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<form id="account-form" onSubmit={speichern}>
|
||
<label>
|
||
<span>{t("accounts.name")}</span>
|
||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||
<input autoFocus value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||
</label>
|
||
<div className="row">
|
||
<label className="grow">
|
||
<span>{t("accounts.type")}</span>
|
||
<select value={form.type} onChange={(e) => setType(e.target.value)}>
|
||
{ACCOUNT_TYPES.map((tp) => (
|
||
<option key={tp} value={tp}>{t(`accountTypes.${tp}`)}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<label style={{ minWidth: 120 }}>
|
||
<span>{t("accounts.currency")}</span>
|
||
<CurrencySelect value={form.currency}
|
||
onChange={(w) => setForm({ ...form, currency: w })} />
|
||
</label>
|
||
</div>
|
||
<div className="row">
|
||
<label className="grow">
|
||
<span>{t("accounts.openingBalance")} ({form.currency})</span>
|
||
<input inputMode="decimal" value={form.opening_balance}
|
||
onChange={(e) => setForm({ ...form, opening_balance: e.target.value })} placeholder="0,00" />
|
||
</label>
|
||
<label style={{ minWidth: 150 }}>
|
||
<span>{t("accounts.openingDate")}</span>
|
||
<input type="date" value={form.opening_date}
|
||
onChange={(e) => setForm({ ...form, opening_date: e.target.value })} />
|
||
</label>
|
||
</div>
|
||
<p className="muted small">{t("accounts.openingDateHint")}</p>
|
||
|
||
{/* Nur bei Kreditkarten: An einem Girokonto gaebe es nichts zu
|
||
begrenzen, und das Feld waere nur ein weiteres Bedienelement. */}
|
||
{form.type === "kreditkarte" && (
|
||
<>
|
||
<label>
|
||
<span>{t("accounts.creditLimit")} ({form.currency})</span>
|
||
<input inputMode="decimal" value={form.credit_limit}
|
||
onChange={(e) => setForm({ ...form, credit_limit: e.target.value })}
|
||
placeholder={t("accounts.creditLimitNone")} />
|
||
</label>
|
||
<p className="muted small">{t("accounts.creditLimitHint")}</p>
|
||
</>
|
||
)}
|
||
|
||
<label>
|
||
<span>{t("accounts.savingsCategory")}</span>
|
||
<CategorySelect
|
||
groups={expenseGroups}
|
||
value={form.savings_category_id}
|
||
onChange={(id) => setForm({ ...form, savings_category_id: id != null ? String(id) : "" })}
|
||
allowNone
|
||
noneLabel={t("accounts.savingsCategoryNone")}
|
||
placeholder={t("accounts.savingsCategoryNone")}
|
||
/>
|
||
</label>
|
||
<p className="muted small">{t("accounts.savingsCategoryHint")}</p>
|
||
{sparzielNeutral && (
|
||
<p className="muted warn small">{t("accounts.savingsCategoryNeutral")}</p>
|
||
)}
|
||
|
||
<label>
|
||
<span>{t("accounts.icon")}</span>
|
||
<IconPicker icons={ACCOUNT_ICONS} value={form.icon} color={form.color}
|
||
onChange={(name) => setForm({ ...form, icon: name })} />
|
||
</label>
|
||
<label>
|
||
<span>{t("accounts.color")}</span>
|
||
<div className="color-row">
|
||
<input type="color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||
<IconChip name={form.icon} color={form.color} />
|
||
</div>
|
||
</label>
|
||
|
||
<label style={{ marginBottom: 6 }}><span>{t("accounts.identifiers")}</span></label>
|
||
{form.identifiers.map((ident, idx) => (
|
||
<div className="row" key={idx} style={{ marginBottom: 6 }}>
|
||
<select style={{ flex: "0 0 150px", width: "auto" }} value={ident.kind}
|
||
onChange={(e) => {
|
||
const next = [...form.identifiers];
|
||
next[idx] = { ...ident, kind: e.target.value };
|
||
setForm({ ...form, identifiers: next });
|
||
}}>
|
||
<option value="iban">IBAN</option>
|
||
<option value="account_no">{t("identifierKinds.account_no")}</option>
|
||
<option value="card_no">{t("identifierKinds.card_no")}</option>
|
||
<option value="text">{t("identifierKinds.text")}</option>
|
||
</select>
|
||
<input className="grow" style={{ minWidth: 0 }} value={ident.value}
|
||
placeholder={ident.kind === "text" ? t("accounts.textIdentPlaceholder") : "DE.. / Nr."}
|
||
onChange={(e) => {
|
||
const next = [...form.identifiers];
|
||
next[idx] = { ...ident, value: e.target.value };
|
||
setForm({ ...form, identifiers: next });
|
||
}} />
|
||
<button type="button" className="btn sm ghost"
|
||
onClick={() => setForm({ ...form, identifiers: form.identifiers.filter((_, i) => i !== idx) })}>
|
||
<Icon name="trash" size={15} />
|
||
</button>
|
||
</div>
|
||
))}
|
||
{form.identifiers.some((i) => i.kind === "text" && i.value.trim().length > 0
|
||
&& i.value.trim().length < 6) && (
|
||
<p className="muted small">{t("accounts.shortIdentWarning")}</p>
|
||
)}
|
||
<button type="button" className="btn sm"
|
||
onClick={() => setForm({ ...form, identifiers: [...form.identifiers, { kind: "iban", value: "" }] })}>
|
||
<Icon name="plus" size={15} /> {t("accounts.addIdentifier")}
|
||
</button>
|
||
</form>
|
||
</Modal>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
// Woraus sich ein Kontostand zusammensetzt. Stimmt er nicht, liegt es fast
|
||
// immer an einer dieser Zahlen oder an einem Übertrag, der zu viel oder zu
|
||
// wenig verschiebt – dann sieht man hier sofort, welcher Teil nicht passt.
|
||
// Was von der Karte noch uebrig ist.
|
||
//
|
||
// Eine Kreditkarte ist kein Konto mit Geld darauf: Abbezahlt steht sie auf
|
||
// null, jede Ausgabe drueckt sie ins Minus. Der Saldo allein sagt deshalb
|
||
// nicht, wie viel noch geht – dafuer braucht es den Rahmen daneben.
|
||
//
|
||
// Gerechnet wird in der KONTOWAEHRUNG. Der umgerechnete Saldo
|
||
// (balance_base_cents) passt nicht zu einem Rahmen, den die Bank in Franken
|
||
// zugesagt hat.
|
||
function Kreditrahmen({ account, t }) {
|
||
const rahmen = account.credit_limit_cents;
|
||
if (account.type !== "kreditkarte" || !rahmen) return null;
|
||
|
||
// Ein positiver Saldo heisst ueberzahlt – dann ist der Rahmen voll da.
|
||
const genutzt = Math.max(0, -account.balance_cents);
|
||
const ueberzogen = genutzt > rahmen;
|
||
const anteil = Math.min(100, Math.round((genutzt / rahmen) * 100));
|
||
|
||
return (
|
||
<div className="named-sub kreditrahmen">
|
||
<div className={`rahmen-balken${ueberzogen ? " ueber" : ""}`}>
|
||
<div className="rahmen-fuellung" style={{ width: `${anteil}%` }} />
|
||
</div>
|
||
<span className={ueberzogen ? "saldo-abweichung" : "muted"}>
|
||
{ueberzogen
|
||
? t("accounts.creditLimitExceeded", {
|
||
over: formatMoney(genutzt - rahmen, account.currency),
|
||
limit: formatMoney(rahmen, account.currency),
|
||
})
|
||
: t("accounts.creditLimitLeft", {
|
||
left: formatMoney(rahmen - genutzt, account.currency),
|
||
limit: formatMoney(rahmen, account.currency),
|
||
})}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Aufschluesselung({ daten, t }) {
|
||
const { teile, uebertraege, bank, waehrung } = daten;
|
||
const zeile = (schluessel, vorzeichen) => {
|
||
const teil = teile[schluessel];
|
||
return (
|
||
<div className="aufschluss-zeile" key={schluessel}>
|
||
<span>
|
||
{t(`accounts.part.${schluessel}`)}
|
||
{teil.anzahl > 0 && <span className="muted"> ({teil.anzahl})</span>}
|
||
</span>
|
||
<span className="nowrap">
|
||
{vorzeichen}{formatMoney(teil.cents, waehrung)}
|
||
</span>
|
||
</div>
|
||
);
|
||
};
|
||
return (
|
||
<div className="aufschluss">
|
||
{zeile("anfangsbestand", "")}
|
||
{/* Der Stichtag ist der haeufigste unsichtbare Grund dafuer, dass die
|
||
Liste etwas anderes zeigt als der Kontostand. */}
|
||
{teile.stichtag && teile.vor_stichtag.anzahl > 0 && (
|
||
<div className="aufschluss-zeile muted">
|
||
<span>
|
||
{t("accounts.part.beforeCutoff", {
|
||
datum: formatDate(teile.stichtag), n: teile.vor_stichtag.anzahl,
|
||
})}
|
||
</span>
|
||
<span className="nowrap">
|
||
{formatMoney(teile.vor_stichtag.cents, waehrung)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{zeile("einnahmen", "+")}
|
||
{zeile("ausgaben", "−")}
|
||
{zeile("uebertraege_raus", "−")}
|
||
{zeile("uebertraege_rein", "+")}
|
||
<div className="aufschluss-zeile aufschluss-summe">
|
||
<span>{t("accounts.part.saldo")}</span>
|
||
<span className="nowrap">{formatMoney(teile.saldo.cents, waehrung)}</span>
|
||
</div>
|
||
{bank.cents != null && (
|
||
<div className="aufschluss-zeile muted">
|
||
<span>{t(bank.vergleichbar ? "accounts.part.bankGebucht"
|
||
: "accounts.part.bankVerfuegbar")}</span>
|
||
<span className="nowrap">{formatMoney(bank.cents, waehrung)}</span>
|
||
</div>
|
||
)}
|
||
{uebertraege.length > 0 && (
|
||
<div className="aufschluss-uebertraege">
|
||
<div className="muted small">{t("accounts.part.legs")}</div>
|
||
{uebertraege.map((u) => (
|
||
<div className="aufschluss-zeile" key={u.id}>
|
||
<span className="ellipsis">
|
||
{formatDate(u.datum)} · {u.von} → {u.nach}
|
||
{u.text ? ` · ${u.text}` : ""}
|
||
</span>
|
||
<span className="nowrap">
|
||
{u.raus ? "−" : "+"}{formatMoney(u.cents, waehrung)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|