Drei Dinge, die zusammengehoeren. 1. Uebertraege sind als solche lesbar. Stand nur eine Seite in der Liste – weil nach einem Konto gefiltert wird oder die andere nicht mitgeladen war – trug die Zeile den Rohtext der Bank als Ueberschrift. "To CHF MB:8f33c0aa-9c92-456b-a6fe-b64a858a019c" sah aus wie eine Zahlung an jemanden; die Verknuepfung stand klein darunter. Jetzt steht oben, zwischen WELCHEN Konten das Geld lief, in beiden Faellen gleich, und daneben eine Marke "Transfer". Ein Uebertrag ist weder Ausgabe noch Einnahme – das gehoert in die erste Zeile, nicht in eine Fussnote. 2. Notiz je Buchung. Das Feld gab es im Modell seit jeher, die Oberflaeche bot es nie an. Die Kategorie sagt, in welche Schublade etwas gehoert – nicht, WAS es war. "Amazon" kann Einrichtung sein, Werkzeug oder ein Geschenk, und in drei Monaten weiss das niemand mehr. Die Notiz steht in der Liste und ist mitdurchsuchbar: Wer sie schreibt, will spaeter danach suchen. 3. "Ignorieren" ist keine Einbahnstrasse mehr. Ein abgelehntes Paar blieb bei jedem weiteren Suchlauf stumm, und nichts verriet, dass es je einen Vorschlag gab. Wer einmal falsch klickte, suchte danach einen Fehler in der Erkennung, den es nicht gab. Die abgelehnten Paare lassen sich jetzt ansehen und zurueckholen. Und wenn die Vorschlaege nicht geladen werden koennen, wird das gesagt statt verschluckt – sonst sieht die Liste aus, als gaebe es nichts zu verbinden. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
168 lines
6.0 KiB
React
168 lines
6.0 KiB
React
import { useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
|
||
import { api } from "../api";
|
||
import Icon from "../components/Icon";
|
||
import Money from "../components/Money";
|
||
import { useToast } from "../toast";
|
||
import { formatDate } from "../format";
|
||
|
||
// Paare, die zusammengehören könnten: ein Abgang auf dem einen Konto, ein
|
||
// Zugang auf dem anderen. Verbinden ändert KEINE Buchung – es setzt nur einen
|
||
// Vermerk, dass die beiden dasselbe Geld sind. Beide bleiben mit Betrag,
|
||
// Kategorie und Import-Schlüssel stehen; sie fallen nur aus der Auswertung.
|
||
// Deshalb ist auch eine falsche Verknüpfung folgenlos: Lösen stellt alles her.
|
||
export default function Uebertragsvorschlaege({ baseCurrency, onChange }) {
|
||
const { t } = useTranslation();
|
||
const toast = useToast();
|
||
|
||
const [stand, setStand] = useState(null); // { verknuepft, vorschlaege }
|
||
const [busy, setBusy] = useState(false);
|
||
// Abgelehnte Vorschlaege. Ohne diese Liste war „Ignorieren" endgueltig:
|
||
// Das Paar blieb bei jedem weiteren Lauf stumm, und nichts verriet, dass es
|
||
// je einen Vorschlag gab – wer einmal falsch klickte, suchte danach einen
|
||
// Fehler in der Erkennung, den es nicht gab.
|
||
const [abgelehnt, setAbgelehnt] = useState([]);
|
||
|
||
async function suchen(melden = false) {
|
||
setBusy(true);
|
||
try {
|
||
const r = await api.searchTransfers();
|
||
setStand(r);
|
||
if (melden) {
|
||
toast(t("transfers.searched", {
|
||
verknuepft: r.verknuepft, offen: r.vorschlaege.length,
|
||
}));
|
||
}
|
||
if (r.verknuepft > 0) onChange?.();
|
||
} catch (e) {
|
||
toast(e.message, "error");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function abgelehnteLaden() {
|
||
try { setAbgelehnt(await api.ignoredTransfers()); } catch { /* still */ }
|
||
}
|
||
|
||
useEffect(() => { suchen(false); abgelehnteLaden(); }, []);
|
||
|
||
async function zurueckholen(paar) {
|
||
try {
|
||
await api.unignoreTransfer(paar.a.id, paar.b.id);
|
||
await abgelehnteLaden();
|
||
suchen(false);
|
||
} catch (e) {
|
||
toast(e.message, "error");
|
||
}
|
||
}
|
||
|
||
async function verbinden(paar) {
|
||
try {
|
||
await api.connectTransfer(paar.a.id, paar.b.id);
|
||
onChange?.();
|
||
suchen(false);
|
||
} catch (e) {
|
||
toast(e.message, "error");
|
||
}
|
||
}
|
||
|
||
async function ignorieren(paar) {
|
||
try {
|
||
await api.ignoreTransfer(paar.a.id, paar.b.id);
|
||
suchen(false);
|
||
abgelehnteLaden();
|
||
} catch (e) {
|
||
toast(e.message, "error");
|
||
}
|
||
}
|
||
|
||
// Vor dem ersten Ergebnis keine leere Karte zeigen.
|
||
if (stand === null) return null;
|
||
if (stand.vorschlaege.length === 0 && abgelehnt.length === 0) return null;
|
||
|
||
return (
|
||
<div className="card">
|
||
<div className="card-head">
|
||
<Icon name="arrow-left-right" size={18} />
|
||
<h2>{t("transfers.title")}</h2>
|
||
<button className="btn sm" disabled={busy} onClick={() => suchen(true)}>
|
||
{t("transfers.search")}
|
||
</button>
|
||
</div>
|
||
<p className="muted small">{t("transfers.intro")}</p>
|
||
|
||
<div className="account-list">
|
||
{stand.vorschlaege.map((paar) => {
|
||
const mitKategorie = paar.a.category_id != null || paar.b.category_id != null;
|
||
return (
|
||
<div className="uebertrag-vorschlag" key={`${paar.a.id}-${paar.b.id}`}>
|
||
<div className="uebertrag-seiten">
|
||
<Seite seite={paar.a} baseCurrency={baseCurrency} />
|
||
<Icon name="arrow-left-right" size={16} className="uebertrag-pfeil" />
|
||
<Seite seite={paar.b} baseCurrency={baseCurrency} />
|
||
</div>
|
||
{/* Eine zugeordnete Buchung faellt durchs Verbinden aus dem
|
||
Budget. Das soll niemanden ueberraschen. */}
|
||
{mitKategorie && (
|
||
<p className="muted small" style={{ margin: "4px 0 0" }}>
|
||
{t("transfers.hasCategory")}
|
||
</p>
|
||
)}
|
||
<div className="btn-pair" style={{ marginTop: "var(--sp-2)" }}>
|
||
<button className="btn sm primary" onClick={() => verbinden(paar)}>
|
||
<Icon name="arrow-left-right" size={14} /> {t("transfers.connect")}
|
||
</button>
|
||
<button className="btn sm ghost" onClick={() => ignorieren(paar)}>
|
||
{t("transfers.ignore")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Der Rueckweg. Eingeklappt, weil man ihn selten braucht – aber da,
|
||
weil ein Klick sonst fuer immer gilt. */}
|
||
{abgelehnt.length > 0 && (
|
||
<details className="abgelehnte">
|
||
<summary className="muted small">
|
||
{t("transfers.ignoredCount", { count: abgelehnt.length })}
|
||
</summary>
|
||
<p className="muted small">{t("transfers.ignoredHint")}</p>
|
||
{abgelehnt.map((paar) => (
|
||
<div className="uebertrag-vorschlag" key={`x${paar.a.id}-${paar.b.id}`}>
|
||
<div className="uebertrag-seiten">
|
||
<Seite seite={paar.a} baseCurrency={baseCurrency} />
|
||
<Icon name="arrow-left-right" size={16} className="uebertrag-pfeil" />
|
||
<Seite seite={paar.b} baseCurrency={baseCurrency} />
|
||
</div>
|
||
<button className="btn sm" style={{ marginTop: "var(--sp-2)" }}
|
||
onClick={() => zurueckholen(paar)}>
|
||
{t("transfers.unignore")}
|
||
</button>
|
||
</div>
|
||
))}
|
||
</details>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Seite({ seite, baseCurrency }) {
|
||
return (
|
||
<div className="uebertrag-seite selectable">
|
||
<div className="named-sub">
|
||
<span className="nowrap">{formatDate(seite.date)}</span>
|
||
{" · "}
|
||
<span className="nowrap">{seite.account_name}</span>
|
||
</div>
|
||
<div><strong>{seite.payee || seite.description || "—"}</strong></div>
|
||
<Money cents={seite.amount_cents} currency={seite.currency}
|
||
sign={seite.type === "expense" ? "neg" : "pos"}
|
||
baseCurrency={baseCurrency} />
|
||
</div>
|
||
);
|
||
}
|