Die neue Kategoriespalte hatte 230 px Mindestbreite hinzugefuegt. Damit war die Tabelle in einem schmalen Fenster breiter als ihre Karte und scrollte waagerecht - von aussen sah das aus, als waere sie abgeschnitten. Alle Spalten sind jetzt enger bemessen; bei 900 px Fensterbreite passt sie ohne Scrollen. Ausserdem liess sich eine Buchung nur aus der Arbeitsliste zum Uebertrag machen, nicht aus der Buchungsliste. Dort steht der Knopf jetzt auch - und zwar statt des Loesen-Knopfes, denn beides zugleich gibt es nie: Eine verknuepfte Buchung IST schon ein Paar. Der Dialog dafuer ist eine eigene Komponente geworden. Die Nachfrage bei einem geschaetzten Betrag - 409 abfangen, begruenden lassen, mit 'trotzdem' erneut senden - ist zu leicht falsch nachgebaut, und jetzt gibt es sie nur einmal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
12 KiB
React
348 lines
12 KiB
React
import { useEffect, useMemo, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
import { api } from "../api";
|
||
import CategorySelect from "../components/CategorySelect";
|
||
import Icon from "../components/Icon";
|
||
import IconChip from "../components/IconChip";
|
||
import Money from "../components/Money";
|
||
import UebertragDialog from "../components/UebertragDialog";
|
||
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
|
||
import { useToast } from "../toast";
|
||
import { useConfirm } from "../confirm";
|
||
import { useSettings } from "../settings";
|
||
import { gruppenFuer } from "../kategorien";
|
||
import { formatDate } from "../format";
|
||
|
||
// Die Arbeitsliste: alles, was noch eine Hand braucht – aus welcher Quelle auch
|
||
// immer. Zwei Arten von Zeilen:
|
||
//
|
||
// Vorschlag – kam per Mail oder Kurzbefehl und ist noch nicht gebucht.
|
||
// Buchung – ist gebucht, hat aber keine Kategorie.
|
||
//
|
||
// Das war früher auf zwei Seiten verteilt („Eingang" und „Offene Buchungen").
|
||
// Seit angebundene Konten selbst buchen, stand jede Bank-Buchung ohne Kategorie
|
||
// in BEIDEN – die Trennung stammte aus der Zeit, als der Eingang nur Vorschläge
|
||
// enthielt.
|
||
//
|
||
// Erledigtes verschwindet aus der Liste. Die Eingangs-Einträge bleiben in der
|
||
// Datenbank: Der spätere CSV-Import gleicht gegen sie ab, damit dieselbe
|
||
// Zahlung nicht ein zweites Mal verbucht wird.
|
||
export default function Eingang() {
|
||
const { t } = useTranslation();
|
||
const { toast } = useToast();
|
||
const confirm = useConfirm();
|
||
const { baseCurrency } = useSettings();
|
||
|
||
const [inbox, setInbox] = useState(null);
|
||
const [buchungen, setBuchungen] = useState([]);
|
||
const [accounts, setAccounts] = useState([]);
|
||
const [groups, setGroups] = useState([]);
|
||
const [merken, setMerken] = useState(true);
|
||
const [uebertrag, setUebertrag] = useState(null);
|
||
|
||
const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
|
||
const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
|
||
const accountById = useMemo(
|
||
() => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
|
||
|
||
async function laden() {
|
||
try {
|
||
const [items, tx, acc, cat] = await Promise.all([
|
||
api.listInbox(),
|
||
api.listTransactions({ uncategorized: true }),
|
||
api.listAccounts(),
|
||
api.listCategories(),
|
||
]);
|
||
setInbox(items);
|
||
setBuchungen(tx);
|
||
setAccounts(acc);
|
||
setGroups(cat);
|
||
} catch (e) {
|
||
toast(e.message, "error");
|
||
}
|
||
}
|
||
useEffect(() => { laden(); }, []);
|
||
|
||
// Woher eine Buchung stammt. Die gebuchten Eingangs-Einträge sagen es für
|
||
// alles, was über Bank, Mail oder Kurzbefehl kam – dafür braucht es keine
|
||
// zweite Abfrage, die Liste ist ohnehin schon da.
|
||
const herkunft = useMemo(() => {
|
||
const nach = {};
|
||
for (const e of inbox || []) {
|
||
if (e.transaction_id != null) nach[e.transaction_id] = e.source;
|
||
}
|
||
return nach;
|
||
}, [inbox]);
|
||
|
||
// Beides in EINER Liste, älteste zuerst – so sieht man den Stapel kleiner
|
||
// werden, statt zwischen zwei Seiten zu wechseln.
|
||
const aufgaben = useMemo(() => {
|
||
const offen = (inbox || [])
|
||
.filter((e) => e.status === "pending" || e.status === "unclear")
|
||
.map((e) => ({ art: "vorschlag", schluessel: `v${e.id}`, datum: e.date, e }));
|
||
const ohneKategorie = buchungen.map((tx) => ({
|
||
art: "buchung", schluessel: `b${tx.id}`, datum: tx.date, tx,
|
||
quelle: herkunft[tx.id] || (tx.import_batch_id != null ? "csv" : "manuell"),
|
||
}));
|
||
return [...offen, ...ohneKategorie]
|
||
.sort((a, b) => a.datum.localeCompare(b.datum) || a.schluessel.localeCompare(b.schluessel));
|
||
}, [inbox, buchungen, herkunft]);
|
||
|
||
const offeneVorschlaege = aufgaben.filter(
|
||
(a) => a.art === "vorschlag" && a.e.account_id);
|
||
|
||
async function zuordnenVorschlag(e, categoryId) {
|
||
if (categoryId == null) return;
|
||
try {
|
||
const neu = await api.setInboxCategory(e.id, categoryId);
|
||
setInbox((r) => r.map((x) => (x.id === e.id ? neu : x)));
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
async function zuordnenBuchung(tx, categoryId) {
|
||
if (categoryId == null) return;
|
||
try {
|
||
await api.updateTransaction(tx.id, { category_id: Number(categoryId) });
|
||
// Aus dem Text ein Schlagwort machen, damit die nächste gleiche Buchung
|
||
// schon beim Import zugeordnet wird. Das ist der Grund, warum der Stapel
|
||
// mit der Zeit kleiner wird.
|
||
const wort = (tx.payee || tx.description || "").trim();
|
||
if (merken && wort) {
|
||
try {
|
||
await api.addKeyword(Number(categoryId), { pattern: wort, match_field: "any" });
|
||
} catch { /* Schlagwort existiert schon oder kollidiert – nicht schlimm */ }
|
||
}
|
||
setBuchungen((r) => r.filter((x) => x.id !== tx.id));
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
async function buchen(e) {
|
||
try {
|
||
await api.bookInboxItem(e.id);
|
||
laden();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
async function alleBuchen() {
|
||
try {
|
||
const r = await api.bookAllInbox();
|
||
toast(t("inbox.bookedN", { n: r.gebucht }));
|
||
laden();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
async function verwerfen(e) {
|
||
if (!(await confirm({
|
||
title: t("inbox.discard"),
|
||
message: t("inbox.discardConfirm"),
|
||
danger: true,
|
||
confirmLabel: t("inbox.discard"),
|
||
}))) return;
|
||
try {
|
||
await api.discardInboxItem(e.id);
|
||
laden();
|
||
} catch (err) {
|
||
toast(err.message, "error");
|
||
}
|
||
}
|
||
|
||
// Der Empfängertext ist oft genau das, was man nachschlagen will („welches
|
||
// Franchise steckt hinter diesem Namen?"). Abtippen fällt damit weg.
|
||
async function kopieren(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast(t("open.copied"));
|
||
} catch {
|
||
toast(t("open.copyFailed"), "error");
|
||
}
|
||
}
|
||
|
||
// --- Übertrag -------------------------------------------------------------
|
||
// Beide Arten führen durch denselben Dialog; nur der Aufruf am Ende
|
||
// unterscheidet sich. Die Frage nach dem Konto hängt an der Richtung.
|
||
|
||
function oeffneUebertrag(aufgabe) {
|
||
const s = aufgabe.art === "vorschlag"
|
||
? {
|
||
art: "vorschlag", id: aufgabe.e.id, kontoId: aufgabe.e.account_id,
|
||
betrag: aufgabe.e.amount_cents, waehrung: aufgabe.e.currency,
|
||
text: aufgabe.e.payee, datum: aufgabe.e.date,
|
||
}
|
||
: {
|
||
art: "buchung", id: aufgabe.tx.id, kontoId: aufgabe.tx.account_id,
|
||
betrag: aufgabe.tx.type === "income"
|
||
? aufgabe.tx.amount_cents : -aufgabe.tx.amount_cents,
|
||
waehrung: accountById[aufgabe.tx.account_id]?.currency,
|
||
text: aufgabe.tx.payee || aufgabe.tx.description, datum: aufgabe.tx.date,
|
||
};
|
||
setUebertrag(s);
|
||
}
|
||
|
||
|
||
if (inbox === null) {
|
||
return <div className="card"><div className="empty-state">…</div></div>;
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-head">
|
||
<h1>{t("todo.title")}</h1>
|
||
<p className="muted">{t("todo.subtitle", { n: aufgaben.length })}</p>
|
||
{offeneVorschlaege.length > 0 && (
|
||
<button className="btn primary" onClick={alleBuchen}>
|
||
<Icon name="check" size={15} /> {t("inbox.bookAll")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Nach dem Buchen entsteht die Gegenseite eines Übertrags erst –
|
||
deshalb steht der Vorschlag hier und nicht in der Buchungsliste. */}
|
||
<Uebertragsvorschlaege baseCurrency={baseCurrency} onChange={laden} />
|
||
|
||
{aufgaben.length === 0 ? (
|
||
<div className="card">
|
||
<div className="empty-state">
|
||
<Icon name="check" size={28} />
|
||
<p>{t("todo.allDone")}</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="card">
|
||
<label className="check-inline" style={{ marginBottom: "var(--sp-3)" }}>
|
||
<input type="checkbox" checked={merken}
|
||
onChange={(e) => setMerken(e.target.checked)} />
|
||
<span>{t("open.rememberKeyword")}</span>
|
||
</label>
|
||
|
||
<div className="account-list">
|
||
{aufgaben.map((aufgabe) => (
|
||
<Zeile
|
||
key={aufgabe.schluessel}
|
||
aufgabe={aufgabe}
|
||
accountById={accountById}
|
||
baseCurrency={baseCurrency}
|
||
expenseGroups={expenseGroups}
|
||
incomeGroups={incomeGroups}
|
||
t={t}
|
||
onKategorie={(id) => (aufgabe.art === "vorschlag"
|
||
? zuordnenVorschlag(aufgabe.e, id)
|
||
: zuordnenBuchung(aufgabe.tx, id))}
|
||
onBuchen={() => buchen(aufgabe.e)}
|
||
onUebertrag={() => oeffneUebertrag(aufgabe)}
|
||
onVerwerfen={() => verwerfen(aufgabe.e)}
|
||
onKopieren={kopieren}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{uebertrag && (
|
||
<UebertragDialog
|
||
vorgang={uebertrag}
|
||
accounts={accounts}
|
||
accountById={accountById}
|
||
baseCurrency={baseCurrency}
|
||
onClose={() => setUebertrag(null)}
|
||
onFertig={laden}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Zeile({
|
||
aufgabe, accountById, baseCurrency, expenseGroups, incomeGroups, t,
|
||
onKategorie, onBuchen, onUebertrag, onVerwerfen, onKopieren,
|
||
}) {
|
||
const vorschlag = aufgabe.art === "vorschlag";
|
||
const e = aufgabe.e;
|
||
const tx = aufgabe.tx;
|
||
|
||
const kontoId = vorschlag ? e.account_id : tx.account_id;
|
||
const konto = accountById[kontoId];
|
||
const betrag = vorschlag ? e.amount_cents : tx.amount_cents;
|
||
const ausgabe = vorschlag ? betrag < 0 : tx.type !== "income";
|
||
const text = vorschlag ? e.payee : (tx.payee || tx.description);
|
||
const uebertragsKonto = vorschlag ? e.transfer_account_id : null;
|
||
|
||
return (
|
||
<div className="account-row">
|
||
<IconChip name={konto?.icon || "wallet"} color={konto?.color || "#6b7480"} size={26} />
|
||
<div className="named-text grow">
|
||
<div className="row" style={{ gap: "var(--sp-2)", alignItems: "center" }}>
|
||
<strong>{text || t("inbox.noText")}</strong>
|
||
{text && (
|
||
<button type="button" className="btn sm ghost" title={t("open.copy")}
|
||
onClick={() => onKopieren(text)}>
|
||
<Icon name="copy" size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="named-sub">
|
||
{/* Jede Angabe für sich unzertrennlich: Auf dem Telefon brach sonst
|
||
„Revolut CHF" um – und genau die Währung im Namen unterscheidet
|
||
die Konten. */}
|
||
<span className="nowrap">{formatDate(aufgabe.datum)}</span>
|
||
{" · "}
|
||
<span className="nowrap">{konto?.name || "—"}</span>
|
||
{" · "}
|
||
<span className="nowrap">
|
||
{t(`todo.from.${vorschlag ? e.source : aufgabe.quelle}`, {
|
||
defaultValue: vorschlag ? e.source : aufgabe.quelle,
|
||
})}
|
||
</span>
|
||
{vorschlag && e.status === "unclear" && ` · ${t("inbox.unclear")}`}
|
||
</div>
|
||
</div>
|
||
|
||
<Money cents={Math.abs(betrag)} currency={vorschlag ? e.currency : konto?.currency}
|
||
sign={ausgabe ? "neg" : "pos"} baseCurrency={baseCurrency} />
|
||
|
||
{/* Ein Übertrag ist keine Ausgabe – er bekommt keine Kategorie. */}
|
||
{uebertragsKonto != null ? (
|
||
<div className="uebertrag-marke">
|
||
<Icon name="arrow-left-right" size={14} />
|
||
<span className="ellipsis">
|
||
{accountById[uebertragsKonto]?.name || t("transfers.linked")}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<CategorySelect
|
||
groups={ausgabe ? expenseGroups : incomeGroups}
|
||
value={vorschlag ? e.category_id : ""}
|
||
onChange={onKategorie}
|
||
placeholder={t("open.choose")}
|
||
/>
|
||
)}
|
||
|
||
{vorschlag && e.account_id && (
|
||
<button className="btn sm primary" onClick={onBuchen}>
|
||
<Icon name="check" size={14} /> {t("inbox.book")}
|
||
</button>
|
||
)}
|
||
{/* Eine Umbuchung ist keine Ausgabe. Ohne diesen Weg bliebe nur, sie als
|
||
solche zu kategorisieren. */}
|
||
{uebertragsKonto == null && kontoId && (
|
||
<button className="btn sm" title={t("inbox.asTransfer")} onClick={onUebertrag}>
|
||
<Icon name="arrow-left-right" size={14} />
|
||
</button>
|
||
)}
|
||
{vorschlag && (
|
||
<button className="btn sm ghost" title={t("inbox.discard")} onClick={onVerwerfen}>
|
||
<Icon name="trash" size={15} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|