Ein Screenshot von einer Website ist selten schon das Logo – da ist Rand drumherum oder drei Marken nebeneinander. Nach dem Waehlen kommt deshalb ein Rahmen: ziehen zum Verschieben, Regler zum Zoomen. Der Rahmen IST der Ausschnitt. Deshalb nichts abgedunkelt und keine Hilfslinien darin – man soll das Ergebnis sehen und nicht eine Vorschau davon, die man erst uebersetzen muss. Zu Beginn steht das GANZE Bild im Rahmen. Wer nicht zuschneiden will, drueckt Uebernehmen und bekommt, was er hochgeladen hat; Zuschneiden ist ein Zoom, kein Zwang. Verschieben geht nur, solange das Bild den Rahmen ueberragt – sonst schoebe man ein kleines Bild aus seinem eigenen Ausschnitt heraus. Geschnitten wird im Browser, hochgeladen nur noch der Ausschnitt: Der Server bekommt kein Bild, das er gar nicht behalten soll. Der durchsichtige Grund bleibt durchsichtig – ein weisser Kasten fiele auf dunklem Hintergrund sofort auf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
555 lines
19 KiB
React
555 lines
19 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 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 (
|
||
<div className={"abo-zeile" + beendet}>
|
||
{/* Das Logo schlaegt das Symbol: Netflix erkennt man am roten N,
|
||
nicht am Wort – und erst recht nicht an einem Zahnrad. */}
|
||
{s.has_logo ? (
|
||
<img className="abo-logo" alt=""
|
||
src={api.subscriptionLogoUrl(s.id, s.logo_version)} />
|
||
) : (
|
||
<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>
|
||
<CurrencySelect
|
||
value={form.currency}
|
||
onChange={(w) => setForm({ ...form, currency: w })}
|
||
/>
|
||
</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 style={{ marginBottom: 6 }}>
|
||
<span>{t("abos.logo")}</span>
|
||
</label>
|
||
<div className="abo-logo-feld">
|
||
{editing?.has_logo ? (
|
||
<img className="abo-logo gross" alt=""
|
||
src={api.subscriptionLogoUrl(editing.id, editing.logo_version)} />
|
||
) : (
|
||
<IconChip name={form.icon} color={form.color} size={48} />
|
||
)}
|
||
<div className="abo-logo-knoepfe">
|
||
{/* Ohne gespeicherten Vertrag gibt es noch keine Nummer, an die
|
||
das Bild gehoert. Erst speichern, dann Logo. */}
|
||
<label className={"btn sm" + (editing ? "" : " deaktiviert")}>
|
||
<Icon name="upload" size={14} /> {t("abos.logoUpload")}
|
||
<input type="file" accept="image/*" hidden disabled={!editing}
|
||
onChange={(e) => { logoWaehlen(e.target.files?.[0]); e.target.value = ""; }} />
|
||
</label>
|
||
{editing?.has_logo && (
|
||
<button type="button" className="btn sm ghost" onClick={logoEntfernen}>
|
||
{t("abos.logoRemove")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<p className="muted small">
|
||
{editing ? t("abos.logoHint") : t("abos.logoAfterSave")}
|
||
</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>
|
||
)}
|
||
|
||
{zuschnitt && (
|
||
<LogoZuschnitt
|
||
datei={zuschnitt}
|
||
onAbbrechen={() => setZuschnitt(null)}
|
||
onFertig={logoHochladen}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|