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 CurrencySelect from "../components/CurrencySelect"; import LogoZuschnitt from "../components/LogoZuschnitt"; 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); // Die gewaehlte Datei, solange der Ausschnitt noch nicht bestaetigt ist. const [zuschnitt, setZuschnitt] = useState(null); 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); } } // Nicht sofort hochladen: Erst waehlt man den Ausschnitt. Was danach zum // Server geht, ist genau das, was im Rahmen stand. function logoWaehlen(datei) { if (datei && editing) setZuschnitt(datei); } async function logoHochladen(datei) { setZuschnitt(null); if (!editing) return; setBusy(true); try { const aktualisiert = await api.uploadSubscriptionLogo(editing.id, datei); // Auch den offenen Dialog nachziehen, sonst zeigt er weiter das Symbol. setEditing(aktualisiert); laden(); } catch (e) { toast(e.message, "error"); } finally { setBusy(false); } } async function logoEntfernen() { if (!editing) return; try { setEditing(await api.deleteSubscriptionLogo(editing.id)); laden(); } catch (e) { toast(e.message, "error"); } } 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 (
{t("abos.noRate", { waehrungen: summary.ohne_kurs.join(", ") })}
)} {bald.length > 0 && ({t("abos.empty")}
) : ({t("abos.cancelledHint")}