diff --git a/web/src/components/UebertragDialog.jsx b/web/src/components/UebertragDialog.jsx
new file mode 100644
index 0000000..c5b91c9
--- /dev/null
+++ b/web/src/components/UebertragDialog.jsx
@@ -0,0 +1,99 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { api } from "../api";
+import AccountSelect from "./AccountSelect";
+import Modal from "./Modal";
+import Money from "./Money";
+import { useToast } from "../toast";
+import { useConfirm } from "../confirm";
+import { formatDate } from "../format";
+
+// Etwas als Kontoübertrag buchen. Zwei Aufrufer – die Arbeitsliste und die
+// Buchungsliste –, deshalb eine Komponente: Die Nachfrage bei einem
+// geschätzten Betrag ist zu leicht falsch nachgebaut.
+//
+// `vorgang` beschreibt, was umgewidmet wird:
+// { art: "vorschlag" | "buchung", id, kontoId, betrag (vorzeichenbehaftet),
+// waehrung, text, datum }
+export default function UebertragDialog({
+ vorgang, accounts, accountById, baseCurrency, onClose, onFertig,
+}) {
+ const { t } = useTranslation();
+ const { toast } = useToast();
+ const confirm = useConfirm();
+ const [ziel, setZiel] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ async function buchen() {
+ if (!ziel) return;
+ const senden = (trotzdem) => (vorgang.art === "vorschlag"
+ ? api.bookInboxAsTransfer(vorgang.id, Number(ziel), trotzdem)
+ : api.makeTransactionTransfer(vorgang.id, Number(ziel), trotzdem));
+ setBusy(true);
+ try {
+ try {
+ await senden(false);
+ } catch (e) {
+ // 409 heisst nachfragen: Der Betrag der Gegenseite ist geschätzt und
+ // das Zielkonto meldet den Zugang selbst. Die Begründung kommt vom
+ // Server – dort steht das Wissen, nicht hier.
+ if (e.status !== 409) { toast(e.message, "error"); return; }
+ if (!(await confirm({
+ title: t("inbox.transferTitle"),
+ message: e.message,
+ confirmLabel: t("inbox.asTransfer"),
+ }))) return;
+ await senden(true);
+ }
+ toast(t("inbox.transferBooked"));
+ onClose();
+ await onFertig();
+ } catch (e) {
+ toast(e.message, "error");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ >
+ }
+ >
+ {t("inbox.transferIntro")}
+
+
+
{vorgang.text || t("inbox.noText")}
+
+ {formatDate(vorgang.datum)} · {accountById[vorgang.kontoId]?.name || "—"}
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx
index 96f8696..19dbf3c 100644
--- a/web/src/pages/Eingang.jsx
+++ b/web/src/pages/Eingang.jsx
@@ -2,12 +2,11 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api";
-import AccountSelect from "../components/AccountSelect";
import CategorySelect from "../components/CategorySelect";
import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
-import Modal from "../components/Modal";
import Money from "../components/Money";
+import UebertragDialog from "../components/UebertragDialog";
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
import { useToast } from "../toast";
import { useConfirm } from "../confirm";
@@ -185,38 +184,9 @@ export default function Eingang() {
waehrung: accountById[aufgabe.tx.account_id]?.currency,
text: aufgabe.tx.payee || aufgabe.tx.description, datum: aufgabe.tx.date,
};
- setUebertrag({ ...s, ziel: "" });
+ setUebertrag(s);
}
- async function uebertragBuchen() {
- const { art, id, ziel } = uebertrag;
- if (!ziel) return;
- const senden = (trotzdem) => (art === "vorschlag"
- ? api.bookInboxAsTransfer(id, Number(ziel), trotzdem)
- : api.makeTransactionTransfer(id, Number(ziel), trotzdem));
- try {
- await senden(false);
- } catch (e) {
- // 409 heisst nachfragen: Der Betrag der Gegenseite ist geschätzt und das
- // Zielkonto meldet den Zugang selbst. Die Begründung kommt vom Server –
- // dort steht das Wissen, nicht hier.
- if (e.status !== 409) { toast(e.message, "error"); return; }
- if (!(await confirm({
- title: t("inbox.transferTitle"),
- message: e.message,
- confirmLabel: t("inbox.asTransfer"),
- }))) return;
- try {
- await senden(true);
- } catch (zweiter) {
- toast(zweiter.message, "error");
- return;
- }
- }
- setUebertrag(null);
- toast(t("inbox.transferBooked"));
- await laden();
- }
if (inbox === null) {
return
;
@@ -277,49 +247,14 @@ export default function Eingang() {
)}
{uebertrag && (
- setUebertrag(null)}
- footer={
- <>
-
-
- >
- }
- >
- {t("inbox.transferIntro")}
-
-
-
{uebertrag.text || t("inbox.noText")}
-
- {formatDate(uebertrag.datum)} ·{" "}
- {accountById[uebertrag.kontoId]?.name || "—"}
-
-
-
-
-
-
+ onFertig={laden}
+ />
)}
);
diff --git a/web/src/pages/Transactions.jsx b/web/src/pages/Transactions.jsx
index 135fe2b..ae9460e 100644
--- a/web/src/pages/Transactions.jsx
+++ b/web/src/pages/Transactions.jsx
@@ -10,6 +10,7 @@ import Money from "../components/Money";
import Modal from "../components/Modal";
import AccountSelect from "../components/AccountSelect";
import CategorySelect from "../components/CategorySelect";
+import UebertragDialog from "../components/UebertragDialog";
import DataTable from "../components/DataTable";
import { gruppenFuer } from "../kategorien";
import { centsToInput, parseAmountToCents, todayIso } from "../format";
@@ -43,6 +44,8 @@ export default function Transactions() {
const [txns, setTxns] = useState([]);
// Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
const [links, setLinks] = useState({});
+ // Buchung, die gerade zum Übertrag gemacht wird.
+ const [uebertrag, setUebertrag] = useState(null);
const [accounts, setAccounts] = useState([]);
const [groups, setGroups] = useState([]);
const [filter, setFilter] = useState({ account_id: "", type: "" });
@@ -243,13 +246,13 @@ export default function Transactions() {
const txnColumns = useMemo(() => [
{
- key: "date", header: t("transactions.date"), width: 112,
+ key: "date", header: t("transactions.date"), width: 100,
sortValue: (tx) => tx.date,
render: (tx) => {formatDate(tx.date)},
},
{
// Wofür die Zahlung war – danach sucht man in der Liste.
- key: "text", header: t("transactions.subject"), grow: true, min: 220,
+ key: "text", header: t("transactions.subject"), grow: true, min: 150,
filterText: (tx) => rowLabel(tx).title,
sortValue: (tx) => rowLabel(tx).title,
render: (tx) => {
@@ -277,7 +280,7 @@ export default function Transactions() {
{
// Eigene Spalte, damit die Auswahl in der Zeile steht und nicht unter dem
// Text – gestapelt wurden die Zeilen hoch und die Spalte ausgefranst.
- key: "category", header: t("transactions.category"), width: 210,
+ key: "category", header: t("transactions.category"), width: 170,
filterText: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "",
sortValue: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "",
render: (tx) => (
@@ -295,12 +298,12 @@ export default function Transactions() {
),
},
{
- key: "account", header: t("transactions.account"), width: 150,
+ key: "account", header: t("transactions.account"), width: 130,
filterText: (tx) => accountById[tx.account_id]?.name || "",
render: (tx) => accountById[tx.account_id]?.name || "",
},
{
- key: "amount", header: t("transactions.amount"), align: "num", width: 150,
+ key: "amount", header: t("transactions.amount"), align: "num", width: 140,
sortValue: (tx) => tx.amount_base_cents,
render: (tx) => {
const acc = accountById[tx.account_id];
@@ -312,18 +315,31 @@ export default function Transactions() {
},
},
{
- key: "actions", header: "", width: 96, fixed: true,
+ key: "actions", header: "", width: 118, fixed: true,
render: (tx) => (
- {links[String(tx.id)] && (
+ {/* Verknüpft: die beiden Seiten wieder trennen. Sonst, wenn es
+ überhaupt einer werden kann: zum Übertrag machen. Beides zugleich
+ gibt es nie – eine verknüpfte Buchung IST schon ein Paar. */}
+ {links[String(tx.id)] ? (
- )}
+ ) : tx.type !== "transfer" ? (
+
+ ) : null}
@@ -373,6 +389,17 @@ export default function Transactions() {
>
)}
+ {uebertrag && (
+ setUebertrag(null)}
+ onFertig={ladenBuchungen}
+ />
+ )}
+
{form && (