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:
@@ -12,6 +12,7 @@ import Categories from "./pages/Categories";
|
|||||||
import Transactions from "./pages/Transactions";
|
import Transactions from "./pages/Transactions";
|
||||||
import Budget from "./pages/Budget";
|
import Budget from "./pages/Budget";
|
||||||
import Import from "./pages/Import";
|
import Import from "./pages/Import";
|
||||||
|
import Eingang from "./pages/Eingang";
|
||||||
import Offen from "./pages/Offen";
|
import Offen from "./pages/Offen";
|
||||||
import Users from "./pages/Users";
|
import Users from "./pages/Users";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
@@ -50,6 +51,7 @@ function Sidebar() {
|
|||||||
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
<NavItem to="/kategorien" icon="list" label={t("nav.categories")} />
|
||||||
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
<NavItem to="/buchungen" icon="arrow-left-right" label={t("nav.transactions")} />
|
||||||
<NavItem to="/offen" icon="inbox" label={t("nav.open")} />
|
<NavItem to="/offen" icon="inbox" label={t("nav.open")} />
|
||||||
|
<NavItem to="/eingang" icon="smartphone" label={t("nav.inbox")} />
|
||||||
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
<NavItem to="/budget" icon="pie-chart" label={t("nav.budget")} />
|
||||||
<NavItem to="/import" icon="download" label={t("nav.import")} />
|
<NavItem to="/import" icon="download" label={t("nav.import")} />
|
||||||
<div className="nav-section">{t("nav.system")}</div>
|
<div className="nav-section">{t("nav.system")}</div>
|
||||||
@@ -137,6 +139,7 @@ export default function App() {
|
|||||||
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
<Route path="/kategorien" element={<Protected><Categories /></Protected>} />
|
||||||
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
<Route path="/buchungen" element={<Protected wide><Transactions /></Protected>} />
|
||||||
<Route path="/offen" element={<Protected><Offen /></Protected>} />
|
<Route path="/offen" element={<Protected><Offen /></Protected>} />
|
||||||
|
<Route path="/eingang" element={<Protected><Eingang /></Protected>} />
|
||||||
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
<Route path="/budget" element={<Protected wide><Budget /></Protected>} />
|
||||||
<Route path="/import" element={<Protected wide><Import /></Protected>} />
|
<Route path="/import" element={<Protected wide><Import /></Protected>} />
|
||||||
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
<Route path="/settings" element={<Protected><Settings /></Protected>} />
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ export const api = {
|
|||||||
deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }),
|
deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }),
|
||||||
setAccountOrder: (ids) => request("/accounts/order", { method: "PUT", body: { ids } }),
|
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)
|
// Kategorien (Gruppen + Kategorien)
|
||||||
listCategories: () => request("/categories"),
|
listCategories: () => request("/categories"),
|
||||||
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
|
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
|
||||||
|
|||||||
+41
-2
@@ -23,7 +23,8 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"open": "Offene Buchungen"
|
"open": "Offene Buchungen",
|
||||||
|
"inbox": "Eingang"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"subtitle": "Bitte anmelden",
|
"subtitle": "Bitte anmelden",
|
||||||
@@ -365,7 +366,9 @@
|
|||||||
"filterTitle": "Diese Datei enthält mehrere Konten",
|
"filterTitle": "Diese Datei enthält mehrere Konten",
|
||||||
"filterValueOption": "{{value}} ({{count}} Zeilen)",
|
"filterValueOption": "{{value}} ({{count}} Zeilen)",
|
||||||
"removeAllUndone": "Rückgängig gemachte entfernen",
|
"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": {
|
"password": {
|
||||||
"show": "Passwort anzeigen",
|
"show": "Passwort anzeigen",
|
||||||
@@ -454,5 +457,41 @@
|
|||||||
"copy": "Text kopieren",
|
"copy": "Text kopieren",
|
||||||
"copied": "Kopiert",
|
"copied": "Kopiert",
|
||||||
"copyFailed": "Kopieren nicht möglich – Text lässt sich jetzt aber markieren."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-2
@@ -23,7 +23,8 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"open": "Open bookings"
|
"open": "Open bookings",
|
||||||
|
"inbox": "Captured"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"subtitle": "Please sign in",
|
"subtitle": "Please sign in",
|
||||||
@@ -365,7 +366,9 @@
|
|||||||
"filterTitle": "This file holds several accounts",
|
"filterTitle": "This file holds several accounts",
|
||||||
"filterValueOption": "{{value}} ({{count}} rows)",
|
"filterValueOption": "{{value}} ({{count}} rows)",
|
||||||
"removeAllUndone": "Remove undone entries",
|
"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": {
|
"password": {
|
||||||
"show": "Show password",
|
"show": "Show password",
|
||||||
@@ -454,5 +457,41 @@
|
|||||||
"copy": "Copy text",
|
"copy": "Copy text",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"copyFailed": "Copying failed – but the text can be selected now."
|
"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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useToast } from "../toast";
|
|||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import PasswordInput from "../components/PasswordInput";
|
import PasswordInput from "../components/PasswordInput";
|
||||||
import PasswordRules from "../components/PasswordRules";
|
import PasswordRules from "../components/PasswordRules";
|
||||||
|
import Geraetemarken from "./settings/Geraetemarken";
|
||||||
import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy";
|
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")}
|
<Icon name="smartphone" size={15} /> {tfa?.enabled ? t("account.manage2fa") : t("settings.twoFactor")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Geraetemarken />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -315,7 +315,12 @@ export default function Import() {
|
|||||||
render: (r) => (
|
render: (r) => (
|
||||||
<span className="ellipsis" style={{ display: "block" }}>
|
<span className="ellipsis" style={{ display: "block" }}>
|
||||||
{r.payee || r.purpose || ""}
|
{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.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.needs_target && <span className="badge" style={{ marginLeft: 6 }}>{t("import.needsTarget")}</span>}
|
||||||
{r.skip_reason === "state" && (
|
{r.skip_reason === "state" && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user