From bf656dc9576322404769351c00fea1559db2536a Mon Sep 17 00:00:00 2001 From: Guido Date: Sat, 22 Aug 2026 19:39:31 +0200 Subject: [PATCH] feat: Oberflaeche fuer die Schnellerfassung - "Mein Konto": Geraetemarken anlegen und widerrufen. Der Klartext ist nur einmal zu sehen, daneben steht eine Bauanleitung fuer den Kurzbefehl mit kopierfertiger Adresse und Kopfzeile. Ein fertiger Download geht nicht: seit iOS 15 muessen Kurzbefehl-Dateien signiert sein, und signieren kann nur ein Mac mit Apple-Konto. - Neue Seite "Eingang": was unterwegs erfasst wurde, mit Kategorie-Auswahl und Verwerfen. - Die Import-Pruefliste unterscheidet jetzt, WARUM eine Zeile uebersprungen wird: "schon erfasst" mit Erklaerung statt nur "Duplikat". Co-Authored-By: Claude Opus 5 --- web/src/App.jsx | 3 + web/src/api.js | 9 ++ web/src/locales/de.json | 43 +++++- web/src/locales/en.json | 43 +++++- web/src/pages/Account.jsx | 3 + web/src/pages/Eingang.jsx | 118 +++++++++++++++ web/src/pages/Import.jsx | 7 +- web/src/pages/settings/Geraetemarken.jsx | 184 +++++++++++++++++++++++ 8 files changed, 405 insertions(+), 5 deletions(-) create mode 100644 web/src/pages/Eingang.jsx create mode 100644 web/src/pages/settings/Geraetemarken.jsx diff --git a/web/src/App.jsx b/web/src/App.jsx index c4743b0..59155ce 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -12,6 +12,7 @@ import Categories from "./pages/Categories"; import Transactions from "./pages/Transactions"; import Budget from "./pages/Budget"; import Import from "./pages/Import"; +import Eingang from "./pages/Eingang"; import Offen from "./pages/Offen"; import Users from "./pages/Users"; import Settings from "./pages/Settings"; @@ -50,6 +51,7 @@ function Sidebar() { +
{t("nav.system")}
@@ -137,6 +139,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index 753cd93..0afe1c3 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -164,6 +164,15 @@ export const api = { deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }), setAccountOrder: (ids) => request("/accounts/order", { method: "PUT", body: { ids } }), + // Schnellerfassung: Gerätemarken und Eingang + listTokens: () => request("/inbox/tokens"), + createToken: (body) => request("/inbox/tokens", { method: "POST", body }), + deleteToken: (id) => request(`/inbox/tokens/${id}`, { method: "DELETE" }), + listInbox: () => request("/inbox"), + discardInboxItem: (id) => request(`/inbox/${id}`, { method: "DELETE" }), + setInboxCategory: (id, categoryId) => + request(`/inbox/${id}/category/${categoryId}`, { method: "POST" }), + // Kategorien (Gruppen + Kategorien) listCategories: () => request("/categories"), createGroup: (body) => request("/categories/groups", { method: "POST", body }), diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 92ad666..9061bba 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -23,7 +23,8 @@ "system": "System", "users": "Benutzer", "settings": "Einstellungen", - "open": "Offene Buchungen" + "open": "Offene Buchungen", + "inbox": "Eingang" }, "login": { "subtitle": "Bitte anmelden", @@ -365,7 +366,9 @@ "filterTitle": "Diese Datei enthält mehrere Konten", "filterValueOption": "{{value}} ({{count}} Zeilen)", "removeAllUndone": "Rückgängig gemachte entfernen", - "removeAllUndoneConfirm": "{{n}} rückgängig gemachte Einträge aus dem Verlauf entfernen? Die Buchungen sind bereits weg, es verschwindet nur der Eintrag." + "removeAllUndoneConfirm": "{{n}} rückgängig gemachte Einträge aus dem Verlauf entfernen? Die Buchungen sind bereits weg, es verschwindet nur der Eintrag.", + "dupInbox": "schon erfasst", + "dupInboxHint": "Diese Zahlung hast du unterwegs per Kurzbefehl erfasst – Betrag gleich, Datum in der Nähe. Sie ist bereits gebucht und wird deshalb übersprungen. Falls das doch zwei verschiedene Zahlungen sind, kannst du die Zeile von Hand anhaken." }, "password": { "show": "Passwort anzeigen", @@ -454,5 +457,41 @@ "copy": "Text kopieren", "copied": "Kopiert", "copyFailed": "Kopieren nicht möglich – Text lässt sich jetzt aber markieren." + }, + "tokens": { + "title": "Schnellerfassung per Kurzbefehl", + "intro": "Eine Gerätemarke erlaubt einem iOS-Kurzbefehl, Zahlungen an Certain zu schicken – damit du im Laden gleich erfassen kannst, wofür du bezahlt hast. Die Marke darf ausschliesslich erfassen: keine Buchungen lesen, nichts ändern, nichts exportieren.", + "name": "Name des Geräts", + "namePlaceholder": "z. B. iPhone", + "defaultAccount": "Standardkonto", + "create": "Marke anlegen", + "onceOnly": "Diese Marke ist nur jetzt zu sehen. Kopiere sie, bevor du weitergehst.", + "copy": "Kopieren", + "copied": "Kopiert", + "copyFailed": "Kopieren nicht möglich – der Text lässt sich aber markieren.", + "hide": "Fertig, ausblenden", + "guideTitle": "So baust du den Kurzbefehl", + "guideNoDownload": "Zum Herunterladen anbieten kann ich ihn leider nicht: Seit iOS 15 müssen Kurzbefehl-Dateien signiert sein, und signieren kann nur ein Mac mit Apple-Konto. Nachbauen dauert zwei Minuten.", + "step1": "„Text erfragen\" – Frage: Betrag, Eingabetyp Zahl", + "step2": "„Text erfragen\" – Frage: Wofür?", + "step3": "„Aus Liste wählen\" für das Konto (oder weglassen, dann gilt das Standardkonto)", + "step4": "„Inhalte von URL abrufen\" – Methode POST, Anfragetext JSON mit den Feldern amount und payee:", + "step5": "„Mitteilung anzeigen\" mit dem Ergebnis – dann siehst du die Bestätigung", + "guideHomeScreen": "Danach über Teilen → „Zum Home-Bildschirm\" ablegen, dann ist es ein Tipp.", + "guideAutomation": "Wenn deine Kurzbefehle die Automation „Transaktion\" anbieten, kannst du denselben Kurzbefehl daran hängen: Schritt 1 und 2 entfallen, Betrag und Händler kommen dann von dort.", + "revoke": "Widerrufen", + "revokeConfirm": "Marke „{{name}}\" widerrufen? Der Kurzbefehl auf dem Gerät funktioniert danach nicht mehr. Bereits erfasste Buchungen bleiben.", + "noDefault": "kein Standardkonto", + "lastUsed": "schon benutzt", + "neverUsed": "noch nie benutzt" + }, + "inbox": { + "title": "Eingang", + "intro": "Zahlungen, die du unterwegs erfasst hast. Sie sind bereits gebucht – hier ordnest du ihnen in Ruhe eine Kategorie zu. Die Einträge bleiben stehen, damit der spätere Kontoauszug sie wiedererkennt und nichts doppelt verbucht wird.", + "empty": "Nichts erfasst. Richte unter „Mein Konto“ eine Gerätemarke ein, dann kannst du Zahlungen per Kurzbefehl vom Telefon aus erfassen.", + "noText": "ohne Text", + "viaShortcut": "per Kurzbefehl", + "discard": "Verwerfen", + "discardConfirm": "Diese Erfassung verwerfen? Die Buchung wird gelöscht. Der Eintrag bleibt als verworfen vermerkt, damit der Kontoauszug die Zahlung später trotzdem erkennt." } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 64c5ec1..36f5244 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -23,7 +23,8 @@ "system": "System", "users": "Users", "settings": "Settings", - "open": "Open bookings" + "open": "Open bookings", + "inbox": "Captured" }, "login": { "subtitle": "Please sign in", @@ -365,7 +366,9 @@ "filterTitle": "This file holds several accounts", "filterValueOption": "{{value}} ({{count}} rows)", "removeAllUndone": "Remove undone entries", - "removeAllUndoneConfirm": "Remove {{n}} undone entries from the history? The bookings are already gone; only the entry disappears." + "removeAllUndoneConfirm": "Remove {{n}} undone entries from the history? The bookings are already gone; only the entry disappears.", + "dupInbox": "already captured", + "dupInboxHint": "You recorded this payment on the go via shortcut – same amount, nearby date. It is already booked, so it is skipped. If these really are two separate payments, tick the row by hand." }, "password": { "show": "Show password", @@ -454,5 +457,41 @@ "copy": "Copy text", "copied": "Copied", "copyFailed": "Copying failed – but the text can be selected now." + }, + "tokens": { + "title": "Quick capture via shortcut", + "intro": "A device token lets an iOS shortcut send payments to Certain, so you can record what a purchase was for while you still remember. The token may only record: it cannot read bookings, change anything, or export.", + "name": "Device name", + "namePlaceholder": "e.g. iPhone", + "defaultAccount": "Default account", + "create": "Create token", + "onceOnly": "This token is shown only now. Copy it before you move on.", + "copy": "Copy", + "copied": "Copied", + "copyFailed": "Copying failed – but the text can be selected.", + "hide": "Done, hide", + "guideTitle": "How to build the shortcut", + "guideNoDownload": "I cannot offer it as a download: since iOS 15 shortcut files must be signed, and only a Mac with an Apple account can sign them. Rebuilding it takes two minutes.", + "step1": "“Ask for Input” – prompt: Amount, input type Number", + "step2": "“Ask for Input” – prompt: What for?", + "step3": "“Choose from List” for the account (or omit it to use the default account)", + "step4": "“Get Contents of URL” – method POST, request body JSON with the fields amount and payee:", + "step5": "“Show Notification” with the result, so you see the confirmation", + "guideHomeScreen": "Then use Share → “Add to Home Screen” so it is a single tap.", + "guideAutomation": "If your Shortcuts app offers the “Transaction” automation, you can attach the same shortcut to it: steps 1 and 2 fall away, amount and merchant come from there instead.", + "revoke": "Revoke", + "revokeConfirm": "Revoke token “{{name}}”? The shortcut on that device will stop working. Bookings already recorded stay.", + "noDefault": "no default account", + "lastUsed": "used before", + "neverUsed": "never used" + }, + "inbox": { + "title": "Captured", + "intro": "Payments you recorded on the go. They are already booked – assign a category here at your leisure. The entries stay so the later statement recognises them and nothing gets booked twice.", + "empty": "Nothing captured. Set up a device token under “My account” to record payments from your phone via shortcut.", + "noText": "no text", + "viaShortcut": "via shortcut", + "discard": "Discard", + "discardConfirm": "Discard this capture? The booking is deleted. The entry stays marked as discarded so the statement still recognises the payment later." } } diff --git a/web/src/pages/Account.jsx b/web/src/pages/Account.jsx index 127aeef..470eb27 100644 --- a/web/src/pages/Account.jsx +++ b/web/src/pages/Account.jsx @@ -7,6 +7,7 @@ import { useToast } from "../toast"; import Icon from "../components/Icon"; import PasswordInput from "../components/PasswordInput"; import PasswordRules from "../components/PasswordRules"; +import Geraetemarken from "./settings/Geraetemarken"; import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy"; /** @@ -98,6 +99,8 @@ export default function Account() { {tfa?.enabled ? t("account.manage2fa") : t("settings.twoFactor")} + + ); diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx new file mode 100644 index 0000000..cfa2314 --- /dev/null +++ b/web/src/pages/Eingang.jsx @@ -0,0 +1,118 @@ +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 { useToast } from "../toast"; +import { useConfirm } from "../confirm"; +import { useSettings } from "../settings"; +import { gruppenFuer } from "../kategorien"; +import { formatDate } from "../format"; + +// Was unterwegs erfasst wurde – per Kurzbefehl vom Telefon. Die Einträge bleiben +// hier stehen, auch nachdem sie gebucht sind: Der spätere Kontoauszug gleicht +// gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal in den Saldo läuft. +export default function Eingang() { + const { t } = useTranslation(); + const { toast } = useToast(); + const confirm = useConfirm(); + const { baseCurrency } = useSettings(); + + const [rows, setRows] = useState(null); + const [accounts, setAccounts] = useState([]); + const [groups, setGroups] = useState([]); + + 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, acc, cat] = await Promise.all([ + api.listInbox(), api.listAccounts(), api.listCategories(), + ]); + setRows(items); + setAccounts(acc); + setGroups(cat); + } catch (e) { + toast(e.message, "error"); + } + } + useEffect(() => { laden(); }, []); + + async function zuordnen(item, categoryId) { + if (categoryId == null) return; + try { + await api.setInboxCategory(item.id, categoryId); + setRows((r) => r.map((x) => (x.id === item.id ? { ...x, category_id: categoryId } : x))); + } catch (e) { + toast(e.message, "error"); + } + } + + async function verwerfen(item) { + if (!(await confirm({ + title: t("inbox.discard"), + message: t("inbox.discardConfirm"), + danger: true, + confirmLabel: t("inbox.discard"), + }))) return; + try { + await api.discardInboxItem(item.id); + laden(); + } catch (e) { + toast(e.message, "error"); + } + } + + if (rows === null) return
…
; + + return ( +
+
+

{t("inbox.title")}

+

{t("inbox.intro")}

+
+ + {rows.length === 0 ? ( +
{t("inbox.empty")}
+ ) : ( +
+
+ {rows.map((item) => { + const konto = accountById[item.account_id]; + const ausgabe = item.amount_cents < 0; + return ( +
+ +
+
{item.payee || t("inbox.noText")}
+
+ {formatDate(item.date)} · {konto?.name || "—"} + {item.source === "shortcut" && ` · ${t("inbox.viaShortcut")}`} +
+
+ + zuordnen(item, id)} + /> + +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/web/src/pages/Import.jsx b/web/src/pages/Import.jsx index c112f88..1d202c4 100644 --- a/web/src/pages/Import.jsx +++ b/web/src/pages/Import.jsx @@ -315,7 +315,12 @@ export default function Import() { render: (r) => ( {r.payee || r.purpose || ""} - {r.is_duplicate && {t("import.duplicate")}} + {r.is_duplicate && ( + + {r.duplicate_reason === "inbox" ? t("import.dupInbox") : t("import.duplicate")} + + )} {r.is_counterpart && {t("import.counterpart")}} {r.needs_target && {t("import.needsTarget")}} {r.skip_reason === "state" && ( diff --git a/web/src/pages/settings/Geraetemarken.jsx b/web/src/pages/settings/Geraetemarken.jsx new file mode 100644 index 0000000..4c6e908 --- /dev/null +++ b/web/src/pages/settings/Geraetemarken.jsx @@ -0,0 +1,184 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { api } from "../../api"; +import { useToast } from "../../toast"; +import { useConfirm } from "../../confirm"; +import Icon from "../../components/Icon"; +import IconChip from "../../components/IconChip"; + +/** + * Gerätemarken für die Schnellerfassung per iOS-Kurzbefehl. + * + * Ein Kurzbefehl kann sich nicht anmelden, er braucht ein Zugangsmerkmal. Die + * Marke darf ausschliesslich erfassen – nicht lesen, nicht ändern. Geht das + * Telefon verloren, ist damit kein Datenabfluss möglich. + * + * Den Kurzbefehl fertig zum Download anzubieten geht nicht: Seit iOS 15 müssen + * solche Dateien signiert sein, und signieren kann nur ein Mac mit Apple-Konto. + * Deshalb steht hier eine Bauanleitung mit kopierfertigen Werten. + */ +export default function Geraetemarken() { + const { t } = useTranslation(); + const toast = useToast(); + const confirm = useConfirm(); + + const [marken, setMarken] = useState([]); + const [konten, setKonten] = useState([]); + const [name, setName] = useState(""); + const [konto, setKonto] = useState(""); + const [frisch, setFrisch] = useState(null); // { token, name } – nur einmal sichtbar + const [busy, setBusy] = useState(false); + + const basis = useMemo(() => window.location.origin, []); + + async function laden() { + try { + const [m, k] = await Promise.all([api.listTokens(), api.listAccounts()]); + setMarken(m); + setKonten(k); + if (!konto && k.length) setKonto(String(k[0].id)); + } catch (e) { + toast(e.message, "error"); + } + } + useEffect(() => { laden(); }, []); + + async function anlegen(e) { + e.preventDefault(); + if (!name.trim()) return; + setBusy(true); + try { + const neu = await api.createToken({ + name: name.trim(), + default_account_id: konto ? Number(konto) : null, + }); + setFrisch(neu); + setName(""); + laden(); + } catch (e) { + toast(e.message, "error"); + } finally { + setBusy(false); + } + } + + async function widerrufen(m) { + if (!(await confirm({ + title: t("tokens.revoke"), + message: t("tokens.revokeConfirm", { name: m.name }), + danger: true, + confirmLabel: t("tokens.revoke"), + }))) return; + try { + await api.deleteToken(m.id); + laden(); + } catch (e) { + toast(e.message, "error"); + } + } + + async function kopieren(text) { + try { + await navigator.clipboard.writeText(text); + toast(t("tokens.copied")); + } catch { + toast(t("tokens.copyFailed"), "error"); + } + } + + const kontoName = (id) => konten.find((k) => k.id === id)?.name; + + return ( +
+
+ +

{t("tokens.title")}

+
+

{t("tokens.intro")}

+ +
+ + + +
+ + {frisch && ( +
+ {t("tokens.onceOnly")} +
+ + +
+
+ {t("tokens.guideTitle")} +

{t("tokens.guideNoDownload")}

+
    +
  1. {t("tokens.step1")}
  2. +
  3. {t("tokens.step2")}
  4. +
  5. {t("tokens.step3")}
  6. +
  7. + {t("tokens.step4")} +
    + + +
    +
    + + +
    +
  8. +
  9. {t("tokens.step5")}
  10. +
+

{t("tokens.guideHomeScreen")}

+

{t("tokens.guideAutomation")}

+
+ +
+ )} + + {marken.length > 0 && ( +
+ {marken.map((m) => ( +
+ +
+
{m.name}
+
+ {m.default_account_id ? kontoName(m.default_account_id) : t("tokens.noDefault")} + {" · "} + {m.last_used_at ? t("tokens.lastUsed") : t("tokens.neverUsed")} +
+
+ +
+ ))} +
+ )} +
+ ); +}