feat: Regeln (Betrag/Transfer/Konto-Texte) + Münz-Icon + pastellgrünes Theme

Regeln:
- Schlagwörter mit optionaler Betragsbedingung (z. B. „SV = 5.00 → Restaurant");
  Betrags-Regeln gewinnen vor betragslosen
- Transfer-Schlagwörter (Text → Transfer) auf der Konten-Seite
- Konto-Identifikator-Art „Text": erkennt das Zielkonto eines Transfers aus dem
  Buchungstext (längster passender Text gewinnt)
- Migration 0007, Endpunkte, Tests (Betrag/Transfer/Konflikt), gesamt 30 grün

Optik:
- App-Icon: Münzhaufen (aus Nutzer-SVG) weiß auf grünem Grund, klein gehalten
- Farbschema auf pastelliges Grün (Akzent hell/dunkel + Favicon)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-19 22:41:38 +02:00
co-authored by Claude Opus 4.8
parent 30bc037579
commit 204a24d0df
16 changed files with 401 additions and 114 deletions
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 11 KiB

+6 -3
View File
@@ -141,13 +141,16 @@ export const api = {
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
// Schlagwörter je Kategorie (manuelle Auto-Zuordnung) + Prüfmeldungen
// Schlagwörter je Kategorie (manuelle Auto-Zuordnung, optional mit Betrag) + Prüfmeldungen
listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`),
addKeyword: (categoryId, word) => request(`/categories/${categoryId}/keywords`, { method: "POST", body: { word } }),
updateKeyword: (ruleId, word) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body: { word } }),
addKeyword: (categoryId, body) => request(`/categories/${categoryId}/keywords`, { method: "POST", body }),
updateKeyword: (ruleId, body) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body }),
deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }),
flaggedKeywords: () => request("/categories/keywords/flagged"),
confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }),
// Transfer-Schlagwörter (Text -> Transfer, Zielkonto über Konto-Texte)
listTransferKeywords: () => request("/categories/transfer-keywords"),
addTransferKeyword: (body) => request("/categories/transfer-keywords", { method: "POST", body }),
// Transaktionen
listTransactions: (params = {}) => {
+7 -23
View File
@@ -1,33 +1,17 @@
import { APP_NAME } from "../brand";
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist ein Inline-SVG
// (gestapelte Münzen) im Akzentton – bewusst kein Emoji.
// Wort-Bild-Marke für die Seitenleiste. Das Zeichen ist der Münzhaufen aus
// public/favicon.svg (identisch zum Browser-Tab-Icon) – bewusst kein Emoji.
export default function BrandMark({ showName = true, size = 26 }) {
return (
<div className="brand-mark">
<svg
<img
className="brand-logo"
src="/favicon.svg"
width={size}
height={size}
viewBox="0 0 32 32"
role="img"
aria-label={APP_NAME}
focusable="false"
>
<rect x="1" y="1" width="30" height="30" rx="8" fill="var(--accent)" />
<g
transform="translate(2.2 2.2) scale(1.15)"
fill="none"
stroke="#ffffff"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="8" r="6" />
<path d="M18.09 10.37A6 6 0 1 1 10.34 18" />
<path d="M7 6h1v4" />
<path d="m16.71 13.88.7.71-2.82 2.82" />
</g>
</svg>
alt={APP_NAME}
/>
{showName && <span className="brand-name">{APP_NAME}</span>}
</div>
);
+9
View File
@@ -6,6 +6,10 @@
align-items: center;
gap: var(--sp-2);
}
.brand-logo {
display: block;
flex: 0 0 auto;
}
.brand-name {
font-weight: 650;
font-size: 1rem;
@@ -739,6 +743,11 @@ tr.row-dup {
border: 1px solid var(--border);
font-size: 0.85rem;
}
.kw-amount {
color: var(--muted);
font-variant-numeric: tabular-nums;
font-size: 0.8em;
}
.kw-chip button {
display: inline-flex;
align-items: center;
+10 -1
View File
@@ -128,6 +128,7 @@
"openingDate": "Stichtag",
"identifiers": "Erkennungs-Nummern (IBAN etc.)",
"addIdentifier": "Nummer hinzufügen",
"textIdentPlaceholder": "z. B. Sparkonto, Tagesgeld",
"identShort": "Nr.",
"total": "Gesamt",
"empty": "Noch keine Konten. Lege dein erstes Konto an.",
@@ -144,7 +145,14 @@
"identifierKinds": {
"iban": "IBAN",
"account_no": "Kontonummer",
"card_no": "Kartennummer"
"card_no": "Kartennummer",
"text": "Text (Erkennung)"
},
"transferRules": {
"title": "Transfer-Erkennung",
"hint": "Buchungen, deren Text eines dieser Wörter enthält, werden als Transfer erkannt. Das Zielkonto wird aus dem Buchungstext über die Erkennungs-Texte je Konto (Konto bearbeiten, Art Text) erraten.",
"wordPlaceholder": "Wort (z. B. Übertrag)",
"empty": "Noch keine Transfer-Schlagwörter."
},
"categories": {
"title": "Kategorien",
@@ -163,6 +171,7 @@
"keywordsTitle": "Schlagwörter – {{name}}",
"keywordsHint": "Buchungen, die eines dieser Wörter im Empfänger oder Verwendungszweck enthalten, werden bei künftigen Importen automatisch dieser Kategorie zugeordnet.",
"keywordAdd": "Wort eingeben und Enter",
"keywordAmount": "Betrag (optional)",
"keywordEmpty": "Noch keine Schlagwörter.",
"keywordEditHint": "Doppelklick auf ein Schlagwort, um es zu bearbeiten.",
"searchPlaceholder": "Kategorie suchen …"
+10 -1
View File
@@ -128,6 +128,7 @@
"openingDate": "As of",
"identifiers": "Identifiers (IBAN etc.)",
"addIdentifier": "Add identifier",
"textIdentPlaceholder": "e.g. Savings, Deposit",
"identShort": "id(s)",
"total": "Total",
"empty": "No accounts yet. Create your first account.",
@@ -144,7 +145,14 @@
"identifierKinds": {
"iban": "IBAN",
"account_no": "Account number",
"card_no": "Card number"
"card_no": "Card number",
"text": "Text (detection)"
},
"transferRules": {
"title": "Transfer detection",
"hint": "Transactions whose text contains one of these words are recognized as a transfer. The target account is guessed from the booking text via each account's detection texts (edit account, kind Text).",
"wordPlaceholder": "Word (e.g. Transfer)",
"empty": "No transfer keywords yet."
},
"categories": {
"title": "Categories",
@@ -163,6 +171,7 @@
"keywordsTitle": "Keywords – {{name}}",
"keywordsHint": "Transactions whose payee or description contains one of these words are automatically assigned to this category on future imports.",
"keywordAdd": "Type a word and press Enter",
"keywordAmount": "Amount (optional)",
"keywordEmpty": "No keywords yet.",
"keywordEditHint": "Double-click a keyword to edit it.",
"searchPlaceholder": "Search category …"
+78 -1
View File
@@ -34,6 +34,80 @@ function emptyForm() {
};
}
// Globale Transfer-Schlagwörter: Text im Buchungstext -> Transfer. Das Zielkonto
// wird über die Erkennungs-Texte je Konto (Identifikator-Art „Text") erraten.
function TransferKeywords() {
const { t } = useTranslation();
const toast = useToast();
const [words, setWords] = useState([]);
const [input, setInput] = useState("");
const [amount, setAmount] = useState("");
const [busy, setBusy] = useState(false);
async function laden() {
try { setWords(await api.listTransferKeywords()); }
catch (err) { toast(err.message, "error"); }
}
useEffect(() => { laden(); }, []);
async function hinzufuegen() {
const w = input.trim();
if (!w) return;
setBusy(true);
try {
const body = { word: w };
const c = parseAmountToCents(amount);
if (c) body.amount_cents = c;
await api.addTransferKeyword(body);
setInput("");
setAmount("");
await laden();
} catch (err) {
toast(err.message, "error");
} finally {
setBusy(false);
}
}
async function entfernen(id) {
try { await api.deleteKeyword(id); setWords((ws) => ws.filter((x) => x.id !== id)); }
catch (err) { toast(err.message, "error"); }
}
return (
<div className="card" style={{ marginTop: "var(--sp-4)" }}>
<h2>{t("transferRules.title")}</h2>
<p className="muted small">{t("transferRules.hint")}</p>
<div className="kw-add">
<input value={input} placeholder={t("transferRules.wordPlaceholder")}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
<input value={amount} inputMode="decimal" placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }} />
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
<Icon name="plus" size={15} />
</button>
</div>
{words.length === 0 ? (
<div className="muted small">{t("transferRules.empty")}</div>
) : (
<div className="kw-chips">
{words.map((w) => (
<span className="kw-chip" key={w.id}>
{w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} />
</button>
</span>
))}
</div>
)}
</div>
);
}
export default function Accounts() {
const { t } = useTranslation();
const { baseCurrency } = useSettings();
@@ -165,6 +239,8 @@ export default function Accounts() {
</div>
)}
<TransferKeywords />
{form && (
<Modal
title={editing ? t("accounts.edit") : t("accounts.new")}
@@ -238,9 +314,10 @@ export default function Accounts() {
<option value="iban">IBAN</option>
<option value="account_no">{t("identifierKinds.account_no")}</option>
<option value="card_no">{t("identifierKinds.card_no")}</option>
<option value="text">{t("identifierKinds.text")}</option>
</select>
<input className="grow" style={{ minWidth: 0 }} value={ident.value}
placeholder="DE.. / Nr."
placeholder={ident.kind === "text" ? t("accounts.textIdentPlaceholder") : "DE.. / Nr."}
onChange={(e) => {
const next = [...form.identifiers];
next[idx] = { ...ident, value: e.target.value };
+18 -2
View File
@@ -7,14 +7,17 @@ import Icon from "../components/Icon";
import IconChip from "../components/IconChip";
import Modal from "../components/Modal";
import IconPicker, { CATEGORY_ICONS } from "../components/IconPicker";
import { centsToInput, parseAmountToCents } from "../format";
// Popup zum Pflegen der Schlagwörter einer Kategorie. Wörter werden beim Import
// gegen Empfänger/Verwendungszweck geprüft und ordnen Buchungen automatisch zu.
// Optional lässt sich ein fester Betrag als Zusatzbedingung angeben.
function KeywordModal({ category, onClose }) {
const { t } = useTranslation();
const toast = useToast();
const [words, setWords] = useState([]);
const [input, setInput] = useState("");
const [amount, setAmount] = useState("");
const [busy, setBusy] = useState(false);
const [editId, setEditId] = useState(null);
const [editVal, setEditVal] = useState("");
@@ -30,8 +33,12 @@ function KeywordModal({ category, onClose }) {
if (!w) return;
setBusy(true);
try {
await api.addKeyword(category.id, w);
const body = { word: w };
const c = parseAmountToCents(amount);
if (c) body.amount_cents = c;
await api.addKeyword(category.id, body);
setInput("");
setAmount("");
await laden();
} catch (err) {
toast(err.message, "error");
@@ -61,7 +68,7 @@ function KeywordModal({ category, onClose }) {
const orig = words.find((x) => x.id === id);
if (!val || (orig && val === orig.word)) { abbrechenBearbeiten(); return; }
try {
await api.updateKeyword(id, val);
await api.updateKeyword(id, { word: val });
abbrechenBearbeiten();
await laden();
} catch (err) {
@@ -87,6 +94,14 @@ function KeywordModal({ category, onClose }) {
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
/>
<input
value={amount}
inputMode="decimal"
placeholder={t("categories.keywordAmount")}
style={{ flex: "0 0 120px", minWidth: 0, marginTop: 0 }}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); hinzufuegen(); } }}
/>
<button className="btn" onClick={hinzufuegen} disabled={busy || !input.trim()}>
<Icon name="plus" size={15} />
</button>
@@ -120,6 +135,7 @@ function KeywordModal({ category, onClose }) {
onDoubleClick={() => starteBearbeiten(w)}
>
{w.word}
{w.amount_cents ? <span className="kw-amount">= {centsToInput(w.amount_cents)}</span> : null}
<button type="button" title={t("common.delete")} onClick={() => entfernen(w.id)}>
<Icon name="close" size={13} />
</button>
+11 -11
View File
@@ -6,9 +6,9 @@
--border-strong: #d1d6dc;
--text: #1a1f27;
--muted: #6b7480;
--accent: #3f51b5;
--accent-hover: #34429a;
--accent-soft: #eceefb;
--accent: #3f9c72;
--accent-hover: #34825f;
--accent-soft: #e6f3ec;
--danger: #c0392b;
--danger-soft: #fbeceb;
--warn: #b06f14;
@@ -536,7 +536,7 @@ td select { width: auto; min-width: 0; max-width: 100%; }
}
.alert .icon { margin-top: 1px; }
.alert.error { background: var(--danger-soft); color: var(--danger); border-color: #f0c8c4; }
.alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #cfd6f4; }
.alert.info { background: var(--accent-soft); color: var(--accent-hover); border-color: #c3e6d3; }
.alert.warn { background: var(--warn-soft); color: var(--warn); border-color: #f0dcb8; }
.alert.ok { background: #e6f4ec; color: var(--ok); border-color: #c3e3d0; }
@@ -1008,9 +1008,9 @@ select.zeitraum:hover { color: var(--text); }
--border-strong: #333c4a;
--text: #e6edf3;
--muted: #8b949e;
--accent: #7c8ae8;
--accent-hover: #93a0ef;
--accent-soft: #1e2340;
--accent: #6cc39d;
--accent-hover: #82d0af;
--accent-soft: #16291f;
--danger: #f0857a;
--danger-soft: #2a1a1a;
--warn: #e0a955;
@@ -1023,14 +1023,14 @@ select.zeitraum:hover { color: var(--text); }
}
:root[data-theme="dark"] {
--bg: #0d1117; --surface: #161b22; --surface-2: #1c2230; --border: #262d38;
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #7c8ae8;
--accent-hover: #93a0ef; --accent-soft: #1e2340; --danger: #f0857a; --danger-soft: #2a1a1a;
--border-strong: #333c4a; --text: #e6edf3; --muted: #8b949e; --accent: #6cc39d;
--accent-hover: #82d0af; --accent-soft: #16291f; --danger: #f0857a; --danger-soft: #2a1a1a;
--warn: #e0a955; --warn-soft: #2a2312; --ok: #5fbf8c;
}
:root[data-theme="light"] {
--bg: #f6f7f9; --surface: #ffffff; --surface-2: #f1f3f5; --border: #e3e6ea;
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f51b5;
--accent-hover: #34429a; --accent-soft: #eceefb; --danger: #c0392b; --danger-soft: #fbeceb;
--border-strong: #d1d6dc; --text: #1a1f27; --muted: #6b7480; --accent: #3f9c72;
--accent-hover: #34825f; --accent-soft: #e6f3ec; --danger: #c0392b; --danger-soft: #fbeceb;
--warn: #b06f14; --warn-soft: #fbf3e6; --ok: #2e7d55;
}