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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-22 19:39:31 +02:00
co-authored by Claude Opus 5
parent 0d7f6a8d79
commit bf656dc957
8 changed files with 405 additions and 5 deletions
+3
View File
@@ -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() {
<Icon name="smartphone" size={15} /> {tfa?.enabled ? t("account.manage2fa") : t("settings.twoFactor")}
</Link>
</div>
<Geraetemarken />
</div>
</div>
);
+118
View File
@@ -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 <div className="card"><div className="empty-state">…</div></div>;
return (
<div className="page">
<div className="page-head">
<h1>{t("inbox.title")}</h1>
<p className="muted">{t("inbox.intro")}</p>
</div>
{rows.length === 0 ? (
<div className="card"><div className="empty-state">{t("inbox.empty")}</div></div>
) : (
<div className="card">
<div className="account-list">
{rows.map((item) => {
const konto = accountById[item.account_id];
const ausgabe = item.amount_cents < 0;
return (
<div className="account-row" key={item.id}>
<IconChip name={konto?.icon || "wallet"} color={konto?.color || "#6b7480"} size={26} />
<div className="named-text grow">
<div><strong>{item.payee || t("inbox.noText")}</strong></div>
<div className="named-sub">
{formatDate(item.date)} · {konto?.name || "—"}
{item.source === "shortcut" && ` · ${t("inbox.viaShortcut")}`}
</div>
</div>
<Money cents={Math.abs(item.amount_cents)} currency={item.currency}
sign={ausgabe ? "neg" : "pos"} baseCurrency={baseCurrency} />
<CategorySelect
groups={ausgabe ? expenseGroups : incomeGroups}
value={item.category_id}
onChange={(id) => zuordnen(item, id)}
/>
<button className="btn sm ghost" title={t("inbox.discard")}
onClick={() => verwerfen(item)}>
<Icon name="trash" size={15} />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
+6 -1
View File
@@ -315,7 +315,12 @@ export default function Import() {
render: (r) => (
<span className="ellipsis" style={{ display: "block" }}>
{r.payee || r.purpose || ""}
{r.is_duplicate && <span className="badge" style={{ marginLeft: 6 }}>{t("import.duplicate")}</span>}
{r.is_duplicate && (
<span className="badge" style={{ marginLeft: 6 }}
title={r.duplicate_reason === "inbox" ? t("import.dupInboxHint") : undefined}>
{r.duplicate_reason === "inbox" ? t("import.dupInbox") : t("import.duplicate")}
</span>
)}
{r.is_counterpart && <span className="badge" style={{ marginLeft: 6 }}>{t("import.counterpart")}</span>}
{r.needs_target && <span className="badge" style={{ marginLeft: 6 }}>{t("import.needsTarget")}</span>}
{r.skip_reason === "state" && (
+184
View File
@@ -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 (
<div className="card">
<div className="card-head">
<Icon name="smartphone" size={18} />
<h2>{t("tokens.title")}</h2>
</div>
<p className="muted small">{t("tokens.intro")}</p>
<form className="row" style={{ gap: "var(--sp-2)", alignItems: "flex-end" }} onSubmit={anlegen}>
<label className="grow">
<span>{t("tokens.name")}</span>
<input value={name} placeholder={t("tokens.namePlaceholder")}
onChange={(e) => setName(e.target.value)} />
</label>
<label>
<span>{t("tokens.defaultAccount")}</span>
<select value={konto} onChange={(e) => setKonto(e.target.value)}>
{konten.map((k) => <option key={k.id} value={k.id}>{k.name}</option>)}
</select>
</label>
<button className="btn primary" disabled={busy || !name.trim()}>
<Icon name="plus" size={15} /> {t("tokens.create")}
</button>
</form>
{frisch && (
<div className="alert" style={{ display: "block", marginTop: "var(--sp-3)" }}>
<strong>{t("tokens.onceOnly")}</strong>
<div className="input-with-button" style={{ marginTop: "var(--sp-2)" }}>
<input readOnly value={frisch.token} className="selectable" />
<button className="btn sm" onClick={() => kopieren(frisch.token)}>
<Icon name="copy" size={14} /> {t("tokens.copy")}
</button>
</div>
<details style={{ marginTop: "var(--sp-3)" }} open>
<summary>{t("tokens.guideTitle")}</summary>
<p className="muted small">{t("tokens.guideNoDownload")}</p>
<ol className="muted small" style={{ paddingLeft: "1.2em" }}>
<li>{t("tokens.step1")}</li>
<li>{t("tokens.step2")}</li>
<li>{t("tokens.step3")}</li>
<li>
{t("tokens.step4")}
<div className="input-with-button" style={{ marginTop: 4 }}>
<input readOnly className="selectable" value={`${basis}/api/quick/booking`} />
<button type="button" className="btn sm"
onClick={() => kopieren(`${basis}/api/quick/booking`)}>
<Icon name="copy" size={14} />
</button>
</div>
<div className="input-with-button" style={{ marginTop: 4 }}>
<input readOnly className="selectable"
value={`X-Certain-Token: ${frisch.token}`} />
<button type="button" className="btn sm"
onClick={() => kopieren(`X-Certain-Token: ${frisch.token}`)}>
<Icon name="copy" size={14} />
</button>
</div>
</li>
<li>{t("tokens.step5")}</li>
</ol>
<p className="muted small">{t("tokens.guideHomeScreen")}</p>
<p className="muted small">{t("tokens.guideAutomation")}</p>
</details>
<button className="btn sm ghost" style={{ marginTop: "var(--sp-2)" }}
onClick={() => setFrisch(null)}>
{t("tokens.hide")}
</button>
</div>
)}
{marken.length > 0 && (
<div className="account-list" style={{ marginTop: "var(--sp-3)" }}>
{marken.map((m) => (
<div className="account-row" key={m.id}>
<IconChip name="smartphone" color="#6b7480" size={26} />
<div className="named-text grow">
<div><strong>{m.name}</strong></div>
<div className="named-sub">
{m.default_account_id ? kontoName(m.default_account_id) : t("tokens.noDefault")}
{" · "}
{m.last_used_at ? t("tokens.lastUsed") : t("tokens.neverUsed")}
</div>
</div>
<button className="btn sm danger" onClick={() => widerrufen(m)}>
<Icon name="trash" size={15} /> {t("tokens.revoke")}
</button>
</div>
))}
</div>
)}
</div>
);
}