feat: Abos & Vertraege – was regelmaessig abgeht, an einer Stelle
Netflix, Handyvertrag, Krankenkasse, Halbtax. Bisher sah man sie nur als einzelne Buchungen im Auszug; die Frage "welche Abos habe ich eigentlich, und was kosten die zusammen im Monat" liess sich nicht beantworten. Genau die steht jetzt oben auf der Seite. Der Bereich heisst "Abos & Vertraege", nicht "Abos": Ein Handyvertrag ist kein Abo und die Versicherung erst recht nicht – niemand soll ueberlegen muessen, wo sie hingehoert. Der Monatswert wird gerechnet, nicht gespeichert (Betrag durch Intervall). Gespeichert veraltete er beim naechsten Preisaufschlag stillschweigend. Bei laengerem Intervall klappt "Ich lege monatlich zurueck auf" auf, mit dem gerechneten Monatsbetrag daneben. Bei monatlicher Abrechnung gibt es nichts anzusparen, also steht es da auch nicht. DAZU: Waehrungen sind offen. Die Rechen-Maschinerie konnte immer schon beliebige Waehrungen – sie rechnet ueber den Euro als Drehpunkt. Beschraenkt haben es zwei Konstanten: eine Erlaubnisliste mit EUR und CHF, und ein Filter, der aus dem EZB-Feed alles ausser CHF wegwarf, obwohl er dreissig Waehrungen liefert. Massgeblich ist jetzt die Liste der EZB. Frei erfundene Waehrungen bleiben draussen, und das ist kein Geiz: Ohne Kurs liesse sich der Betrag nie umrechnen – er stuende in keiner Summe und in keinem Budget, und das faellt erst spaet auf. Ein Vertrag in einer Waehrung ohne Kurs wird deshalb genannt, statt die Summe stillschweigend zu klein anzuzeigen. Erkannt und gebucht wird noch nichts; der Erkennungstext wird nur erfasst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ff621984dc
commit
f99be47587
@@ -12,6 +12,7 @@ import Accounts from "./pages/Accounts";
|
||||
import Categories from "./pages/Categories";
|
||||
import Transactions from "./pages/Transactions";
|
||||
import Budget from "./pages/Budget";
|
||||
import Abos from "./pages/Abos";
|
||||
import Import from "./pages/Import";
|
||||
import BankRueckkehr from "./pages/BankRueckkehr";
|
||||
import Eingang from "./pages/Eingang";
|
||||
@@ -53,6 +54,7 @@ function Sidebar() {
|
||||
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||||
<NavItem to="/eingang" icon="inbox" label={t("nav.todo")} />
|
||||
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
||||
<NavItem to="/abos" icon="refresh-ccw" label={t("nav.abos")} />
|
||||
<NavItem to="/import" icon="download" label={t("nav.import")} />
|
||||
<div className="nav-section">{t("nav.system")}</div>
|
||||
<NavItem to="/settings" icon="settings" label={t("nav.settings")} />
|
||||
@@ -147,6 +149,7 @@ export default function App() {
|
||||
<Route path="/eingang" element={<Protected><Eingang /></Protected>} />
|
||||
<Route path="/bank/rueckkehr" element={<Protected><BankRueckkehr /></Protected>} />
|
||||
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
||||
<Route path="/abos" element={<Protected><Abos /></Protected>} />
|
||||
<Route path="/import" element={<Protected wide><Import /></Protected>} />
|
||||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||||
<Route path="/konto" element={<Protected><Account /></Protected>} />
|
||||
|
||||
@@ -267,6 +267,12 @@ export const api = {
|
||||
request(`/bank/links/${linkId}/account`, { method: "DELETE" }),
|
||||
|
||||
// Kategorien (Gruppen + Kategorien)
|
||||
// Abos & Verträge
|
||||
listSubscriptions: () => request("/subscriptions"),
|
||||
createSubscription: (body) => request("/subscriptions", { method: "POST", body }),
|
||||
updateSubscription: (id, body) => request(`/subscriptions/${id}`, { method: "PATCH", body }),
|
||||
deleteSubscription: (id) => request(`/subscriptions/${id}`, { method: "DELETE" }),
|
||||
|
||||
listCategories: () => request("/categories"),
|
||||
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
|
||||
updateGroup: (id, body) => request(`/categories/groups/${id}`, { method: "PATCH", body }),
|
||||
|
||||
@@ -1625,3 +1625,66 @@ tr.row-dup {
|
||||
background: var(--surface-2);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
/* --- Abos & Verträge ----------------------------------------------------- */
|
||||
|
||||
/* Die Summe ganz oben: Sie ist die Antwort auf die Frage, wegen der man die
|
||||
Seite überhaupt öffnet. */
|
||||
.abo-summe {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-5);
|
||||
}
|
||||
.abo-summe-zahl {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.abo-liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.abo-zeile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.abo-zeile:last-child { border-bottom: none; }
|
||||
/* Gekündigtes bleibt lesbar, tritt aber zurück – es zählt nicht mehr mit. */
|
||||
.abo-beendet { opacity: 0.55; }
|
||||
|
||||
.abo-mitte { flex: 1; min-width: 0; }
|
||||
.abo-kopf {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.abo-unten {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.abo-faellig { color: var(--muted); white-space: nowrap; }
|
||||
.abo-faellig.warn { color: var(--warn); }
|
||||
|
||||
.abo-betrag {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.abo-knoepfe { display: flex; gap: 2px; }
|
||||
|
||||
/* Das Ansparen gehört sichtbar zum Intervall darüber, nicht daneben. */
|
||||
.abo-ansparen {
|
||||
border-left: 2px solid var(--border-strong);
|
||||
padding-left: var(--sp-3);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
+47
-1
@@ -23,7 +23,8 @@
|
||||
"system": "System",
|
||||
"users": "Benutzer",
|
||||
"settings": "Einstellungen",
|
||||
"todo": "Zu erledigen"
|
||||
"todo": "Zu erledigen",
|
||||
"abos": "Abos"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Bitte anmelden",
|
||||
@@ -320,6 +321,51 @@
|
||||
"incomeCategory": "Kategorie",
|
||||
"targetAccount": "Zielkonto"
|
||||
},
|
||||
"abos": {
|
||||
"title": "Abos & Verträge",
|
||||
"new": "Neuer Vertrag",
|
||||
"edit": "Vertrag bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirm": "„{{name}}“ wirklich löschen? Der Vertrag verschwindet samt seiner Angaben. Wenn er nur ausgelaufen ist, setz ihn lieber auf gekündigt – dann bleibt er zum Nachsehen stehen.",
|
||||
"monthlyTotal": "Im Monat",
|
||||
"yearlyTotal": "Im Jahr",
|
||||
"activeCount": "Laufende Verträge",
|
||||
"dueSoon": "Demnächst fällig",
|
||||
"all": "Alle Verträge",
|
||||
"cancelled": "Gekündigt",
|
||||
"cancelledHint": "Zählen nicht mehr in der Summe. Sie bleiben stehen, damit du später nachsehen kannst, was du hattest.",
|
||||
"empty": "Noch keine Verträge. Trag ein, was regelmässig abgeht – Streaming, Handy, Versicherung, Halbtax.",
|
||||
"noRate": "Für {{waehrungen}} liegt kein Kurs vor. Diese Verträge fehlen in der Summe.",
|
||||
"name": "Name",
|
||||
"amount": "Betrag je Abrechnung",
|
||||
"currency": "Währung",
|
||||
"interval": "Abgerechnet",
|
||||
"every": {
|
||||
"1": "monatlich",
|
||||
"2": "alle 2 Monate",
|
||||
"3": "vierteljährlich",
|
||||
"6": "halbjährlich",
|
||||
"12": "jährlich"
|
||||
},
|
||||
"perMonth": "{{betrag}} im Monat",
|
||||
"paidFrom": "Bezahlt über",
|
||||
"accountNone": "Kein Konto gewählt",
|
||||
"category": "Kategorie",
|
||||
"categoryNone": "Keine Kategorie",
|
||||
"nextDue": "Nächste Fälligkeit",
|
||||
"dueIn": "in {{tage}} Tagen",
|
||||
"dueToday": "heute fällig",
|
||||
"overdue": "seit {{tage}} Tagen überfällig",
|
||||
"savingsAccount": "Ich lege monatlich zurück auf",
|
||||
"savingsNone": "Nicht ansparen",
|
||||
"savingsHint": "Bei diesem Intervall wären das {{betrag}} im Monat. Wähl das Konto, auf das du zurücklegst – dann sprengt die Rechnung nicht einen einzelnen Monat.",
|
||||
"savingInto": "spart auf {{name}}",
|
||||
"pattern": "Erkennungstext",
|
||||
"patternHint": "Woran die Abbuchung im Auszug zu erkennen ist. Auf dem Auszug steht selten der Name, unter dem du den Vertrag kennst. Wird noch nicht ausgewertet – kommt mit der automatischen Zuordnung.",
|
||||
"cancelledFlag": "Gekündigt",
|
||||
"cancelledFlagHint": "Zählt dann nicht mehr in der Monatssumme, bleibt aber in der Liste stehen.",
|
||||
"notes": "Notiz"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"modeCsv": "CSV",
|
||||
|
||||
+47
-1
@@ -23,7 +23,8 @@
|
||||
"system": "System",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"todo": "To do"
|
||||
"todo": "To do",
|
||||
"abos": "Subscriptions"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Please sign in",
|
||||
@@ -320,6 +321,51 @@
|
||||
"incomeCategory": "Category",
|
||||
"targetAccount": "Target account"
|
||||
},
|
||||
"abos": {
|
||||
"title": "Subscriptions & contracts",
|
||||
"new": "New contract",
|
||||
"edit": "Edit contract",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Really delete “{{name}}”? The contract and its details are gone. If it merely ended, mark it cancelled instead — then it stays for reference.",
|
||||
"monthlyTotal": "Per month",
|
||||
"yearlyTotal": "Per year",
|
||||
"activeCount": "Active contracts",
|
||||
"dueSoon": "Due soon",
|
||||
"all": "All contracts",
|
||||
"cancelled": "Cancelled",
|
||||
"cancelledHint": "No longer counted in the total. They stay so you can look back at what you had.",
|
||||
"empty": "No contracts yet. Add what goes out regularly — streaming, phone, insurance, travel pass.",
|
||||
"noRate": "No exchange rate for {{waehrungen}}. Those contracts are missing from the total.",
|
||||
"name": "Name",
|
||||
"amount": "Amount per billing",
|
||||
"currency": "Currency",
|
||||
"interval": "Billed",
|
||||
"every": {
|
||||
"1": "monthly",
|
||||
"2": "every 2 months",
|
||||
"3": "quarterly",
|
||||
"6": "twice a year",
|
||||
"12": "yearly"
|
||||
},
|
||||
"perMonth": "{{betrag}} per month",
|
||||
"paidFrom": "Paid from",
|
||||
"accountNone": "No account chosen",
|
||||
"category": "Category",
|
||||
"categoryNone": "No category",
|
||||
"nextDue": "Next due",
|
||||
"dueIn": "in {{tage}} days",
|
||||
"dueToday": "due today",
|
||||
"overdue": "{{tage}} days overdue",
|
||||
"savingsAccount": "I set aside monthly into",
|
||||
"savingsNone": "Do not set aside",
|
||||
"savingsHint": "At this interval that would be {{betrag}} per month. Pick the account you set aside into — then the bill does not blow up a single month.",
|
||||
"savingInto": "saving into {{name}}",
|
||||
"pattern": "Match text",
|
||||
"patternHint": "How the charge appears on the statement — rarely the name you know it by. Not used yet; it arrives with automatic matching.",
|
||||
"cancelledFlag": "Cancelled",
|
||||
"cancelledFlagHint": "Stops counting towards the monthly total but stays in the list.",
|
||||
"notes": "Note"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"modeCsv": "CSV",
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
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 Modal from "../components/Modal";
|
||||
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
|
||||
import CategorySelect from "../components/CategorySelect";
|
||||
import AccountSelect from "../components/AccountSelect";
|
||||
import { gruppenFuer } from "../kategorien";
|
||||
import { WAEHRUNGEN } from "../waehrungen";
|
||||
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
|
||||
|
||||
// Muss zu INTERVALL_MONATE im Server passen.
|
||||
const INTERVALLE = [1, 2, 3, 6, 12];
|
||||
|
||||
// Ab wann eine Fälligkeit als „demnächst" gilt.
|
||||
const BALD_TAGE = 30;
|
||||
|
||||
function leeresFormular() {
|
||||
return {
|
||||
name: "",
|
||||
icon: "refresh-ccw",
|
||||
color: "#6b7480",
|
||||
amount: "",
|
||||
currency: "EUR",
|
||||
interval_months: "1",
|
||||
account_id: "",
|
||||
category_id: "",
|
||||
next_due: "",
|
||||
payee_pattern: "",
|
||||
savings_account_id: "",
|
||||
notes: "",
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Abos() {
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useSettings();
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
const [daten, setDaten] = useState(null); // { subscriptions, summary }
|
||||
const [konten, setKonten] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [form, setForm] = useState(null); // Formularzustand oder null
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function laden() {
|
||||
try {
|
||||
setDaten(await api.listSubscriptions());
|
||||
} catch (e) {
|
||||
toast(e.message, "error");
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
laden();
|
||||
api.listAccounts().then(setKonten).catch(() => setKonten([]));
|
||||
api.listCategories().then(setGroups).catch(() => setGroups([]));
|
||||
}, []);
|
||||
|
||||
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
|
||||
const kontoById = useMemo(
|
||||
() => Object.fromEntries(konten.map((a) => [a.id, a])),
|
||||
[konten],
|
||||
);
|
||||
const katById = useMemo(() => {
|
||||
const m = {};
|
||||
for (const g of groups) for (const c of g.categories) m[c.id] = c;
|
||||
return m;
|
||||
}, [groups]);
|
||||
|
||||
// Ansparen ergibt nur Sinn, wenn nicht ohnehin jeden Monat abgebucht wird.
|
||||
const spartAn = form && Number(form.interval_months) > 1;
|
||||
|
||||
function neu() {
|
||||
setEditing(null);
|
||||
setForm(leeresFormular());
|
||||
}
|
||||
|
||||
function bearbeiten(s) {
|
||||
setEditing(s);
|
||||
setForm({
|
||||
name: s.name,
|
||||
icon: s.icon,
|
||||
color: s.color,
|
||||
amount: centsToInput(s.amount_cents),
|
||||
currency: s.currency,
|
||||
interval_months: String(s.interval_months),
|
||||
account_id: s.account_id ? String(s.account_id) : "",
|
||||
category_id: s.category_id ? String(s.category_id) : "",
|
||||
next_due: s.next_due || "",
|
||||
payee_pattern: s.payee_pattern || "",
|
||||
savings_account_id: s.savings_account_id ? String(s.savings_account_id) : "",
|
||||
notes: s.notes || "",
|
||||
is_active: s.is_active,
|
||||
});
|
||||
}
|
||||
|
||||
async function speichern(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
icon: form.icon,
|
||||
color: form.color,
|
||||
amount_cents: parseAmountToCents(form.amount) ?? 0,
|
||||
currency: form.currency,
|
||||
interval_months: Number(form.interval_months),
|
||||
account_id: form.account_id ? Number(form.account_id) : null,
|
||||
category_id: form.category_id ? Number(form.category_id) : null,
|
||||
next_due: form.next_due || null,
|
||||
payee_pattern: form.payee_pattern.trim() || null,
|
||||
// Bei monatlicher Abrechnung gibt es nichts anzusparen – ein aus einer
|
||||
// früheren Eingabe stehengebliebenes Konto wäre sonst still wirksam.
|
||||
savings_account_id:
|
||||
spartAn && form.savings_account_id ? Number(form.savings_account_id) : null,
|
||||
notes: form.notes.trim() || null,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
try {
|
||||
if (editing) await api.updateSubscription(editing.id, body);
|
||||
else await api.createSubscription(body);
|
||||
setForm(null);
|
||||
toast(t("settings.saved"));
|
||||
laden();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loeschen(s) {
|
||||
const ok = await confirm({
|
||||
title: t("abos.delete"),
|
||||
message: t("abos.deleteConfirm", { name: s.name }),
|
||||
confirmLabel: t("abos.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.deleteSubscription(s.id);
|
||||
laden();
|
||||
} catch (e) {
|
||||
toast(e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
if (!daten) return null;
|
||||
|
||||
const { subscriptions: liste, summary } = daten;
|
||||
const aktiv = liste.filter((s) => s.is_active);
|
||||
const gekuendigt = liste.filter((s) => !s.is_active);
|
||||
const bald = aktiv
|
||||
.filter((s) => s.tage_bis_faellig != null && s.tage_bis_faellig <= BALD_TAGE)
|
||||
.sort((a, b) => a.tage_bis_faellig - b.tage_bis_faellig);
|
||||
|
||||
function faelligText(s) {
|
||||
if (s.tage_bis_faellig == null) return null;
|
||||
if (s.tage_bis_faellig < 0) return t("abos.overdue", { tage: -s.tage_bis_faellig });
|
||||
if (s.tage_bis_faellig === 0) return t("abos.dueToday");
|
||||
return t("abos.dueIn", { tage: s.tage_bis_faellig });
|
||||
}
|
||||
|
||||
function Zeile({ s }) {
|
||||
const konto = s.account_id ? kontoById[s.account_id] : null;
|
||||
const kat = s.category_id ? katById[s.category_id] : null;
|
||||
const pocket = s.savings_account_id ? kontoById[s.savings_account_id] : null;
|
||||
const beendet = s.is_active ? "" : " abo-beendet";
|
||||
const ueberfaellig = s.tage_bis_faellig < 0 ? " warn" : "";
|
||||
return (
|
||||
<div className={"abo-zeile" + beendet}>
|
||||
<IconChip name={s.icon} color={s.color} size={30} />
|
||||
<div className="abo-mitte">
|
||||
<div className="abo-kopf">
|
||||
<strong className="ellipsis">{s.name}</strong>
|
||||
{s.tage_bis_faellig != null && s.is_active && (
|
||||
<span className={"abo-faellig small" + ueberfaellig}>{faelligText(s)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="abo-unten muted small">
|
||||
{kat && <span className="ellipsis">{kat.name}</span>}
|
||||
{konto && <span className="ellipsis">· {konto.name}</span>}
|
||||
{pocket && (
|
||||
<span className="ellipsis">· {t("abos.savingInto", { name: pocket.name })}</span>
|
||||
)}
|
||||
{s.next_due && <span>· {formatDate(s.next_due, locale)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="abo-betrag">
|
||||
<strong>{formatMoney(s.amount_cents, s.currency)}</strong>
|
||||
<span className="muted small">{t("abos.every." + s.interval_months)}</span>
|
||||
{s.interval_months > 1 && (
|
||||
<span className="muted small">
|
||||
{t("abos.perMonth", { betrag: formatMoney(s.monatlich_cents, s.currency) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="abo-knoepfe">
|
||||
<button className="btn-icon" title={t("common.edit")} onClick={() => bearbeiten(s)}>
|
||||
<Icon name="edit" size={16} />
|
||||
</button>
|
||||
<button className="btn-icon" title={t("abos.delete")} onClick={() => loeschen(s)}>
|
||||
<Icon name="trash" size={16} className="icon-danger" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<h1>{t("abos.title")}</h1>
|
||||
<button className="btn primary" onClick={neu}>
|
||||
<Icon name="plus" size={15} /> {t("abos.new")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Die Zahl, wegen der man die Seite öffnet – ganz oben, nicht unten. */}
|
||||
<div className="card abo-summe">
|
||||
<div>
|
||||
<span className="muted small">{t("abos.monthlyTotal")}</span>
|
||||
<div className="abo-summe-zahl">
|
||||
{formatMoney(summary.monatlich_cents, summary.currency)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted small">{t("abos.yearlyTotal")}</span>
|
||||
<div className="abo-summe-zahl">
|
||||
{formatMoney(summary.jaehrlich_cents, summary.currency)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted small">{t("abos.activeCount")}</span>
|
||||
<div className="abo-summe-zahl">{summary.anzahl_aktiv}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eine zu kleine Summe wäre schlimmer als gar keine. */}
|
||||
{summary.ohne_kurs.length > 0 && (
|
||||
<p className="muted warn small">
|
||||
{t("abos.noRate", { waehrungen: summary.ohne_kurs.join(", ") })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{bald.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="calendar" size={18} />
|
||||
<h2>{t("abos.dueSoon")}</h2>
|
||||
</div>
|
||||
<div className="abo-liste">
|
||||
{bald.map((s) => (
|
||||
<Zeile key={s.id} s={s} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="refresh-ccw" size={18} />
|
||||
<h2>{t("abos.all")}</h2>
|
||||
</div>
|
||||
{aktiv.length === 0 ? (
|
||||
<p className="muted">{t("abos.empty")}</p>
|
||||
) : (
|
||||
<div className="abo-liste">
|
||||
{aktiv.map((s) => (
|
||||
<Zeile key={s.id} s={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{gekuendigt.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<Icon name="close" size={18} />
|
||||
<h2>{t("abos.cancelled")}</h2>
|
||||
</div>
|
||||
<p className="muted small">{t("abos.cancelledHint")}</p>
|
||||
<div className="abo-liste">
|
||||
{gekuendigt.map((s) => (
|
||||
<Zeile key={s.id} s={s} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<Modal
|
||||
title={editing ? t("abos.edit") : t("abos.new")}
|
||||
onClose={() => setForm(null)}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn" onClick={() => setForm(null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button type="submit" form="abo-form" className="btn primary" disabled={busy}>
|
||||
{t("common.save")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="abo-form" onSubmit={speichern}>
|
||||
<label>
|
||||
<span>{t("abos.name")}</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={form.name}
|
||||
required
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
<span>{t("abos.amount")}</span>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={form.amount}
|
||||
placeholder="0,00"
|
||||
required
|
||||
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ minWidth: 110 }}>
|
||||
<span>{t("abos.currency")}</span>
|
||||
<select
|
||||
value={form.currency}
|
||||
onChange={(e) => setForm({ ...form, currency: e.target.value })}
|
||||
>
|
||||
{WAEHRUNGEN.map((w) => (
|
||||
<option key={w} value={w}>
|
||||
{w}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>{t("abos.interval")}</span>
|
||||
<select
|
||||
value={form.interval_months}
|
||||
onChange={(e) => setForm({ ...form, interval_months: e.target.value })}
|
||||
>
|
||||
{INTERVALLE.map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{t("abos.every." + n)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Nur bei längerem Intervall: Monatlich abgebucht gibt es nichts
|
||||
anzusparen. */}
|
||||
{spartAn && (
|
||||
<div className="abo-ansparen">
|
||||
<label>
|
||||
<span>{t("abos.savingsAccount")}</span>
|
||||
<AccountSelect
|
||||
accounts={konten}
|
||||
value={form.savings_account_id}
|
||||
onChange={(id) =>
|
||||
setForm({ ...form, savings_account_id: id != null ? String(id) : "" })
|
||||
}
|
||||
allowNone
|
||||
noneLabel={t("abos.savingsNone")}
|
||||
placeholder={t("abos.savingsNone")}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted small">
|
||||
{t("abos.savingsHint", {
|
||||
betrag: formatMoney(
|
||||
Math.round(
|
||||
(parseAmountToCents(form.amount) ?? 0) / Number(form.interval_months),
|
||||
),
|
||||
form.currency,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label>
|
||||
<span>{t("abos.paidFrom")}</span>
|
||||
<AccountSelect
|
||||
accounts={konten}
|
||||
value={form.account_id}
|
||||
onChange={(id) => setForm({ ...form, account_id: id != null ? String(id) : "" })}
|
||||
allowNone
|
||||
noneLabel={t("abos.accountNone")}
|
||||
placeholder={t("abos.accountNone")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{t("abos.category")}</span>
|
||||
<CategorySelect
|
||||
groups={expenseGroups}
|
||||
value={form.category_id}
|
||||
onChange={(id) => setForm({ ...form, category_id: id != null ? String(id) : "" })}
|
||||
allowNone
|
||||
noneLabel={t("abos.categoryNone")}
|
||||
placeholder={t("abos.categoryNone")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{t("abos.nextDue")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={form.next_due}
|
||||
onChange={(e) => setForm({ ...form, next_due: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>{t("abos.pattern")}</span>
|
||||
<input
|
||||
value={form.payee_pattern}
|
||||
placeholder={form.name}
|
||||
onChange={(e) => setForm({ ...form, payee_pattern: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<p className="muted small">{t("abos.patternHint")}</p>
|
||||
|
||||
<label>
|
||||
<span>{t("accounts.icon")}</span>
|
||||
<IconPicker
|
||||
icons={CATEGORY_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 className="checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!form.is_active}
|
||||
onChange={(e) => setForm({ ...form, is_active: !e.target.checked })}
|
||||
/>
|
||||
<span>{t("abos.cancelledFlag")}</span>
|
||||
</label>
|
||||
<p className="muted small">{t("abos.cancelledFlagHint")}</p>
|
||||
|
||||
<label>
|
||||
<span>{t("abos.notes")}</span>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import Modal from "../components/Modal";
|
||||
import IconPicker, { ACCOUNT_ICONS } from "../components/IconPicker";
|
||||
import CategorySelect from "../components/CategorySelect";
|
||||
import { gruppenFuer } from "../kategorien";
|
||||
import { WAEHRUNGEN } from "../waehrungen";
|
||||
import { centsToInput, formatDate, formatMoney, parseAmountToCents } from "../format";
|
||||
|
||||
const ACCOUNT_TYPES = ["girokonto", "sparkonto", "bargeld", "kreditkarte", "depot", "sonstiges"];
|
||||
@@ -470,8 +471,7 @@ export default function Accounts() {
|
||||
<label style={{ minWidth: 120 }}>
|
||||
<span>{t("accounts.currency")}</span>
|
||||
<select value={form.currency} onChange={(e) => setForm({ ...form, currency: e.target.value })}>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="CHF">CHF</option>
|
||||
{WAEHRUNGEN.map((w) => <option key={w} value={w}>{w}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Welche Währungen die Oberfläche anbietet.
|
||||
//
|
||||
// Muss zur Erlaubnisliste des Servers passen (backend/app/waehrungen.py):
|
||||
// genau die Währungen, für die es einen EZB-Referenzkurs gibt. Ohne Kurs
|
||||
// liesse sich ein Betrag nie umrechnen – er stünde in keiner Summe.
|
||||
//
|
||||
// Reihenfolge: das Naheliegende zuerst, der Rest alphabetisch. Wer CHF sucht,
|
||||
// soll nicht an zwanzig Währungen vorbeiscrollen, die er nie braucht.
|
||||
const VORNE = ["EUR", "CHF", "USD", "GBP"];
|
||||
|
||||
const REST = [
|
||||
"AUD", "BGN", "BRL", "CAD", "CNY", "CZK", "DKK", "HKD", "HUF", "IDR",
|
||||
"ILS", "INR", "ISK", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP",
|
||||
"PLN", "RON", "SEK", "SGD", "THB", "TRY", "ZAR",
|
||||
];
|
||||
|
||||
export const WAEHRUNGEN = [...VORNE, ...REST];
|
||||
Reference in New Issue
Block a user