Drei Sachen, die beim Benutzen aufgefallen sind. ANGEBOTENE WAEHRUNGEN. In den Einstellungen steht jetzt, welche Waehrungen in den Auswahlfeldern erscheinen (Vorgabe EUR, CHF, USD). Dass dreissig unterstuetzt werden, ist gut; dreissig Zeilen in einem Feld zu sehen, wenn man drei davon braucht, ist es nicht. Die Einstellung beschraenkt nur die ANZEIGE. Gueltig bleibt alles mit EZB-Kurs, und die gerade gesetzte Waehrung steht IMMER im Feld – sonst stuende ein Konto in Dollar vor einem Feld ohne Dollar und wechselte beim naechsten Speichern stillschweigend die Waehrung. Der Saldo waere danach still falsch. Die Hauptwaehrung laesst sich nicht abwaehlen. ZEICHEN STATT KUERZEL, wo es hilft: "€ EUR", "$ USD", "£ GBP". CHF steht bewusst ohne: "Fr." ist weniger gelaeufig als CHF selbst. Mehrfach belegte Zeichen bleiben ebenfalls draussen – "US$" von "CA$" zu unterscheiden ist Raetselraten. AUF DEM TELEFON. Die Vertragszeile quetschte vier Spalten in 375 Pixel; vom Namen blieb "Sal..." uebrig und von der Kategorie ein Punkt. Sie ist jetzt ein Gitter: Unter 620 Pixel rutscht der Betrag unter den Namen, die Knoepfe bleiben rechts. Der Name bekommt die volle Breite – auf dem Telefon ist er das Einzige, woran man die Zeile erkennt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
633 lines
26 KiB
React
633 lines
26 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: "",
|
||
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 || "",
|
||
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,
|
||
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");
|
||
}
|
||
}
|
||
|
||
const total = accounts.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>{formatMoney(total, 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>
|
||
{/* 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>
|
||
|
||
<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.
|
||
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>
|
||
);
|
||
}
|