fix: Tabelle passt wieder, und Uebertraege gehen auch aus der Buchungsliste
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>
This commit is contained in:
@@ -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 (
|
||||||
|
<Modal
|
||||||
|
title={t("inbox.transferTitle")}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button className="btn" onClick={onClose}>{t("common.cancel")}</button>
|
||||||
|
<button className="btn primary" disabled={!ziel || busy} onClick={buchen}>
|
||||||
|
{t("inbox.asTransfer")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="muted small">{t("inbox.transferIntro")}</p>
|
||||||
|
<div className="account-row" style={{ borderBottom: "none" }}>
|
||||||
|
<div className="named-text grow">
|
||||||
|
<div><strong>{vorgang.text || t("inbox.noText")}</strong></div>
|
||||||
|
<div className="named-sub">
|
||||||
|
{formatDate(vorgang.datum)} · {accountById[vorgang.kontoId]?.name || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Money cents={Math.abs(vorgang.betrag)} currency={vorgang.waehrung}
|
||||||
|
sign={vorgang.betrag < 0 ? "neg" : "pos"} baseCurrency={baseCurrency} />
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
{/* Die Frage hängt an der Richtung – „wohin" bei einem Abgang,
|
||||||
|
„woher" bei einem Zugang. */}
|
||||||
|
<span>
|
||||||
|
{vorgang.betrag < 0
|
||||||
|
? t("inbox.transferTargetOut")
|
||||||
|
: t("inbox.transferTargetIn")}
|
||||||
|
</span>
|
||||||
|
<AccountSelect
|
||||||
|
accounts={accounts.filter((a) => a.id !== vorgang.kontoId)}
|
||||||
|
value={ziel}
|
||||||
|
onChange={(id) => setZiel(id ?? "")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,11 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
import AccountSelect from "../components/AccountSelect";
|
|
||||||
import CategorySelect from "../components/CategorySelect";
|
import CategorySelect from "../components/CategorySelect";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import IconChip from "../components/IconChip";
|
import IconChip from "../components/IconChip";
|
||||||
import Modal from "../components/Modal";
|
|
||||||
import Money from "../components/Money";
|
import Money from "../components/Money";
|
||||||
|
import UebertragDialog from "../components/UebertragDialog";
|
||||||
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
|
import Uebertragsvorschlaege from "../components/Uebertragsvorschlaege";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useConfirm } from "../confirm";
|
import { useConfirm } from "../confirm";
|
||||||
@@ -185,38 +184,9 @@ export default function Eingang() {
|
|||||||
waehrung: accountById[aufgabe.tx.account_id]?.currency,
|
waehrung: accountById[aufgabe.tx.account_id]?.currency,
|
||||||
text: aufgabe.tx.payee || aufgabe.tx.description, datum: aufgabe.tx.date,
|
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) {
|
if (inbox === null) {
|
||||||
return <div className="card"><div className="empty-state">…</div></div>;
|
return <div className="card"><div className="empty-state">…</div></div>;
|
||||||
@@ -277,49 +247,14 @@ export default function Eingang() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{uebertrag && (
|
{uebertrag && (
|
||||||
<Modal
|
<UebertragDialog
|
||||||
title={t("inbox.transferTitle")}
|
vorgang={uebertrag}
|
||||||
|
accounts={accounts}
|
||||||
|
accountById={accountById}
|
||||||
|
baseCurrency={baseCurrency}
|
||||||
onClose={() => setUebertrag(null)}
|
onClose={() => setUebertrag(null)}
|
||||||
footer={
|
onFertig={laden}
|
||||||
<>
|
|
||||||
<button className="btn" onClick={() => setUebertrag(null)}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button className="btn primary" disabled={!uebertrag.ziel}
|
|
||||||
onClick={uebertragBuchen}>
|
|
||||||
{t("inbox.asTransfer")}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p className="muted small">{t("inbox.transferIntro")}</p>
|
|
||||||
<div className="account-row" style={{ borderBottom: "none" }}>
|
|
||||||
<div className="named-text grow">
|
|
||||||
<div><strong>{uebertrag.text || t("inbox.noText")}</strong></div>
|
|
||||||
<div className="named-sub">
|
|
||||||
{formatDate(uebertrag.datum)} ·{" "}
|
|
||||||
{accountById[uebertrag.kontoId]?.name || "—"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Money cents={Math.abs(uebertrag.betrag)} currency={uebertrag.waehrung}
|
|
||||||
sign={uebertrag.betrag < 0 ? "neg" : "pos"}
|
|
||||||
baseCurrency={baseCurrency} />
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
{/* Die Frage hängt an der Richtung – „wohin" bei einem Abgang,
|
|
||||||
„woher" bei einem Zugang. */}
|
|
||||||
<span>
|
|
||||||
{uebertrag.betrag < 0
|
|
||||||
? t("inbox.transferTargetOut")
|
|
||||||
: t("inbox.transferTargetIn")}
|
|
||||||
</span>
|
|
||||||
<AccountSelect
|
|
||||||
accounts={accounts.filter((a) => a.id !== uebertrag.kontoId)}
|
|
||||||
value={uebertrag.ziel}
|
|
||||||
onChange={(id) => setUebertrag((u) => ({ ...u, ziel: id ?? "" }))}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
|
||||||
</Modal>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Money from "../components/Money";
|
|||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
import AccountSelect from "../components/AccountSelect";
|
import AccountSelect from "../components/AccountSelect";
|
||||||
import CategorySelect from "../components/CategorySelect";
|
import CategorySelect from "../components/CategorySelect";
|
||||||
|
import UebertragDialog from "../components/UebertragDialog";
|
||||||
import DataTable from "../components/DataTable";
|
import DataTable from "../components/DataTable";
|
||||||
import { gruppenFuer } from "../kategorien";
|
import { gruppenFuer } from "../kategorien";
|
||||||
import { centsToInput, parseAmountToCents, todayIso } from "../format";
|
import { centsToInput, parseAmountToCents, todayIso } from "../format";
|
||||||
@@ -43,6 +44,8 @@ export default function Transactions() {
|
|||||||
const [txns, setTxns] = useState([]);
|
const [txns, setTxns] = useState([]);
|
||||||
// Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
|
// Je Buchungs-ID die Gegenseite, falls sie Teil eines Übertrags ist.
|
||||||
const [links, setLinks] = useState({});
|
const [links, setLinks] = useState({});
|
||||||
|
// Buchung, die gerade zum Übertrag gemacht wird.
|
||||||
|
const [uebertrag, setUebertrag] = useState(null);
|
||||||
const [accounts, setAccounts] = useState([]);
|
const [accounts, setAccounts] = useState([]);
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [filter, setFilter] = useState({ account_id: "", type: "" });
|
const [filter, setFilter] = useState({ account_id: "", type: "" });
|
||||||
@@ -243,13 +246,13 @@ export default function Transactions() {
|
|||||||
|
|
||||||
const txnColumns = useMemo(() => [
|
const txnColumns = useMemo(() => [
|
||||||
{
|
{
|
||||||
key: "date", header: t("transactions.date"), width: 112,
|
key: "date", header: t("transactions.date"), width: 100,
|
||||||
sortValue: (tx) => tx.date,
|
sortValue: (tx) => tx.date,
|
||||||
render: (tx) => <span className="nowrap">{formatDate(tx.date)}</span>,
|
render: (tx) => <span className="nowrap">{formatDate(tx.date)}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Wofür die Zahlung war – danach sucht man in der Liste.
|
// 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,
|
filterText: (tx) => rowLabel(tx).title,
|
||||||
sortValue: (tx) => rowLabel(tx).title,
|
sortValue: (tx) => rowLabel(tx).title,
|
||||||
render: (tx) => {
|
render: (tx) => {
|
||||||
@@ -277,7 +280,7 @@ export default function Transactions() {
|
|||||||
{
|
{
|
||||||
// Eigene Spalte, damit die Auswahl in der Zeile steht und nicht unter dem
|
// Eigene Spalte, damit die Auswahl in der Zeile steht und nicht unter dem
|
||||||
// Text – gestapelt wurden die Zeilen hoch und die Spalte ausgefranst.
|
// 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 : "") || "",
|
filterText: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "",
|
||||||
sortValue: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "",
|
sortValue: (tx) => (tx.category_id ? categoryById[tx.category_id]?.name : "") || "",
|
||||||
render: (tx) => (
|
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 || "",
|
filterText: (tx) => accountById[tx.account_id]?.name || "",
|
||||||
render: (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,
|
sortValue: (tx) => tx.amount_base_cents,
|
||||||
render: (tx) => {
|
render: (tx) => {
|
||||||
const acc = accountById[tx.account_id];
|
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) => (
|
render: (tx) => (
|
||||||
<div className="nowrap" style={{ textAlign: "right" }}>
|
<div className="nowrap" style={{ textAlign: "right" }}>
|
||||||
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
|
<button className="btn sm ghost" title={t("common.edit")} onClick={() => openEdit(tx)}>
|
||||||
<Icon name="edit" size={14} />
|
<Icon name="edit" size={14} />
|
||||||
</button>
|
</button>
|
||||||
{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)] ? (
|
||||||
<button className="btn sm ghost" title={t("transfers.release")}
|
<button className="btn sm ghost" title={t("transfers.release")}
|
||||||
onClick={() => loesen(tx)}>
|
onClick={() => loesen(tx)}>
|
||||||
<Icon name="arrow-left-right" size={14} />
|
<Icon name="arrow-left-right" size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : tx.type !== "transfer" ? (
|
||||||
|
<button className="btn sm ghost" title={t("inbox.asTransfer")}
|
||||||
|
onClick={() => setUebertrag({
|
||||||
|
art: "buchung", id: tx.id, kontoId: tx.account_id,
|
||||||
|
betrag: tx.type === "income" ? tx.amount_cents : -tx.amount_cents,
|
||||||
|
waehrung: accountById[tx.account_id]?.currency,
|
||||||
|
text: tx.payee || tx.description, datum: tx.date,
|
||||||
|
})}>
|
||||||
|
<Icon name="arrow-left-right" size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
|
<button className="btn sm danger" title={t("common.delete")} onClick={() => loeschen(tx)}>
|
||||||
<Icon name="trash" size={14} />
|
<Icon name="trash" size={14} />
|
||||||
</button>
|
</button>
|
||||||
@@ -373,6 +389,17 @@ export default function Transactions() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{uebertrag && (
|
||||||
|
<UebertragDialog
|
||||||
|
vorgang={uebertrag}
|
||||||
|
accounts={accounts}
|
||||||
|
accountById={accountById}
|
||||||
|
baseCurrency={baseCurrency}
|
||||||
|
onClose={() => setUebertrag(null)}
|
||||||
|
onFertig={ladenBuchungen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{form && (
|
{form && (
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? t("transactions.edit") : t("transactions.new")}
|
title={editing ? t("transactions.edit") : t("transactions.new")}
|
||||||
|
|||||||
Reference in New Issue
Block a user